📝

Draft article

This draft is visible to admins and superusers only. Sign in with an authorized account.

← Terug naar artikelen
August 7, 2026
5 min leestijd

Toda-Yamamoto vs Differenced Granger: Does the BTC Lead-Lag Survive?

#causal-inference
#Granger-causality
#lead-lag
#crypto
#VAR

Almost every published Granger-causality result on crypto is computed on log-returns. That choice is not free. Differencing makes the series stationary, which the standard F-test requires, but it also discards the level relationship — and if two coins are cointegrated, the differenced VAR is misspecified and the test is answering a slightly different question than the one you asked.

Toda and Yamamoto (1995) offer a way out: fit the VAR on levels with extra lags, test only the original ones, and get a valid χ2\chi^2 regardless of unit roots or cointegration. It is well known in econometrics and almost never applied to crypto lead-lag.

So this article does one thing: run both tests on the same pairs, over the same window, and report whether they disagree. Then apply the honest follow-up — a correlated-family multiple-testing correction on the resulting matrix, and a rolling p-value to see whether the "significant" lag is stable enough to trade or just flickers across the threshold.

The theory below is only as much as is needed to make the comparison legible. The blog already covers unit roots, cointegration, data loading, sizing, and costs; those are links, not sections.

What Granger causality actually claims

Granger causality is predictive precedence, not mechanism: XX Granger-causes YY if past XX reduces the forecast error variance of YY beyond what past YY already explains.

XX Granger-causes YY if σ2(YtYt1,,Ytp)>σ2(YtYt1,,Ytp,Xt1,,Xtp)\sigma^2(Y_t \mid Y_{t-1}, \ldots, Y_{t-p}) > \sigma^2(Y_t \mid Y_{t-1}, \ldots, Y_{t-p}, X_{t-1}, \ldots, X_{t-p})

Two series driven by a common hidden factor can show Granger causality with no direct link between them. In crypto that caveat is not academic — BTC is a single dominant factor across the whole altcoin complex, so almost any alt-to-alt "causality" is a candidate artifact of differing response speeds to the same BTC shock.

The VAR framework

The test lives inside a Vector Autoregression. For two variables with pp lags:

Yt=α0+i=1pαiYti+i=1pβiXti+ϵ1tY_t = \alpha_0 + \sum_{i=1}^{p} \alpha_i Y_{t-i} + \sum_{i=1}^{p} \beta_i X_{t-i} + \epsilon_{1t} Xt=γ0+i=1pγiXti+i=1pδiYti+ϵ2tX_t = \gamma_0 + \sum_{i=1}^{p} \gamma_i X_{t-i} + \sum_{i=1}^{p} \delta_i Y_{t-i} + \epsilon_{2t}

In matrix form, a VAR(p) with kk variables:

yt=c+A1yt1++Apytp+ut\mathbf{y}_t = \mathbf{c} + \mathbf{A}_1 \mathbf{y}_{t-1} + \cdots + \mathbf{A}_p \mathbf{y}_{t-p} + \mathbf{u}_t

Testing whether XX Granger-causes YY is testing the joint restriction H0:β1==βp=0H_0: \beta_1 = \cdots = \beta_p = 0 in the first equation. The standard route is an F-test on restricted vs unrestricted residual sums of squares,

F=(RSSrRSSu)/pRSSu/(T2p1)F = \frac{(RSS_r - RSS_u) / p}{RSS_u / (T - 2p - 1)}

with the asymptotically equivalent Wald form W=T(RSSrRSSu)/RSSudχ2(p)W = T \cdot (RSS_r - RSS_u)/RSS_u \xrightarrow{d} \chi^2(p). Everything that follows is a question about which coefficients that restriction is applied to, and on what data the VAR was fitted.

Lag selection

pp matters more than most write-ups admit: too few lags miss the dynamics, too many burn degrees of freedom and destroy power.

AIC(p)=lnΣ^u(p)+2pk2T,BIC(p)=lnΣ^u(p)+pk2lnTT\text{AIC}(p) = \ln|\hat{\Sigma}_u(p)| + \frac{2pk^2}{T}, \qquad \text{BIC}(p) = \ln|\hat{\Sigma}_u(p)| + \frac{pk^2 \ln T}{T} HQIC(p)=lnΣ^u(p)+2pk2ln(lnT)T\text{HQIC}(p) = \ln|\hat{\Sigma}_u(p)| + \frac{2pk^2 \ln(\ln T)}{T}

Reasonable search ranges: 1-60 on second data, 1-30 on minute data, 1-48 on hourly. BIC is the right default here — it is the more parsimonious criterion, and lead-lag in liquid crypto pairs is a short-memory phenomenon. Every result below reports the BIC-selected pp per pair rather than fixing one lag across the universe, because a fixed lag silently converts a lag-selection choice into a significance claim.

Why the differenced test is the wrong default

Crypto price series are I(1). The usual fix — take log-returns — buys stationarity at the cost of the cointegrating level, and if the pair shares a long-run equilibrium the differenced VAR is misspecified. The unit-root and cointegration machinery behind that sentence (ADF, Engle-Granger with its Monte-Carlo critical values, Johansen on a VAR system) is already covered in statistical arbitrage and pairs trading; assume it here. The point is only that the usual remedy — difference, then test — is a modelling decision with consequences, and Toda-Yamamoto is the way to avoid making it.

The Toda-Yamamoto procedure

Fit a VAR with p+dmaxp + d_{max} lags, where pp is the optimal lag order and dmaxd_{max} is the maximum integration order of the series, then test restrictions only on the first pp lags. The extra dmaxd_{max} lags absorb the non-stationarity; the Wald statistic on the first pp coefficients follows a standard χ2(p)\chi^2(p) regardless of whether the series are I(0), I(1), or cointegrated.

  1. Determine dmaxd_{max} — ADF and KPSS on each series. For crypto prices dmax=1d_{max} = 1 almost always.
  2. Select pp — fit a VAR on levels, choose by BIC.
  3. Estimate the augmented VAR(p+dmaxp + d_{max}) on levels, no differencing.
  4. Wald-test the first pp lags only, ignoring the dmaxd_{max} extras. Result is χ2(p)\chi^2(p).

The payoff: no cointegration pre-test, no differencing, and correct test size — the rejection rate under the null stays near nominal whatever the integration properties turn out to be. The cost is that step 4 is not what test_causality does by default, which is where most implementations quietly go wrong.

Implementation

Assume you already have aligned minute bars in a DataFrame — the ccxt fetch-and-index boilerplate is in regime detection with HMM, and the ADF wrapper you would use for step 1 is in statistical arbitrage and pairs trading.

One version note before any of this runs: grangercausalitytests(..., verbose=False) was deprecated and then removed in statsmodels 0.15. Drop the argument (the function no longer prints by default) or pin statsmodels<0.15. Code below assumes the modern signature.

Toda-Yamamoto, done correctly

The restriction has to be built by hand. VARResults.test_causality tests all lags of the causing variable in the fitted model — on an augmented VAR(p+1p+1) that includes the dmaxd_{max} lag in the restriction, which is exactly what Toda-Yamamoto says not to do. The fix is an explicit RR matrix against the full coefficient covariance:

import numpy as np
from scipy.stats import chi2
from statsmodels.tsa.api import VAR


def toda_yamamoto_test(data, target, predictor, max_lag=15, d_max=1,
                       significance=0.05):
    """
    Toda-Yamamoto Granger causality on levels (no differencing).

    Fits VAR(p + d_max) and applies a Wald test to the predictor's
    coefficients at lags 1..p ONLY, leaving the d_max augmenting lags
    unrestricted. Statistic is chi2(p).
    """
    pair = data[[target, predictor]].dropna()
    n_vars = pair.shape[1]
    target_idx = list(pair.columns).index(target)
    pred_idx = list(pair.columns).index(predictor)

    model = VAR(pair)
    p = model.select_order(maxlags=max_lag).bic or 1
    res = model.fit(p + d_max)

    beta = res.params.values.ravel(order="F")
    cov = res.cov_params()
    cov = cov.values if hasattr(cov, "values") else np.asarray(cov)

    n_per_eq = res.params.shape[0]
    idx = [target_idx * n_per_eq + 1 + lag * n_vars + pred_idx
           for lag in range(p)]                      # first p lags only

    R = np.zeros((p, beta.size))
    R[np.arange(p), idx] = 1.0

    Rb = R @ beta
    V = R @ cov @ R.T
    wald = float(Rb @ np.linalg.solve(V, Rb))        # no pinv: V must be PD
    p_value = float(chi2.sf(wald, df=p))

    return {
        "target": target, "predictor": predictor,
        "p": p, "augmented_lag": p + d_max, "d_max": d_max,
        "wald_stat": wald, "p_value": p_value,
        "significant": p_value < significance,
    }

Two details that are easy to get wrong and that invalidate the test if you do. First, the index arithmetic must match how statsmodels flattens params — verify on a fitted model that beta[target_idx * n_per_eq + 1] equals res.params.iloc[1, target_idx] before trusting any p-value. Second, use np.linalg.solve, not pinv: if VV is singular the restriction is degenerate and the run should fail loudly rather than return a plausible-looking number. A pseudo-inverse here is how broken Wald tests survive code review.

Standard differenced Granger, for comparison

The baseline the comparison is against — log-returns, same pairs, same BIC lag:

from statsmodels.tsa.stattools import grangercausalitytests


def differenced_granger(data, target, predictor, p):
    """Standard Granger test on log-returns at a fixed lag p."""
    pair = data[[target, predictor]].dropna()   # returns, not levels
    out = grangercausalitytests(pair, maxlag=[p])   # statsmodels >= 0.15
    f_stat, p_value = out[p][0]["ssr_ftest"][:2]
    return {"target": target, "predictor": predictor, "lag": p,
            "f_stat": f_stat, "p_value": p_value}

Note maxlag=[p] rather than maxlag=p: passing an int runs every lag from 1 to pp and tempts you to report the best one, which is an unlogged multiple test on top of the multiple test you are already running.

The comparison

For each pair, run the differenced test on returns and Toda-Yamamoto on levels, at the same BIC-selected pp, and tabulate where the two disagree. Disagreement is the interesting cell: a pair significant on differences but not on levels is a candidate artifact of differencing away a cointegrating relationship; the reverse suggests the level relationship carries information the return test cannot see.

Multiple comparisons on a causality matrix

The full n×nn \times n matrix is where this gets dangerous. A 20-asset, 10-lag sweep is 3,800 hypothesis tests, and Bonferroni is the wrong correction for a family this correlated — the tests share data, share the BTC factor, and are nowhere near independent, so Bonferroni is simultaneously too conservative in aggregate and misleading about which cells survive. Use the effective-N machinery from the deflated Sharpe article, which is built for exactly this situation: cluster the correlated test family, count independent trials rather than raw ones, and threshold against that.

def granger_matrix(data, max_lag=15, d_max=1):
    """Toda-Yamamoto p-value matrix. Entry (i, j): does col_j cause col_i?"""
    cols = list(data.columns)
    out = pd.DataFrame(np.nan, index=cols, columns=cols, dtype=float)
    for target in cols:
        for predictor in cols:
            if target == predictor:
                continue
            r = toda_yamamoto_test(data, target, predictor,
                                   max_lag=max_lag, d_max=d_max)
            out.loc[target, predictor] = r["p_value"]
    return out

The number that matters is not how many cells are below 0.05 — it is how many survive the effective-N corrected threshold.

Read the surviving matrix structurally, not cell by cell: rows where one asset causes many others confirm an information hierarchy; a column caused by nothing suggests idiosyncratic, token-specific dynamics rather than a discovery.

The honest test: rolling stability

A single in-sample p-value is close to worthless for trading. The relevant question is whether significance persists. Cross-asset relationships in crypto are regime-dependent — correlation structure differs sharply between calm and panic, and the regime itself is estimable (HMM regime detection) — so a lead-lag estimated over one window is a statement about that window.

def rolling_granger(data, target, predictor, window=500, lag=5, step=50):
    """Rolling-window p-value: when is the lead-lag active vs dormant?"""
    rows = []
    for start in range(0, len(data) - window, step):
        chunk = data.iloc[start:start + window][[target, predictor]].dropna()
        if len(chunk) < window * 0.8:
            continue
        try:
            out = grangercausalitytests(chunk, maxlag=[lag])
            f_stat, p_value = out[lag][0]["ssr_ftest"][:2]
        except Exception:
            f_stat, p_value = np.nan, np.nan
        rows.append({"timestamp": data.index[start + window - 1],
                     "f_stat": f_stat, "p_value": p_value})
    return pd.DataFrame(rows).set_index("timestamp")

Report two things from this series: the fraction of windows below 0.05, and the number of threshold crossings. A relationship that is significant in 80% of windows with three crossings is a different object from one significant in 55% of windows with forty crossings, even if the pooled p-value is identical. The second is not tradeable — you cannot size a position on a signal whose existence flips every few hundred bars, and re-estimation lag guarantees you are always trading the previous regime.

The data trap that is specific to Granger

Generic crypto data hygiene is covered elsewhere — gaps and missing candles in backtest-live parity, timestamp discipline in the look-ahead bias taxonomy and proving no look-ahead across timeframes. One failure mode, though, is Granger-specific and severe enough to name:

Forward-filling manufactures the result. A forward-filled candle repeats the previous close, which injects pure autocorrelation into the series — and lag-1 autocorrelation in YY that is mechanically aligned with XX is indistinguishable, to the F-test, from a one-lag causal relationship. The same applies across venues: a 1-second clock offset between two exchanges on minute bars can create or destroy lag-1 significance outright, because it shifts which side of the bar boundary a move lands on. Before running any of this, verify that gaps are dropped rather than filled, and that both series are stamped from the same clock.

Reported lead-lag findings in the literature

These are citations, not measurements from this article. They are here to say what the published record claims, so that the comparison above has something to agree or disagree with.

  • BTC to altcoins. A 2026 study in Asia-Pacific Financial Markets (Springer) reports high-frequency price transmission from Bitcoin to altcoins, with small-cap coins showing significantly delayed responses and lower liquidity associated with slower reactions. The lag magnitudes commonly quoted from this line of work — roughly 1-3 minutes BTC to ETH, longer for mid- and small-caps, increasingly unidirectional as market cap falls — are cited findings, not re-derived here. The market-cap tiering they rest on is the same ladder used in multi-symbol validation.
  • CEX leads DEX. Research on crypto microstructure (MDPI) reports centralized venues dominating price discovery, with information flow running CEX-to-DEX and no significant reverse causality — consistent with sub-millisecond matching engines versus block-time settlement.
  • ETH as an independent coin. VAR-SVAR work (MDPI) finds regimes in which Ethereum acts as the source and Bitcoin as the spillover recipient, notably during DeFi- and NFT-driven phases.

Cross-exchange, briefly

The same-coin-two-venues lag is a real setup, but the economics of it — fee thresholds, the latency ladder, why the lag exists at all — are already worked through in statistical arbitrage and pairs trading and the kimchi premium. The only thing Granger adds is a stability question: is Binance-to-Coinbase causality persistently significant, or does it flicker like everything else? Run the rolling test on the venue pair; the answer is a p-value time series, not a yes.

Turning a surviving lag into a position

If a pair clears the corrected threshold and the rolling test, the signal itself is trivial: cumulative predictor return over the lag window, thresholded.

def lead_lag_signal(btc_returns, alt_returns, lag=5, threshold=0.001):
    """+1 / -1 / 0 on ALT from BTC's cumulative return over `lag` bars."""
    btc_cum = btc_returns.rolling(lag).sum()
    signal = pd.Series(0, index=alt_returns.index)
    signal[btc_cum > threshold] = 1
    signal[btc_cum < -threshold] = -1
    return signal

Three things that are not part of this article because they are already settled here:

Limitations

Granger causality is not causality. Confounders (a macro event moving both legs with slight timestamp misalignment), common drivers (two alts both following BTC at different speeds), and omitted variables (testing BTC to DOGE without ETH in the system) all produce significant statistics with no direct information flow. Restricting to bivariate tests, as this article does, makes the omitted-variable problem worse, not better.

Linearity. The test sees only linear predictive structure in the conditional mean. Crypto has volatility clustering, leverage effects, and regime switches — see GARCH volatility forecasting and DCC-GARCH dynamic correlation for the second-moment story. Nonlinear alternatives worth knowing: the Diks-Panchenko (2006) kernel test, transfer entropy as the information-theoretic analog, and copula Granger causality across the full joint distribution.

Structural breaks. Any relationship estimated on one window is conditional on that window's regime; the rolling test above is the minimum diagnostic, and it is a diagnostic, not a fix.

Summary

  1. Fit the VAR on levels, select pp by BIC per pair, augment by dmaxd_{max}, and Wald-test the first pp lags only. Build the restriction matrix by hand — the library's test_causality restricts the augmenting lag too, which is not Toda-Yamamoto.
  2. Run the differenced return test as a baseline on the same pairs and lag, and report the disagreements. That table is the finding; a matrix of asterisks is not.
  3. Correct the matrix for a correlated test family via effective N, not Bonferroni, and report how many cells survive.
  4. Roll it. Report the fraction of significant windows and the crossing count. Significance that flickers is not a tradeable lag no matter how small the pooled p-value.
  5. Drop gaps instead of forward-filling, and check clock alignment across venues, before believing any lag-1 result.

A perfectly good outcome of this procedure is negative — a BTC-to-alt relationship that is significant in-sample, survives correction, and still crosses the 0.05 line in both directions every few hundred bars is a real result about tradeability, and worth publishing as one.

References

Disclaimer: De informatie in dit artikel is uitsluitend bedoeld voor educatieve en informatieve doeleinden en vormt geen financieel, beleggings- of handelsadvies. Het handelen in cryptovaluta brengt een aanzienlijk risico op verlies met zich mee.

Auteurs

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Blijf de markt voor

Abonneer je op onze nieuwsbrief voor exclusieve AI-handelsinzichten, marktanalyses en platformupdates.

We respecteren je privacy. Je kunt je op elk moment afmelden.