Summary

Not every approach that looks compelling in a paper survives contact with live-cost, out-of-sample evaluation. Three research threads were pursued in parallel with the strategies currently in the working file. All three were formally rejected after failing the promotion gate. The rejections are documented here alongside the core code used to test each hypothesis.

3 Hypotheses tested
0 Promoted to paper observation
1.5 Promotion threshold — Sharpe
Archived Status — negative results documented

Hypothesis 1 — Distance-based pairs (Gatev et al. 2006)

The distance method from Gatev, Goetzmann & Rouwenhorst is the benchmark pairs-trading approach in the academic literature. It ranks pairs by the sum of squared deviations of normalised prices over a formation window and opens positions when the spread exceeds two standard deviations of its formation-period distribution.

Why it was rejected: the approach assumes the spread is stationary through the holding period but does not test for cointegration. In a universe of FX majors and crosses, a large fraction of pairs with low formation-period distance are spuriously similar — they co-move in a trend rather than mean-revert. The OOS Sharpe on stressed costs was 0.61, well below the 1.5 threshold.

import numpy as np
import pandas as pd

def select_pairs_distance(prices: pd.DataFrame, n_pairs: int = 20) -> list[tuple]:
    """
    Gatev et al. (2006) distance-based pair selection.
    Ranks all pairs by SSD of normalised price series over formation window.
    Returns the top-n pairs by lowest SSD.
    """
    normed = prices / prices.iloc[0]
    labels = normed.columns.tolist()
    ssd: dict[tuple, float] = {}

    for i in range(len(labels)):
        for j in range(i + 1, len(labels)):
            diff = normed[labels[i]] - normed[labels[j]]
            ssd[(labels[i], labels[j])] = float((diff ** 2).sum())

    return sorted(ssd, key=ssd.get)[:n_pairs]


def distance_signals(prices: pd.DataFrame, pairs: list[tuple],
                     formation_days: int = 252) -> pd.DataFrame:
    """
    Generate long/short signals for each pair.
    Enter when spread > 2 std devs of formation-period spread.
    Exit at zero crossing or after max_hold bars.
    """
    signals = pd.DataFrame(index=prices.index, columns=pairs, dtype=float).fillna(0.0)

    for sym_a, sym_b in pairs:
        spread = prices[sym_a] - prices[sym_b]
        mu     = spread.rolling(formation_days).mean()
        sigma  = spread.rolling(formation_days).std()
        z      = (spread - mu) / sigma.replace(0, np.nan)
        signals[(sym_a, sym_b)] = np.where(z > 2, -1.0,
                                   np.where(z < -2,  1.0, np.nan))
        signals[(sym_a, sym_b)] = signals[(sym_a, sym_b)].ffill().fillna(0.0)

    return signals

The critical flaw is visible at the z-score construction: mu and sigma are rolling from a fixed window anchored to the current bar, so the signal does not account for non-stationarity in the spread. The OU-process model used in the live sleeve explicitly tests for cointegration and fits a mean-reversion speed — this additional constraint eliminated the spurious pairs and raised the OOS Sharpe above 2.0.


Hypothesis 2 — VIX-regime filter on Gold H4

The academic intuition was that Gold’s mean-reversion characteristics differ across volatility regimes: the strategy should only be active when the VIX is below a threshold associated with risk-off trending, and flat otherwise. Several papers document regime-conditional behaviour in commodity futures.

Why it was rejected: after fitting the regime threshold in-sample, the filter added no predictive value on the OOS window. The conditional Sharpe inside the “favourable” regime was not significantly different from the unconditional Sharpe, and the filter reduced trade frequency enough to widen the confidence interval substantially. Net effect: lower risk-adjusted return, higher estimation error.

import pandas as pd
import numpy as np

def apply_vix_filter(signals: pd.Series, vix: pd.Series,
                     threshold: float = 20.0) -> pd.Series:
    """
    Zero out signals when VIX is above threshold.
    Hypothesis: Gold H4 strategy only valid in low-vol regime.
    """
    aligned_vix = vix.reindex(signals.index, method='ffill')
    active = aligned_vix < threshold
    return signals.where(active, other=0.0)


def evaluate_conditional_sharpe(returns: pd.Series, vix: pd.Series,
                                 threshold: float = 20.0,
                                 ann_factor: float = 252.0) -> dict:
    aligned_vix = vix.reindex(returns.index, method='ffill')
    below = returns[aligned_vix <  threshold]
    above = returns[aligned_vix >= threshold]

    def sharpe(r):
        if r.std() == 0 or len(r) < 10:
            return np.nan
        return (r.mean() / r.std()) * np.sqrt(ann_factor)

    return {
        'unconditional':  sharpe(returns),
        'below_threshold': sharpe(below),
        'above_threshold': sharpe(above),
        'pct_time_active': len(below) / len(returns),
    }

The evaluate_conditional_sharpe output on the OOS window showed below_threshold Sharpe of 1.48 against an unconditional Sharpe of 1.97 — the filter was removing good trades not bad ones. The hypothesis was rejected and the unfiltered Gold H4 v4 configuration was carried forward.


Hypothesis 3 — ARIMA signal generation for FX intraday

The third thread tested whether an ARIMA(p,d,q) model fitted to the rolling residuals of a cointegrated FX pair could generate directional signals superior to a fixed-threshold OU entry. The academic basis was the Box-Jenkins framework applied to mean-reverting spreads, with the ARIMA capturing higher-order autocorrelation structure beyond the AR(1) assumed by the OU model.

Why it was rejected: ARIMA parameter instability. The optimal (p, d, q) order selected by AIC on each rolling window was highly variable — switching between (1,0,1), (2,0,0), and (0,0,2) across adjacent windows. This instability propagated into the signals: out-of-sample hit rate was 51.3%, statistically indistinguishable from chance, and the stressed-cost Sharpe was 0.29.

from statsmodels.tsa.arima.model import ARIMA
import pandas as pd
import numpy as np
import warnings

def arima_signal(spread: pd.Series, window: int = 120,
                 max_p: int = 3, max_q: int = 3) -> pd.Series:
    """
    Rolling ARIMA signal on spread. Selects order by AIC each window.
    Returns +1 (buy spread), -1 (sell spread), 0 (flat).
    Rejected: order instability caused OOS Sharpe of 0.29.
    """
    signals = pd.Series(index=spread.index, dtype=float).fillna(0.0)

    for t in range(window, len(spread)):
        train = spread.iloc[t - window: t]
        best_aic, best_order = np.inf, (1, 0, 0)

        for p in range(max_p + 1):
            for q in range(max_q + 1):
                try:
                    with warnings.catch_warnings():
                        warnings.simplefilter('ignore')
                        m = ARIMA(train, order=(p, 0, q)).fit()
                    if m.aic < best_aic:
                        best_aic, best_order = m.aic, (p, 0, q)
                except Exception:
                    continue

        try:
            with warnings.catch_warnings():
                warnings.simplefilter('ignore')
                model   = ARIMA(train, order=best_order).fit()
                forecast = model.forecast(steps=1).iloc[0]
            signals.iloc[t] = 1.0 if forecast < 0 else (-1.0 if forecast > 0 else 0.0)
        except Exception:
            pass

    return signals

The OU-process entry used in the live sleeve sidesteps this instability by estimating only two parameters (mean-reversion speed κ and long-run mean μ) on a longer window, which are far more stable across rolling periods. The ARIMA approach was archived as a negative result.


On the value of rejection

Each of these three threads cost roughly two to three weeks of research time. The negative results are genuinely useful: they narrow the hypothesis space, they stress-test the promotion gate, and they make the positive results more credible by contrast. All three rejection reports are stored in the versioned research file alongside the passing strategies.