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 ou and 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 b is 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.

  1. Type the fit in the Calculator. The result line updates 120 ms after the last keystroke.
  2. Replace it with the half-life.
  3. 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.