Toda-Yamamoto vs Differenced Granger: Does the BTC Lead-Lag Survive?
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 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: Granger-causes if past reduces the forecast error variance of beyond what past already explains.
Granger-causes if
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 lags:
In matrix form, a VAR(p) with variables:
Testing whether Granger-causes is testing the joint restriction in the first equation. The standard route is an F-test on restricted vs unrestricted residual sums of squares,
with the asymptotically equivalent Wald form . Everything that follows is a question about which coefficients that restriction is applied to, and on what data the VAR was fitted.
Lag selection
matters more than most write-ups admit: too few lags miss the dynamics, too many burn degrees of freedom and destroy power.
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 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 lags, where is the optimal lag order and is the maximum integration order of the series, then test restrictions only on the first lags. The extra lags absorb the non-stationarity; the Wald statistic on the first coefficients follows a standard regardless of whether the series are I(0), I(1), or cointegrated.
- Determine — ADF and KPSS on each series. For crypto prices almost always.
- Select — fit a VAR on levels, choose by BIC.
- Estimate the augmented VAR() on levels, no differencing.
- Wald-test the first lags only, ignoring the extras. Result is .
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() that includes the lag in the restriction, which is exactly what Toda-Yamamoto says not to do. The fix is an explicit 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 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 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 , 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 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 that is mechanically aligned with 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:
- Costs decide it. A minute-scale lag signal on altcoins has to clear spread, impact, and fees — see slippage and cost models and maker-taker economics, and what happens when costs are applied to a selected winner.
- Sizing is not a p-value transform. Scaling exposure inversely with the Granger p-value is wrong: a p-value is evidence against a null, not an effect size, and it moves with sample length. Size on the estimated effect and its uncertainty — see Kelly sizing.
- Validation is walk-forward. Re-estimate on a rolling basis and evaluate out-of-sample; walk-forward optimization is the protocol.
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
- Fit the VAR on levels, select by BIC per pair, augment by , and Wald-test the first lags only. Build the restriction matrix by hand — the library's
test_causalityrestricts the augmenting lag too, which is not Toda-Yamamoto. - 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.
- Correct the matrix for a correlated test family via effective N, not Bonferroni, and report how many cells survive.
- 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.
- 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
- Granger, C.W.J. (1969). "Investigating Causal Relations by Econometric Models and Cross-spectral Methods." Econometrica, 37(3), 424-438.
- Toda, H.Y. and Yamamoto, T. (1995). "Statistical inference in vector autoregressions with possibly integrated processes." Journal of Econometrics, 66(1-2), 225-250.
- Diks, C. and Panchenko, V. (2006). "A new statistic and practical guidelines for nonparametric Granger causality testing." Journal of Economic Dynamics and Control, 30(9-10), 1647-1669.
- Sifat, I.M., Mohamad, A. (2019). "Lead-Lag relationship between Bitcoin and Ethereum: Evidence from hourly and daily data." Research in International Business and Finance, 50, 306-321.
- "Price Transmission from Bitcoin to Altcoins: High-Frequency Evidence and Implications for Trading Strategy." Asia-Pacific Financial Markets, Springer, 2026.
- "Spillover Risks on Cryptocurrency Markets: A Look from VAR-SVAR Granger Causality." Journal of Risk and Financial Management, MDPI.
- "The Two-Tiered Structure of Cryptocurrency Funding Rate Markets." Mathematics, MDPI.
- "Decentralized and centralized exchanges: Which digital tokens pose a greater contagion risk?" ScienceDirect, 2023.
Autori
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.