| myst |
|
|---|
Author: Artur Sepp / First recorded: 2026-10-01
Implemented in trendfollowing. Software citation: CITATION.cff.
A return-generating process specifies the joint law of a sequence of returns. For trend following the relevant features are the drift and the autocorrelation function: positive autocorrelation at the lags a filter reads is the source of trend-following profits at zero drift. This chapter defines the processes that the handbook's closed forms are evaluated under (white noise with drift, AR(1), MA(1) and the fractional ARFIMA family), gives their autocorrelation functions and moving-average representations, and documents the simulation engine that verifies the closed forms by Monte Carlo.
The closed forms of Part II hold at two levels of generality (Sepp and Lucic, 2026, Section 1). The expected return requires only a stationary mean, variance and autocorrelation function. The Sharpe ratio additionally requires a causal linear process with independent innovations, which fixes the fourth moments. Any process with an autocorrelation function can therefore be fed to the expected-return formula, and any linear process to the Sharpe formula, including an empirical autocorrelation function estimated from data.
The named processes isolate three features:
| Process | Feature | Trend-following content |
|---|---|---|
| White noise with drift | Drift only | Profits only from the squared drift, growing with the span |
| AR(1) | Short memory, geometric decay | Profits at short spans if |
| MA(1) | One lag of dependence | The shortest possible memory |
| ARFIMA(0,d,0) | Long memory, hyperbolic decay | Profits at every span if |
| ARFIMA(1,d,0) | Short and long memory together | Profits at slow spans despite short-term mean reversion |
| Convention | This article |
|---|---|
| Return basis | Daily returns |
| Normalisation | Innovation variance |
| Signal filter | Not applicable; the processes are inputs to every filter |
| Moment basis | Population moments; Monte Carlo sample moments in the verification |
| Annualisation |
|
| Timing | Causal: |
| Costs | Not applicable |
| trendfollowing default |
population_acf(n_lags=250, phi=0.0, d=0.0); the closed forms use 1000 to 2000 lags |
| Symbol or input | Meaning | Units and convention |
|---|---|---|
| Zero-mean part of the return | Per day | |
| MA(1) coefficient | The phi argument of expected_pnl_ma1
|
|
| ARFIMA(0,d,0) process with unit innovations | Dimensionless | |
| Autocovariance at lag |
Units of variance | |
| Variance of the unscaled ARFIMA(1,d,0) process | Dimensionless | |
| Gauss hypergeometric function | Written 2F1 in SciPy as hyp2f1
|
|
| Gamma function |
Definition (population moments). A process satisfies the moment assumption of Sepp and
Lucic (2026, Section 4.5) when
Definition (linear process). The normalised returns admit a Wold representation
with independent identically distributed innovations of zero mean, unit variance, zero third
moment and finite excess kurtosis
Identity (autocorrelation from weights).
Proof.
A stationary Gaussian process is a linear process with Gaussian innovations,
Definition.
Identity.
Proof. The stationary solution is
Proposition (aggregated variance). The variance of the
Proof. The ratio equals
With
expected_pnl_ma1, whose argument named phi is
Definition (Granger and Joyeux, 1980; Hosking, 1981). For
with variance and autocorrelation
At
Identity (generating function; Sepp and Lucic, 2026, Section 6.3). For
Proof. The Gauss series is
The identity makes every closed form of Part II exact in closed form under the pure fractional
process, because they depend on the process only through
Definition.
where
The AR part acts at short lags and the fractional part at long lags. With
The closed forms evaluate verify_garch_and_truncation.py in the replication folder).
The path engine generates paths of each process with a common interface. White noise, AR(p) and
MA(q) are Numba recursions; ARFIMA uses the fast fractional-differencing algorithm of Jensen and
Nielsen (2014) on a FFT grid, with
The block checks the autocorrelation functions against their closed forms, the hypergeometric generating function, the moving-average weights and the aggregated-variance limit, then estimates the first-lag autocorrelation from simulated AR(1) and ARFIMA paths.
import numpy as np
from scipy.special import gamma, hyp2f1
import trendfollowing as tf
from trendfollowing.analytics.autocorrelation import ma_weights, power_autocorr
from trendfollowing.processes.path_engine import ProcessType, generate_paths, set_seed
# AR(1): rho(m) = phi^m and psi_s = sqrt(1 - phi^2) phi^s
phi = 0.05
np.testing.assert_allclose(tf.population_acf(n_lags=4, phi=phi), phi ** np.arange(5))
psi_ar = ma_weights(phi=phi, d=0.0, n_lags=2000)
np.testing.assert_allclose(psi_ar[:3], np.sqrt(1.0 - phi ** 2) * phi ** np.arange(3))
# aggregated variance ratio of AR(1) tends to (1 + phi) / (1 - phi)
horizon = 20_000
h = np.arange(1, horizon)
ratio = 1.0 + 2.0 * np.sum((1.0 - h / horizon) * phi ** h)
np.testing.assert_allclose(ratio, (1.0 + phi) / (1.0 - phi), rtol=1e-4)
# ARFIMA(0,d,0): Gamma-function closed form, rho(1) = d / (1 - d)
d = 0.02
lags = np.arange(51)
rho_d = tf.population_acf(n_lags=50, d=d)
closed_form = gamma(1.0 - d) * gamma(lags + d) / (gamma(d) * gamma(lags - d + 1.0))
np.testing.assert_allclose(rho_d, closed_form, rtol=1e-12)
np.testing.assert_allclose(rho_d[1], d / (1.0 - d))
# the generating function is the hypergeometric function F(d, 1, 1 - d; nu)
rho_long = tf.population_acf(n_lags=2000, d=d)
for span in (5, 63, 250):
nu = tf.span_to_nu(span)
np.testing.assert_allclose(1.0 + tf.compute_psi_nu(rho_long, nu), hyp2f1(d, 1.0, 1.0 - d, nu),
rtol=1e-9)
# ARFIMA(1,d,0): short-term mean reversion with a long-memory tail
rho_mixed = tf.population_acf(n_lags=5, phi=-0.05, d=0.02)
np.testing.assert_allclose(rho_mixed[1], -0.030111, atol=5e-7)
assert np.all(rho_mixed[2:] > 0.0)
# moving-average weights reproduce the autocorrelation: rho(m) = sum psi_s psi_{s+m}
psi_frac = ma_weights(phi=0.0, d=0.1) # 8000 lags, normalised to unit sum of squares
implied = [np.sum(psi_frac[:-m] * psi_frac[m:]) for m in (1, 2, 3)]
np.testing.assert_allclose(implied, tf.population_acf(n_lags=3, d=0.1)[1:], atol=2e-4)
# power_autocorr returns autocovariances with lag 0 set to zero, not autocorrelations
gamma0 = gamma(1.0 - 2.0 * d) / gamma(1.0 - d) ** 2
np.testing.assert_allclose(power_autocorr(delta=d, n=3), [0.0, gamma0 * rho_d[1], gamma0 * rho_d[2]])
# drift normalisation of the paper's ARFIMA rows: V = Gamma(0.8) / Gamma(0.9)^2
variance_scale = gamma(0.8) / gamma(0.9) ** 2
np.testing.assert_allclose([variance_scale, 0.5 / np.sqrt(variance_scale)], [1.019, 0.495], atol=5e-4)
def lag_one_autocorrelation(paths):
x = paths - paths.mean(axis=0)
return np.sum(x[1:] * x[:-1]) / np.sum(x * x)
# Monte Carlo: the Numba engine draws from Numba's generator, ARFIMA from NumPy's
set_seed(20261001)
ar_paths = generate_paths(process_type=ProcessType.AR_P, phi=np.array([phi]), n_path=100,
m_times=10_000)
np.testing.assert_allclose(lag_one_autocorrelation(ar_paths[100:]), phi, atol=0.004)
np.random.seed(20261001)
arfima_paths = generate_paths(process_type=ProcessType.ARFIMA, ar_params=[0.0], delta=0.1,
n_path=4, m_times=20_000)
np.testing.assert_allclose(lag_one_autocorrelation(arfima_paths), 0.1 / 0.9, atol=0.015)
np.testing.assert_allclose(arfima_paths.var(), gamma(0.8) / gamma(0.9) ** 2, atol=0.05)| Quantity | Formula | trendfollowing entry point |
|---|---|---|
| Autocorrelation function |
n_lags, for white noise, AR(1), ARFIMA(0,d,0) and ARFIMA(1,d,0) |
trendfollowing.population_acf(n_lags=250, phi=0.0, d=0.0) |
| Autocovariance, ARFIMA convention |
|
trendfollowing.power_autocorr(delta, phi=0.0, n=100) |
| Moving-average weights |
|
trendfollowing.analytics.autocorrelation.ma_weights(phi=0.0, d=0.0, n_lags=8000) |
| Process selector | white noise, AR(p), MA(q), ARFIMA | trendfollowing.processes.path_engine.ProcessType |
| Path simulation | array of shape (m_times, n_path)
|
trendfollowing.processes.path_engine.generate_paths(process_type, phi, ar_params, delta, n_path, m_times, mean, noise_std, dt) |
| ARFIMA series | fractional differencing by FFT | trendfollowing.processes.arfima.arfima(ar_params, d, ma_params, n_points, noise_std=1, noise_alpha=2.0, warmup=0) |
The autocorrelation functions live in
autocorrelation.py,
and the simulation in
path_engine.py
and arfima.py,
which adapts the ARFIMA generator of Kononovicius.
API reference: {py:func}trendfollowing.population_acf, {py:func}trendfollowing.power_autocorr,
{py:func}trendfollowing.analytics.autocorrelation.ma_weights and
{py:func}trendfollowing.processes.path_engine.generate_paths.
Contract details:
-
population_acfraisesValueErrorunless$\lvert\phi\rvert\lt 1$ and$\lvert d\rvert\lt 0.5$ , and returnsn_lags + 1values starting with$\rho(0)=1$ . It selects the branch withnp.isclose, so a tiny nonzero$d$ is treated as zero. -
generate_pathsadds the driftmean * dtand the noisesqrt(dt) * noise_std * epsto each recursion, with time stepdt. The AR(p) and MA(q) recursions start from zero without a burn-in; discard the first observations, as the example does.phimust be a NumPy array of AR or MA coefficients. - White noise, AR(p) and MA(q) draw from Numba's generator, seeded with
trendfollowing.processes.path_engine.set_seed; ARFIMA draws through SciPy from NumPy's global generator, seeded withnp.random.seed.
Pitfall.
power_autocorrhas two conventions in one function: its AR branch returns the autocorrelations$\phi^h$ with lag zero equal to one, and its ARFIMA branches return autocovariances with lag zero set to zero, scaled by$\gamma(0)$ (or by$V_{\phi,d}$ with$\phi\ne 0$ ). It is the input ofexpected_pnl_arfima, not an autocorrelation function. Usepopulation_acffor an autocorrelation function.
- The processes are stationary with constant parameters. The empirical autocorrelation of
normalised futures returns drifts across decades: the lag-1 autocorrelation of
$z_t$ across the paper's universe falls from about 0.04 in the 1990s to about 0.01 after 2010 (Sepp and Lucic, 2026, Section 7.3). - The first-lag calibration
$\rho(1)=d/(1-d)$ maps the observed range 0.01 to 0.04 into$d$ from about 0.01 to 0.04; the paper's process figures use$d=0.02$ . - Long memory is hard to estimate: a small
$d$ produces small autocorrelations at every lag, and their sum over long lags is what the filter monetises. - A linear process excludes volatility clustering and leverage; the normalisation chapter explains why normalised returns are close to linear in practice.
- Notation and conventions
- The P&L decomposition: autocorrelation and drift channels
- The closed-form Sharpe ratio
- Sharpe ratios under white noise, AR(1) and ARFIMA
- Bibliography
- Sepp, A., and Lucic, V. (2026). The Science and Practice of Trend-Following Systems. Working paper. arXiv:2607.19497; SSRN 3167787. Sections 5 and 6 state the linear-process assumption and the processes.
- Granger, C. W. J., and Joyeux, R. (1980). An Introduction to Long-Memory Time Series Models and Fractional Differencing. Journal of Time Series Analysis, 1(1), 15–29. DOI: 10.1111/j.1467-9892.1980.tb00297.x. The ARFIMA process.
- Hosking, J. R. M. (1981). Fractional Differencing. Biometrika, 68(1), 165–176. DOI: 10.1093/biomet/68.1.165. The ARFIMA(0,d,0) autocorrelation function.
- Sowell, F. (1992). Maximum Likelihood Estimation of Stationary Univariate Fractionally Integrated Time Series Models. Journal of Econometrics, 53(1–3), 165–188. DOI: 10.1016/0304-4076(92)90084-5. The ARFIMA(1,d,0) autocovariance.
- Hamilton, J. D. (1994). Time Series Analysis. Princeton University Press. The Wold representation.
- Jensen, A. N., and Nielsen, M. Ø. (2014). A Fast Fractional Difference Algorithm. Journal of Time Series Analysis, 35(5), 428–436. DOI: 10.1111/jtsa.12074. The FFT fractional differencing behind the ARFIMA simulation.
- Sepp, A., and Lucic, V. trendfollowing: Closed-form trend-following analytics, reference system implementations, and reproducible futures evidence in Python. Software citation metadata.