Time-series analysis for OHLC
Before you fit a rule to a price series, you owe it one honest question: what kind of series is this? A trend model on a random walk and a reversion model on a runaway trend both lose money for the same reason — a mismatch between the strategy's assumption and the data's actual memory structure. This chapter is the toolkit for reading that structure: stationarity, the trend-vs-reversion tests, autocorrelation, volatility clustering, and seasonality — each with the exact edgekit.timeseries call.
Why stationarity matters#
A stationary series is one whose statistical fingerprint stays put: the mean you estimate on the first half is the mean you'll see in the second half, the variance is stable, and the correlation between two points depends only on the gap between them, not on where they sit in time. That is the assumption baked into nearly every estimator — a rolling mean, a z-score, a regression coefficient — because each one pools information across time as if the underlying process weren't moving under your feet.
A price series breaks that assumption in the most fundamental way. The canonical model of a price is a random walk: each price is the last price plus an unpredictable shock.
The variance of grows without bound as increases (it is ), so there is no fixed mean to revert to and no stable variance to standardise by. Compute a z-score of a random walk and the “distance from the mean” you get is meaningless, because the mean itself is wandering. This is why a strategy fit to price levels so often looks brilliant in-sample and dies out-of-sample: it learned the sample's particular wander, not a repeatable relationship.
The fix is to stationarise. Differencing the series once removes the stochastic trend, and for prices the natural first difference is the log return:
Returns hover around a stable mean of roughly zero with a (mostly) stable variance — a series statistics can actually work on. In edgekit that transform is one call, and the differencing primitive is there for higher orders or non-price series:
import edgekit as ek
rets = ek.timeseries.log_returns(bars.close) # stationarise prices -> returns
diff = ek.timeseries.difference(some_series, d=1) # general d-th order difference
Random walk vs mean-reversion: three tests#
Once you can transform a series, the first question worth money is which of three worlds it lives in: a random walk (unpredictable — no edge from direction), a trending world (moves persist, so momentum pays), or a mean-reverting world (moves overshoot and snap back, so fading pays). Three complementary tests triangulate the answer.
The variance ratio#
The Lo-MacKinlay variance ratio exploits a simple fact: for a random walk, variance grows linearly with the horizon. So the variance of -period returns should be exactly times the variance of one-period returns. The ratio of the two is the statistic:
Read it directly: is a random walk; means multi-period moves are larger than the random-walk baseline — positive autocorrelation, i.e. trend; means they are damped — negative autocorrelation, i.e. mean reversion. Sweep to see at which horizon the structure lives.
vr2 = ek.timeseries.variance_ratio(bars.close, k=2)
vr5 = ek.timeseries.variance_ratio(bars.close, k=5)
# vr > 1 -> trending on that horizon; < 1 -> mean-reverting; ~1 -> random walkThe Hurst exponent#
The Hurst exponent measures the same persistence from the scaling of dispersion across lags, boiling it into a single number in :
- — a random walk (independent increments).
- — persistent / long-memory: an up move tends to be followed by another up move (trend).
- — anti-persistent: moves tend to reverse (mean reversion).
h = ek.timeseries.hurst_exponent(bars.close, max_lag=50)
# ~0.5 random walk | >0.5 persistent/trend | <0.5 mean-revertingThe ADF test#
The Augmented Dickey-Fuller test asks the sharp version of the stationarity question: does the series have a unit root(the random-walk coefficient of exactly 1)? It regresses the change on the level and tests whether the level's coefficient is zero:
The null is (a unit root — non-stationary). The test returns a t-statistic on ; because a stationary series pulls itself back toward its mean, is negative, and a sufficiently negative t-stat rejects the unit root. The rule of thumb: below about is significant at 5%, below at 1%. A price level typically sits near zero (fails to reject — non-stationary); its returns sit far below the threshold (stationary).
adf_stat returns the raw t-statistic only — edgekit carries no scipy to look up an exact p-value. Compare the number to the MacKinnon critical values above. More negative = more clearly stationary.adf_price = ek.timeseries.adf_stat(bars.close) # near 0 -> non-stationary
adf_ret = ek.timeseries.adf_stat(ek.timeseries.log_returns(bars.close)) # very negative -> stationaryYou have two candidate series on the desk and want to know, before writing a single rule, which world each lives in. Series A is the BTCUSDT H4 close. Series B is a constructed spread — the residual of one index regressed on a close cousin (the stat-arb setup). You run the same three probes on each:
# Series A: BTC price level
ek.timeseries.variance_ratio(btc.close, k=5) # -> 1.28 (>1: multi-bar moves amplify -> trend)
ek.timeseries.hurst_exponent(btc.close, 50) # -> 0.58 (>0.5: persistent)
ek.timeseries.adf_stat(btc.close) # -> -1.4 (near 0: unit root, can't reject random walk)
# Series B: the mean-reverting spread
ek.timeseries.variance_ratio(spread, k=5) # -> 0.61 (<1: multi-bar moves damp -> reversion)
ek.timeseries.hurst_exponent(spread, 50) # -> 0.34 (<0.5: anti-persistent)
ek.timeseries.adf_stat(spread) # -> -3.9 (well below -3.43: reject unit root, stationary)Read together the verdict is unambiguous. Series A: VR above 1, Hurst above 0.5, ADF too shallow to reject a random walk — a trending / drifting series where a fade would get run over and a momentum idea is at least worth testing. Series B: VR below 1, Hurst below 0.5, ADF firmly past the 1% critical value — a genuinely mean-reverting spread you can build a z-score fade on. Three independent lenses agreeing is what turns a hunch into a prior. (These numbers are illustrative of the pattern, not a claim about any live instrument.)
One call for the verdict#
You rarely want to eyeball three numbers by hand. stationarity_report runs the ADF stat, the variance ratio, the Hurst exponent, and lag-1 autocorrelation together and returns a single verdict — "trending", "mean-reverting", or "random-walk" — plus the raw components so you can see why.
rep = ek.timeseries.stationarity_report(bars.close)
# {"adf_stat": ..., "variance_ratio": ..., "hurst": ...,
# "autocorr_lag1": ..., "verdict": "trending" | "mean-reverting" | "random-walk"}
if rep["verdict"] == "random-walk":
print("no directional edge here — don't fit a trend or reversion rule")Autocorrelation: the memory of a series#
The variance ratio and Hurst exponent both summarise autocorrelation — the correlation of a series with a lagged copy of itself. The ACF and PACF show it lag by lag, which is how you read the actual memory structure rather than a single scalar.
The autocorrelation function (ACF) at lag is the correlation between and . By construction . A slow, gradual decay signals persistence; a sharp cut-off after a few lags signals short-memory (moving-average) structure; bars that are all inside the noise band signal white noise — no linear predictability.
The partial autocorrelation function (PACF) gives the correlation at lag after removing the influence of all shorter lags. The division of labour is the classic Box-Jenkins reading: the PACF cutting off after lag suggests an AR(p) process, while the ACF cutting off after lag suggests an MA(q).
Significance is judged against the white-noise band. Under the null of no autocorrelation, each coefficient is approximately normal with standard error , so the two-sided 95% band is:
Bars poking outside that band are the ones worth a second look; bars inside it are noise. edgekit computes both functions directly (the PACF via Durbin-Levinson recursion — no statsmodels):
ac = ek.timeseries.acf(rets, nlags=40) # ac[0] == 1
pc = ek.timeseries.pacf(rets, nlags=40) # partial autocorrelations
band = 1.96 / len(rets) ** 0.5 # the +/- significance band
lag1 = ek.timeseries.autocorr(rets, lag=1) # single-lag convenience formAnd to ask “is there anyautocorrelation across the first several lags at all?” in one shot, the Ljung-Box portmanteau test pools them:
lb = ek.timeseries.ljung_box(rets, lags=10)
# lb == {"stat": Q, "dof": 10}; compare Q to a chi-squared(dof) — a large Q rejects white noise
Volatility clustering: returns are not iid#
Here is the twist that shapes almost every real strategy. Run the ACF on returns and you usually find very little — direction is close to unpredictable. Run it on squared (or absolute) returns and you find strong, slowly-decaying autocorrelation. That is volatility clustering: large moves cluster with large moves and calm with calm, even when the signof the next move is unpredictable. The famous one-liner is that returns are “serially uncorrelated but not independent.”
This is the empirical fact GARCH-family models formalise — tomorrow's variance is a weighted blend of a long-run level, yesterday's variance, and yesterday's squared shock — and it is why a single fixed stop distance is fragile: the size of a “normal” move is itself time-varying. edgekit gives you the practical estimators without the full GARCH fit. The RiskMetrics EWMA recursion is the workhorse:
Because recent squared returns carry more weight, EWMA vol turns on a dime when a shock hits — much faster than a flat rolling window, which dilutes the shock across the whole lookback. Use it to size risk in volatility units so a stop widens in stress and tightens in calm.
ewma = ek.timeseries.ewma_volatility(rets, lam=0.94) # RiskMetrics, fast-reacting
roll = ek.timeseries.rolling_volatility(rets, 20, periods_per_year=252) # annualised
rv = ek.timeseries.realized_vol(rets, window=20) # vol actually delivered
# confirm the clustering: autocorrelation of SQUARED returns is strongly positive
lb_sq = ek.timeseries.ljung_box(rets ** 2, lags=10)Scenario (why a fixed stop breaks). On BTCUSDT you run ljung_box(rets, 10) and get Q ≈ 9 against a chi-squared(10) — inside the noise band, direction is unpredictable. Then you run it on rets ** 2 and get Q ≈ 140 — a screaming rejection of white noise. Same series, opposite verdicts: the sign of tomorrow's move is a coin flip, but its sizeis highly forecastable. That is why the EWMA vol you'd have sized a stop off in the calm June regime (say 0.9% daily) is useless the day a 3% shock lands — the estimate reprices within a bar or two, and a stop pinned to the old number gets swept.

Seasonality & decomposition#
Some series carry a repeating calendar rhythm — a day-of-week pattern in equity-index returns, an hour-of-session pattern in intraday volume. seasonal_decompose splits a series into three additive parts over a fixed period so you can see the cycle on its own:
The trend is the slow drift, the seasonal component is the repeating cycle of length period, and the residual is what neither explains. If the seasonal component is small relative to the residual, there is no reliable cycle to trade — the honest and common outcome.
dec = ek.timeseries.seasonal_decompose(daily_ret, period=5) # weekly cycle on daily bars
# dec == {"trend": Series, "seasonal": Series, "resid": Series}
dec["seasonal"] # the repeating component; compare its scale to dec["resid"]Scenario (a “Monday effect” that isn't). You decompose daily US500 returns with period=5and the seasonal component shows Monday averaging −0.06% and Wednesday +0.05%. Tempting. But the residual's daily standard deviation is 1.1% — the seasonal swing is one-twentieth the noise it swims in. At the desk that means the “pattern” would be buried by a single ordinary day's move: not a tradeable cycle, just the sample's particular wobble. A seasonal component only earns a second look when its amplitude is a meaningful fraction of the residual, which is the honest-and-usually-disappointing outcome here.

Where this leads#
You now have the vocabulary to describe a series honestly: stationary or not, trending or reverting or random, which lags carry memory, whether volatility clusters, whether a calendar cycle exists. The next step is turning that description into inputs a strategy can act on — without smuggling the future into the past.
Next: Feature engineering — how make_features turns raw OHLC into a causal feature matrix, and the leakage traps that quietly destroy otherwise-sound models.



