Mean reversion
Fit a mean reversion model to a daily series, read its level, speed and z-score, project its path and touch chance, and size a trade.
The mean reversion functions fit a model of a series that returns to a fixed level. From the fit they derive the level, the speed of return, today's distance from the level, and the chance of touching it. They suit rates and spreads such as SOFR minus EFFR. The Calculator and the Sheet run the same functions. For a worked Sheet built from them, see Build a mean reversion model.
The model
The model is the Ornstein-Uhlenbeck process. A series is pulled toward a level, harder the further it is from that level, and takes random shocks. Three parameters describe it:
- kappa: the speed of the pull, per year. A larger kappa closes a gap faster.
- mu: the level the series returns to, in the series' units.
- sigma: the size of the shocks, in the series' units per square root of a year.
Every fit function takes the series first, then the fit window. The fit
uses the last window points of the series, or all of them when the
series is shorter. It regresses each value on the previous value by least
squares. The regression gives a slope b, an intercept a and a
residual standard error. The residual standard error divides by the number of pairs minus 2.
The parameters follow from the slope by the exact conversion of a daily step, with one step equal to 1/252 of a year:
- kappa = -ln(b) * 252
- mu = a / (1 - b)
- sigma = residual standard error * sqrt(-2 ln(b) / ((1/252) * (1 - b^2)))
The fit pairs points by position. Consecutive points count as consecutive
trading days whatever their dates. A pair with a missing value on either
side is dropped. The latest value used by ou.z, ou.carry, ou.path
and ou.fpt is the last non-missing point in the window.
Every ou function fails in these cases:
- E_TYPE: the first argument is not a series.
- E_DOMAIN: the window is not a positive whole number. For
ouand the one-number fit functions, a window below 2 is also E_DOMAIN. - E_NO_DATA: the window has fewer than 3 usable pairs of consecutive values.
- E_DOMAIN: the values in the window are constant, or the slope
bis outside 0 to 1. A slope of 1 or more means the series does not revert over the window. A slope of 0 or less has no mean reversion speed to convert.
Fit a model
ou(s, window): the fitted parameters as a 3 by 1 Matrix, kappa, mu
and sigma from top to bottom. window defaults to 504 points, about two
trading years.
ou.kappa(s, window), ou.mu(s, window), ou.sigma(s, window):
one parameter each, as a number. window defaults to 504.
ou(fred.series("SOFR")) => a 3 by 1 Matrix
ou.kappa(fred.series("SOFR")) => a number per year
ou.mu(fred.series("SOFR"), 252) => a number in percent
ou.sigma(fred.series("SOFR"), 252) => a number
In a Sheet cell, the 3 by 1 result of ou fills the two cells below it.
Matrices describes how a Matrix result fills a block.
Read the fit
These functions take (s, window) with window defaulting to 504, and
return a number.
ou.halflife(s, window): the number of trading days for half of a gap between the series and mu to close, ln(2) / kappa * 252.
ou.sigmastat(s, window): the long run standard deviation of the series around mu, sigma / sqrt(2 * kappa), in the series' units.
ou.z(s, window): the distance of the latest value from mu, in long
run standard deviations: (latest - mu) / ou.sigmastat. At -2 the series
sits two long run standard deviations below its level, and ou.path
rises toward mu.
ou.r2(s, window): the R squared of the regression of each value on the previous value, from 0 to 1. A series that reverts slowly has a slope near 1 and an R squared near 1. Judge the speed by kappa and the half-life.
ou.halflife(fred.series("SOFR_minus_EFFR"), 252) => a number of trading days
ou.sigmastat(fred.series("SOFR_minus_EFFR"), 252) => a number
ou.z(fred.series("SOFR_minus_EFFR"), 252) => a number
ou.r2(fred.series("SOFR_minus_EFFR"), 252) => a number
Project the fit
These functions take (s, window, T). window defaults to 504 and T,
the horizon, defaults to 60 trading days. T must be a whole number from
1 to 2001; outside that range the result is E_DOMAIN. Each returns a
Series of T points. Its x values are the day numbers 1 to T in
place of dates. iloc(..., -1) returns the value for day T.
ou.path(s, window, T): the expected value on each day t from 1 to T, starting from the latest value. With d = e^(-kappa t / 252), the value is latest * d + mu * (1 - d).
ou.pathsd(s, window, T): the standard deviation around ou.path on
each day, in the series' units. It grows from 0 toward ou.sigmastat.
ou.fpt(s, window, T): the chance that the series touches mu at least once by each day, from 0 to 1. The chance on day t covers days 1 to t, so the Series never falls. When the latest value is within 0.0001 of mu, every point is 1.
ou.path(fred.series("SOFR_minus_EFFR"), 252, 21) => a Series of 21 expected values
ou.pathsd(fred.series("SOFR_minus_EFFR"), 252, 21) => a Series of 21 standard deviations
ou.fpt(fred.series("SOFR"), 504, 20) => a Series of 20 touch chances
iloc(ou.fpt(fred.series("SOFR"), 504, 20), -1) => a number for day 20
In the Calculator a Series result displays as a chart under the input.
Roll the fit over time
These functions refit the model on windows that step back in time and
measure how much the fit changes. They take (s, window, step, count):
- window: points per refit, default 252, at least 4.
- step: points between the ends of two refits, default 21.
- count: the number of refits, default 20.
Refit k, counting from 0, uses the window points that end k * step
points before the latest point. When the series is too short for count
full windows, the functions use the full windows that fit. A series
shorter than one window is E_NO_DATA.
ou.roll(s, window, step, count): a Matrix with one row per refit, newest first. Its four columns are kappa, mu, sigma and the half-life in trading days. A window that does not revert gives a row of NaN.
ou.cv(s, window, step, count): the steadiness of kappa: the sample standard deviation of kappa across the refits, divided by its mean. Lower is steadier. Windows that do not revert are left out. Fewer than 2 valid windows is E_DOMAIN.
ou.flips(s, window, step, count): the number of times mu changes sign from one valid refit to the next, a whole number. Zero counts as positive. A count above 0 means the fitted level changed sign between refits.
ou.roll(fred.series("SOFR_minus_EFFR"), 252, 126, 4) => a 4 by 4 Matrix
ou.cv(fred.series("SOFR_minus_EFFR"), 252) => a number
ou.flips(fred.series("SOFR") - 4) => a number of sign changes
Size a trade
ou.carry(s, window): the pull toward mu over one trading day, kappa *
(mu - latest) / 252, in the series' units. It is positive when the latest
value is below mu. window defaults to 504.
kelly(p, rr): the Kelly fraction of capital, p - (1 - p) / rr. p is
the chance of a win, from 0 to 1, and rr is the average win divided by
the average loss. A p outside 0 to 1, or an rr of 0 or less, is
E_DOMAIN. A negative result means the odds are against the trade.
normcdf(x): the chance that a standard normal value is at or below
x. normcdf(-ou.z(s, w)) returns the chance of a standard normal
value at or below minus today's z-score.
ou.carry(fred.series("SOFR_minus_EFFR"), 252) => a number
kelly(0.6, 1) => 0.2
kelly(0.5, 2) => 0.25
normcdf(0) => 0.5
normcdf(1.96) => 0.975
normcdf(-ou.z(fred.series("SOFR_minus_EFFR"), 252)) => a number
Shift a series
lag(s, k): the series moved k points later. Each date from point
k + 1 on takes the value from k points before, and the result is k
points shorter. k is required and must be a positive whole number,
else E_DOMAIN. A k equal to or longer than the series is E_NO_DATA.
Series operators need identical dates on both sides, so a series minus
its lag is E_DIM. Cut the original to the lagged dates with align
first. Series describes align and the date rule.
lag(fred.series("SOFR"), 1) => a Series one point shorter
align(fred.series("SOFR"), lag(fred.series("SOFR"), 1)) - lag(fred.series("SOFR"), 1) => a Series of daily changes
Combine with other groups
A spread fit starts from a data function. fred.series returns the
daily history of a FRED series; Data functions
lists the others. The SOFR minus EFFR spread is in percent, so 0.02 is
two basis points.
- Type the fit in the Calculator. The result line updates 120 ms after the last keystroke.
- Replace it with the half-life.
- Replace that with today's z-score.
A Sheet takes each formula with a leading = and Enter.
ou(fred.series("SOFR_minus_EFFR"), 252) => a 3 by 1 Matrix
ou.halflife(fred.series("SOFR_minus_EFFR"), 252) => a number of trading days
ou.z(fred.series("SOFR_minus_EFFR"), 252) => a number
iloc from the Series page gives the latest value and
the end of a projection. Compare the latest value with mu, and the
expected value in 21 trading days with today's:
iloc(fred.series("SOFR_minus_EFFR"), -1) - ou.mu(fred.series("SOFR_minus_EFFR"), 252) => a number
iloc(ou.path(fred.series("SOFR_minus_EFFR"), 252, 21), -1) => a number
The fit uses the last window points. To fit on values that end one
point earlier, pass lag(s, 1): its values end at the second-to-last
point of s.
ou.z(lag(fred.series("SOFR_minus_EFFR"), 1), 252) => a number
The Function reference lists every signature and Errors lists every error code.