fracdiff
Supervised learning wants stationary features: a model cannot map a price of 4,000 to an outcome if it was trained when prices were near 1,000. The usual fix is to take returns, and returns are stationary because they forget everything — each one is the difference between two adjacent prices and carries no trace of the level or the path. AFML §5.2 calls this the stationarity-versus-memory dilemma, and points out that the choice is not binary. Returns are the series differenced times. Differencing it times is also defined, is often already stationary, and keeps most of the level’s information.
The weights
Section titled “The weights”Write for the backshift operator, . Integer differencing is , and the binomial series extends it to real (Hosking, 1981):
so the differenced value is . For integer the weights hit zero after lags: gives , the ordinary first difference. For fractional they never reach zero but decay like , which is where the memory comes from — every past value still contributes, a little.
get_weights(d, size) returns the first size weights. They come back oldest lag first,
so the last element is ; that is the order in which they are multiplied against a
window of the series.
Two windows
Section titled “Two windows”An infinite sum has to be cut somewhere, and the two functions cut it differently.
frac_diff(series, d, thresh) — expanding window (Snippet 5.2). Every output uses all the
history available at that point, so early outputs are built from fewer weights than late
ones. The first outputs, where the weights that would have applied to missing history add
up to more than thresh of the total, are returned as NaN. The drawback is structural:
because the number of terms grows with , the output picks up a drift that comes from the
window and not from the data (§5.5.1).
frac_diff_ffd(series, d, thresh) — fixed-width window (Snippet 5.3). Weights are
generated until one falls below thresh in absolute value, and that fixed set is applied at
every point. Every output is the same function of the same number of lags, so there is no
window-induced drift. This is the one to use for features. The first width − 1 outputs are
NaN.
thresh means different things in the two functions: a share of cumulative weight in the
first, a floor on a single weight in the second.
Choosing d
Section titled “Choosing d”AFML’s procedure (§5.6) is to sweep from 0 to 1, run an ADF test on each output, and keep the smallest that rejects a unit root. This crate has no ADF test, so the example reports two plain statistics instead: the correlation between the differenced series and the original log price (memory kept) and the differenced series’ lag-1 autocorrelation (a value near 1 is what a unit root looks like).
import mathimport randomfrom statistics import correlation
from openquant import fracdiff
# A random-walk log price: 2,000 bars, 1% volatility. Non-stationary by construction.rng = random.Random(5)log_price, level = [], math.log(100.0)for _ in range(2000): level += rng.gauss(0.0002, 0.01) log_price.append(level)
def lag1(xs): return correlation(xs[:-1], xs[1:])
print(" d window corr with price lag-1 autocorr")for d in (0.0, 0.2, 0.4, 0.6, 0.8, 1.0): out = fracdiff.frac_diff_ffd(log_price, d, 1e-4) kept = [(p, v) for p, v in zip(log_price, out) if not math.isnan(v)] prices, values = zip(*kept) width = len(log_price) - len(kept) + 1 memory = correlation(prices, values) if d > 0 else 1.0 print(f"{d:.1f} {width:6d} {memory:15.3f} {lag1(values):14.3f}") d window corr with price lag-1 autocorr0.0 1 1.000 0.9940.2 497 0.919 0.9700.4 282 0.698 0.8590.6 140 0.382 0.5770.8 64 0.177 0.2571.0 2 0.055 0.008The last row is returns: no autocorrelation and almost no relationship to the price level.
The rows between are the point of the chapter. At the series still correlates 0.70
with the level while its autocorrelation has dropped well away from 1. Whether 0.4 is
enough is what the ADF test decides. In theory a pure random walk differenced times is
stationary only for , so this synthetic series would not pass at 0.4; real prices are
not pure random walks, and AFML reports most liquid futures passing at (§5.6). Run
the sweep with statsmodels.tsa.stattools.adfuller on your own data, per instrument;
does not transfer between them.
The window column is the cost. At thresh = 1e-4, needs 497 bars of history before
it produces its first value. A lower keeps more memory because it uses a longer window.
From Rust
Section titled “From Rust”use openquant::fracdiff::{frac_diff_ffd, get_weights, get_weights_ffd};
// Oldest lag first; the last weight is w_0 = 1 and the one before it is -d.let w = get_weights(0.5, 4);assert_eq!(w, vec![-0.0625, -0.125, -0.5, 1.0]);
// d = 1 is the first difference: two weights, then an exact zero ends the expansion.assert_eq!(get_weights_ffd(1.0, 1e-5, 100), vec![-1.0, 1.0]);
let series: Vec<f64> = (1..=10).map(f64::from).collect();let diffed = frac_diff_ffd(&series, 1.0, 1e-5);assert!(diffed[0].is_nan());assert!(diffed[1..].iter().all(|v| *v == 1.0));
// A smaller threshold keeps more weights, so more leading values are NaN.let width = |thresh| get_weights_ffd(0.5, thresh, 10_000).len();assert_eq!((width(1e-2), width(1e-4)), (10, 200));The third argument of get_weights_ffd caps the number of weights, for every value: lim = 0
gives no weights and lim = 1 gives just [1.0]. The cap is what ends the expansion when
the threshold cannot: for a fractional the weights never reach zero, so a thresh of
zero or below runs to exactly lim weights. frac_diff_ffd sets the cap to the series
length, so a threshold too small for the data yields a window as long as the series and a
single non-NaN output.
use openquant::fracdiff::get_weights_ffd;
assert_eq!(get_weights_ffd(0.5, 0.0, 1), vec![1.0]);assert_eq!(get_weights_ffd(0.5, 0.0, 4).len(), 4);What to watch for
Section titled “What to watch for”- Difference the log price, or the price — not returns. The input is a level. Feeding returns differences them a second time.
- Nothing is validated. These functions return
Vec<f64>, notResult. A negativedis accepted and integrates the series instead of differencing it; its weights decay so slowly that the window runs to the cap and only the last output is a number. Athreshof zero does the same. ANaNin the input poisons every output whose window covers it; AFML’s snippet skips such points, and this port does not. Clean the series first. - The window is look-back only, but
dis not. Each output uses values at or before its own bar. A chosen by an ADF sweep over the full history, though, has seen the test period. Choose on the training span. frac_diffwiththresh = 1.0skips nothing, so its first output is justseries[0]. That is the mlfinlab default and rarely what you want; AFML uses 0.01.- Cost grows with the window.
frac_diff_ffdis andfrac_diffis . Tick-level series with athreshof1e-5and a small mean windows in the thousands. - Stationarity is per series and per regime. A that passed on ten years of data can fail on the last two.
Related modules
Section titled “Related modules”structural-breaks— SADF and related tests, the nearest thing in this crate to a unit-root test.feature-importance— check that a fractionally differenced feature earns its place.data-structures— build the bars first; differencing a time-bar series inherits its heteroskedasticity.
API reference
Section titled “API reference”Python
Section titled “Python”Signatures, parameters, return values and errors for every function: openquant.fracdiff.
fracdiff.get_weights— First `size` fractional-differencing weights of order `diff_amt`.fracdiff.get_weights_ffd— Fixed-width-window fractional-differencing weights of order `diff_amt`.fracdiff.frac_diff— Fractionally difference a series with an expanding window.fracdiff.frac_diff_ffd— Fractionally difference a series with a fixed-width window (FFD).
Rustdoc for this commit, with every public item of openquant::fracdiff (after a crates.io release the same pages are on docs.rs):