Différenciation fractionnaire pour des caractéristiques stationnaires qui conservent la mémoire des prix
Résumé
La différenciation fractionnaire applique une puissance non entière de la différence de l’opérateur de retard aux prix logarithmiques. Ses poids binomiaux décroissent au fil des observations passées ; le résultat combine donc les variations de prix avec une partie de l’information de niveau qui subsiste. L’ordre fractionnaire détermine le compromis : les ordres plus faibles préservent davantage de mémoire, mais tendent à fournir des preuves moins solides de stationnarité, tandis que les ordres plus élevés utilisent un historique plus court et éliminent davantage l’information de niveau.
Les exemples comparent différents ordres sur des données quotidiennes ETF, au moyen de tests de racine unitaire, de corrélations avec le prix logarithmique d’origine, de sommes des poids et de largeurs de fenêtre. La troncature des poids infimes laisse une somme positive ; la série transformée conserve donc une certaine dépendance au niveau. Le document oppose aussi le filtrage partiel des bords à une convention de fenêtre complète : cette dernière rend indisponibles les observations initiales de chauffe, en nombre variable selon l’ordre et le seuil. Il recommande de choisir un ordre à partir des données d’entraînement et de signaler la perte d’échantillon qui en découle. Les éléments probants sont limités par la dépendance au test ADF, qui peut manquer une lente réversion à la moyenne ; les ordres proposés par classe d’actifs sont des conventions, et les résultats peuvent varier selon les régimes.
Idées clés
- La différenciation fractionnaire utilise des poids binomiaux décroissants pour conserver une partie du niveau des prix tout en transformant la série vers la stationnarité.
- Les ordres plus faibles préservent davantage la mémoire à long terme, mais nécessitent un historique plus large et fournissent généralement des preuves moins solides de stationnarité.
- La troncature de la séquence de poids laisse une somme non nulle ; la sortie peut donc conserver une dépendance au niveau.
- Une convention de fenêtre complète évite d’appliquer des filtres plus courts aux bords de l’échantillon, au prix de la perte des observations initiales.
- Choisissez l’ordre à partir des données d’entraînement et signalez la perte liée à la période de chauffe ; les conclusions sur la stationnarité dépendent du test et de l’échantillon.
Étiquettes
Texte intégral
# Fractional Differencing
# Fractional Differencing
**Chapter 9 | Section 9.1**
**Docker image**: `ml4t`
Differencing a price series once makes it stationary and throws away the level. That is
a real loss: whether a price is near the top or the bottom of its recent range is
information, and a return series has none of it. **Fractional differencing** takes the
difference a fractional number of times, which is enough to make the series stationary
while leaving part of the level intact.
**Learning objectives**
- Explain what differencing a series a fractional number of times means, and read the
weights that do it.
- Apply the transform across a range of the fractional order and read the trade it makes:
how much of the original level is left against how strongly the result tests
stationary.
- Say how many observations at the start of a sample the transform costs, and why the
answer depends on a convention you choose rather than on the data.
- Pick the fractional order without searching the sample you will be tested on.
**Book reference**
Chapter 9, Section 9.1 (Diagnostics and stationarity features).
**Prerequisites**
`01_visual_diagnostics` for the ADF test and what stationarity means.
## Setup
```python
"""Fractional Differencing - achieve stationarity while preserving memory."""
import logging
import warnings
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import polars as pl
from IPython.display import display
from ml4t.engineer.features.fdiff import (
fdiff_diagnostics,
ffdiff,
find_optimal_d,
get_ffd_weights,
)
from ml4t.engineer.logging import setup_logging
from plotly.subplots import make_subplots
from statsmodels.tools.sm_exceptions import InterpolationWarning
from statsmodels.tsa.stattools import adfuller
from data import load_etfs
from utils.style import COLORS, show_plotly_with_alt
# Per-call timing notices from the feature library are about this machine, not the data.
setup_logging(level=logging.ERROR)
# The ADF helper inside the library runs KPSS alongside it on some paths, and a statistic
# past the ends of the KPSS lookup table raises this on every call.
warnings.filterwarnings(
"ignore",
message="The test statistic is outside of the range of p-values",
category=InterpolationWarning,
)
```
```python
START_DATE = "2015-01-01"
END_DATE = "2024-01-01"
FFD_THRESHOLD = 1e-4
```
## What a fractional difference is
Differencing once replaces $x_t$ with $x_t - x_{t-1}$. Write that as a weighted sum of
the history, $1 \cdot x_t - 1 \cdot x_{t-1}$, and the weights are $[1, -1, 0, 0, \dots]$.
Differencing twice gives $[1, -2, 1, 0, \dots]$. The **fractional** difference of order
$d$ uses the weights the binomial expansion of $(1 - L)^d$ produces for a
non-integer $d$, where $L$ is the operator that shifts a series back one period:
$$w_0 = 1, \qquad w_k = -w_{k-1}\,\frac{d - k + 1}{k}$$
For $d = 1$ this terminates after two terms and reproduces the ordinary difference. For
$d$ between zero and one it never terminates. After $w_0 = 1$ every weight is negative
and decays towards zero, so the transform is today's value minus a decaying weighted
average of the entire past. At $d = 1$ that average reduces to yesterday alone,
which is the ordinary difference; below one it spreads over hundreds of sessions, and
that spread is the memory ordinary differencing discards.
In practice the tail is cut where the weights become negligible. `FFD_THRESHOLD` is
where: a weight smaller than this in magnitude is dropped, and the number of weights
left is the width of the fixed window the transform applies. The threshold therefore
decides both how faithful the transform is and how much history each output value
needs, which is the subject of two sections below.
## The series
One ETF panel, nine years of daily closes, and the transform is applied to **log**
prices throughout. Logs matter because the filter is linear: on log prices its output is
a weighted combination of log returns plus a small multiple of the log level, where a
multiple of the *price* would scale with the price itself. The residual level term does
not vanish, and it is the subject of the weight-sum section below: multiplying every
price by a constant shifts every output by the weight sum times the log of that
constant. Reduced dependence on the price scale, not independence from it.
```python
all_etfs = load_etfs()
def load_etf(symbol: str) -> pl.DataFrame:
"""One symbol from the panel, inside the requested window, in session order."""
return (
all_etfs.filter(pl.col("symbol") == symbol)
.filter(pl.col("timestamp") >= pl.lit(START_DATE).str.to_date())
.filter(pl.col("timestamp") <= pl.lit(END_DATE).str.to_date())
.sort("timestamp")
)
spy = load_etf("SPY")
log_prices = spy["close"].log()
print(f"SPY: {spy.height:,} sessions ({spy['timestamp'].min()} to {spy['timestamp'].max()})")
```
## The weights, and what the truncation leaves behind
Every weight sequence starts at $w_0 = 1$, so the differences between orders are all in
the tail. Plotting the magnitude of the weights on a log scale, with the first one
omitted because it is the same for every order, shows the decay rate directly.
Read it in two parts. At lag one the weight is exactly $-d$, so a higher order applies
the larger immediate correction and starts above the others. Every pair of lines then
crosses, because a lower order decays more slowly and its weights end up larger at
distant lags, but they cross at very different places: widely separated orders swap
within the first ten or twenty lags, while two neighbouring low orders stay close and
swap only after hundreds. The end of each line is where its window closes, and that
ordering does not reverse anywhere.
```python
D_GRID = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]
def sequential_shades(start: str, stop: str, n: int) -> list[str]:
"""*n* colors interpolated between two palette entries, for an ordered variable."""
low = np.array([int(start[i : i + 2], 16) for i in (1, 3, 5)])
high = np.array([int(stop[i : i + 2], 16) for i in (1, 3, 5)])
return [
"#{:02x}{:02x}{:02x}".format(*(low + (high - low) * t).astype(int))
for t in np.linspace(0, 1, n)
]
d_shades = sequential_shades(COLORS["blue"], COLORS["copper"], len(D_GRID))
fig = go.Figure()
for d, color in zip(D_GRID, d_shades):
weights = get_ffd_weights(d, threshold=FFD_THRESHOLD)
fig.add_trace(
go.Scatter(
x=list(range(1, len(weights))),
y=np.abs(weights[1:]),
mode="lines",
name=f"d={d}",
line=dict(color=color, width=2),
)
)
fig.update_layout(
title="A lower order decays more slowly, so its window reaches further back",
xaxis_title="Lag in sessions",
yaxis_title="Weight magnitude, log scale",
yaxis=dict(type="log", exponentformat="power"),
)
show_plotly_with_alt(
fig,
"Six lines on a log vertical axis, one per fractional order, each showing the "
"magnitude of the weights against the lag they apply to. At the first lag the highest "
"fractional order sits highest, because that weight is the order itself. The lines "
"then cross, the widely separated orders within the first tens of lags and the "
"neighbouring low orders only far to the right, after which the lower orders lie "
"above. Each line ends where its weights fall under the truncation threshold, and the "
"lower the order the further right that is.",
)
```
The two columns below are what the truncation costs. The window width is how many
sessions of history each output value needs. The weight sum is what a constant input
would come out as: for an untruncated fractional difference of any positive order the
weights sum to zero, so a constant maps to zero and the level is removed entirely.
Truncation leaves a small positive remainder, and the transformed series therefore
carries that fraction of the level on top of the differenced fluctuation. At a low order
the remainder is large, which is the same fact as "a low order preserves memory", seen
from the other side.
```python
weight_table = pd.DataFrame(
[
{
"d": d,
"window_sessions": len(get_ffd_weights(d, threshold=FFD_THRESHOLD)),
"weight_sum": get_ffd_weights(d, threshold=FFD_THRESHOLD).sum(),
}
for d in D_GRID
]
)
display(weight_table)
```
## The boundary convention, and the observations it costs
`ffdiff` is **boundary-partial**: near the start of the sample, where the full window of
history does not exist yet, it applies whatever weights it can and returns a value
anyway. Nothing is null and the row count is preserved, which is convenient and means
the earliest values were produced by a shorter filter than the later ones.
The **full-window** convention (Lopez de Prado, 2018) treats those rows as unavailable.
It is the one to use for a feature, because a column whose first few hundred values were
computed a different way is a column with a silent regime change at its start.
Imposing it is one line: the first `width - 1` rows, where `width` is the number of
weights, do not have a full window and are set to null. The count of those rows is the
**sample loss**, and it is reported everywhere below because it is the price of the
order chosen.
```python
def ffd_full_window(series: pl.Series, d: float, threshold: float = FFD_THRESHOLD) -> dict:
"""Fractional difference under the full-window convention, with its diagnostics."""
width = len(get_ffd_weights(d, threshold=threshold))
values = ffdiff(series, d=d, threshold=threshold).to_numpy().copy()
valid = np.zeros(len(values), dtype=bool)
valid[width - 1 :] = True
valid &= ~np.isnan(values)
values[~valid] = np.nan
# Polars keeps NaN and null distinct and `drop_nulls` drops only the second, so the
# warmup is converted to null for every downstream filter to agree on.
return {
"transformed": pl.Series(series.name, values).fill_nan(None),
"valid": pl.Series("valid", valid),
"sample_loss": int((~valid).sum()),
"d": d,
"window_sessions": width,
}
```
## Reading the trade across the grid
Each row applies one order to the SPY log price and reports three things: how many
observations the warmup cost, how strongly the result rejects a unit root, and how much
of the original level survived, measured as the correlation between the transformed
series and the log price it came from.
```python
grid_rows = []
for d in D_GRID:
result = ffd_full_window(log_prices, d=d)
valid = result["valid"].to_numpy()
transformed = result["transformed"].to_numpy()[valid]
grid_rows.append(
{
"d": d,
"window_sessions": result["window_sessions"],
"sample_loss": result["sample_loss"],
"sample_loss_pct": 100 * result["sample_loss"] / len(log_prices),
"adf_pval": adfuller(transformed, autolag="AIC")[1],
"corr_with_level": np.corrcoef(log_prices.to_numpy()[valid], transformed)[0, 1],
}
)
grid_df = pd.DataFrame(grid_rows)
grid_df["stationary"] = grid_df["adf_pval"] < 0.05
display(grid_df)
```
```python
first_stationary = grid_df.loc[grid_df["stationary"], "d"].min()
at_first = grid_df.loc[grid_df["d"] == first_stationary].iloc[0]
print(
f"Smallest order on the grid that rejects a unit root: d = {first_stationary}, "
f"keeping correlation {at_first['corr_with_level']:.2f} with the log price "
f"at a cost of {at_first['sample_loss']} warmup sessions"
)
```
Three things move together down that table, and they are the whole subject.
The correlation with the level falls as the order rises: more differencing, less memory.
The ADF p-value falls with it, because the part of the series that carries the memory is
the part that wanders. And the sample loss falls too, which is the direction that
surprises people: a *lower* order needs a *wider* window, because its weights take
longer to fall under the threshold, so keeping memory is paid for twice, once in
stationarity and once in observations.
The grid brackets the crossing deliberately. Orders below it do not reject a unit root
and orders above it do, and the row where that changes is the one to read against the
correlation column: it is the most memory this series will give up while still testing
stationary.
## Choosing the order without searching
The obvious next move is to search for the smallest order that passes on this sample.
That is a selection made on the same data the model will be evaluated on, and it makes
the transform a fitted object with all the look-ahead that implies.
The alternative is a **fixed order per asset class**, set from what those series are
known to be like and held constant. It is not optimal for any one symbol and it is not
estimated from anything, which is what makes it safe to apply across a panel and across
time. Persistent series get a higher order because they need more differencing; series
that already mean-revert get less.
```python
ASSET_CLASS_D = {
"equities": 0.4,
"fixed_income": 0.5,
"crypto": 0.5,
"commodities": 0.4,
"fx": 0.35,
}
display(
pd.DataFrame(
[
{"asset class": name, "d": d, "why": reason}
for (name, d), reason in zip(
ASSET_CLASS_D.items(),
[
"moderate persistence in the level",
"rates trend for years at a time",
"strong trending, short history",
"similar persistence to equities",
"levels mean-revert, so less differencing is needed",
],
)
]
)
)
```
## Across a panel
Applying the fixed order to seven ETFs shows what a no-search rule costs. Every symbol
in a class gets the same order, so every symbol in a class loses the same number of
warmup sessions, and some symbols will not test stationary at that order. That is the
trade being made, not a failure of the rule.
```python
ETF_ASSETS = {
"SPY": "equities",
"QQQ": "equities",
"IWM": "equities",
"TLT": "fixed_income",
"GLD": "commodities",
"EFA": "equities",
"EEM": "equities",
}
panel_rows = []
for symbol, asset_class in ETF_ASSETS.items():
data = load_etf(symbol)
if data.height < 100:
continue
d = ASSET_CLASS_D[asset_class]
result = ffd_full_window(data["close"].log(), d=d)
transformed = result["transformed"].drop_nulls().to_numpy()
panel_rows.append(
{
"symbol": symbol,
"asset_class": asset_class,
"d": d,
"sample_loss": result["sample_loss"],
"sample_loss_pct": 100 * result["sample_loss"] / data.height,
"adf_pval": adfuller(transformed, autolag="AIC")[1],
}
)
panel_df = pd.DataFrame(panel_rows)
panel_df["stationary"] = panel_df["adf_pval"] < 0.05
display(panel_df)
```
```python
not_stationary = panel_df.loc[~panel_df["stationary"], "symbol"].tolist()
print(
f"Symbols still testing non-stationary at their class order: "
f"{', '.join(not_stationary) if not_stationary else 'none'} "
f"({len(not_stationary)} of {len(panel_df)})"
)
```
The table above runs its test on each symbol's whole sample, so it describes the panel
and must not select for it. A symbol that fails may be moved one step up the grid, but
the diagnostic that triggers the move has to be computed on training observations alone.
Run on the full sample it is the evaluation period choosing the parameter, and a single
predetermined step is still a step the test data asked for.
## The output a model reads
The feature table pairs the transformed column with its validity mask. Downstream code
filters on the mask, which keeps the warmup out of every model that reads the column
without anyone having to carry the count separately.
```python
D_EQUITIES = ASSET_CLASS_D["equities"]
equity_result = ffd_full_window(log_prices, d=D_EQUITIES)
spy_features = spy.select(["timestamp", "close"]).with_columns(
log_close=pl.col("close").log(),
return_1d=pl.col("close").pct_change(),
ffd=equity_result["transformed"],
ffd_valid=equity_result["valid"],
)
print(
f"Rows: {spy_features.height}, valid: {equity_result['valid'].sum()}, "
f"warmup dropped: {equity_result['sample_loss']}"
)
display(spy_features.filter(pl.col("ffd_valid")).tail(10))
```
Compare the `ffd` column against `log_close` in those rows and the weight sum from the
earlier table is visible directly: the transformed value sits at roughly that fraction
of the log price, with the differenced fluctuation on top of it. This is what
"preserving memory" means concretely, and it is also the reason the transformed series
is only approximately stationary: the surviving fraction of the level drifts with the
level.
## What the three transforms look like
The same series undifferenced, differenced once, and differenced fractionally. The last
500 sessions, so the shapes are visible rather than compressed.
```python
DISPLAY_SESSIONS = 500
fig = make_subplots(
rows=3,
cols=1,
shared_xaxes=True,
subplot_titles=[
"Log price, no differencing",
"Simple return, differenced once",
f"Fractional difference at d={D_EQUITIES}",
],
vertical_spacing=0.08,
)
tail = slice(-DISPLAY_SESSIONS, None)
sessions = spy["timestamp"].to_list()[tail]
for row, values in enumerate(
[
log_prices.to_numpy()[tail],
spy["close"].pct_change().to_numpy()[tail],
equity_result["transformed"].to_numpy()[tail],
],
start=1,
):
fig.add_trace(
go.Scatter(x=sessions, y=values, mode="lines", line=dict(color=COLORS["blue"])),
row=row,
col=1,
)
fig.update_yaxes(title_text="Log price", row=1, col=1)
fig.update_yaxes(title_text="Return", row=2, col=1)
fig.update_yaxes(title_text="FFD value", row=3, col=1)
fig.update_xaxes(title_text="Session", row=3, col=1)
fig.update_layout(
height=600,
showlegend=False,
title="The fractional difference keeps the slow movement returns discard",
)
show_plotly_with_alt(
fig,
"Three stacked panels over the last 500 sessions. The top panel, the log price, "
"rises and falls across a wide range. The middle panel, the daily return, is a band "
"of noise around zero with no visible trend. The bottom panel, the fractional "
"difference, oscillates like the return panel but around a level that drifts with "
"the shape of the top panel.",
)
```
## If you must search, search forward
Where a fixed order is not acceptable, the search has to be confined to data the model
is not evaluated on. The function below takes a training cut-off, searches only inside
it, and returns the smallest order on the grid that rejects a unit root there. That is
the Lopez de Prado convention: take the least differencing the diagnostic will accept,
because everything past it is memory given away for nothing.
```python
SEARCH_GRID = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
TRAIN_FRACTION = 0.8
def search_d_on_training(series: pl.Series, train_end: int) -> dict:
"""The smallest order on the grid that rejects a unit root inside the training cut."""
train = series.head(train_end)
for d in sorted(SEARCH_GRID):
result = ffd_full_window(train, d=d)
valid = result["valid"].to_numpy()
transformed = result["transformed"].to_numpy()[valid]
if len(transformed) < 50:
continue
adf_pval = adfuller(transformed, autolag="AIC")[1]
if adf_pval < 0.05:
return {
"selected_d": d,
"train_adf_pval": adf_pval,
"train_corr": np.corrcoef(train.to_numpy()[valid], transformed)[0, 1],
}
# Nothing on the grid was stationary; fall back to the ordinary first difference.
return {"selected_d": 1.0, "train_adf_pval": float("nan"), "train_corr": 0.0}
```
```python
train_end = int(spy.height * TRAIN_FRACTION)
search = search_d_on_training(log_prices, train_end)
print(f"Training: {spy['timestamp'][0]} to {spy['timestamp'][train_end - 1]}")
print(f"Test: {spy['timestamp'][train_end]} to {spy['timestamp'][-1]}")
print(f"Selected d on training data only: {search['selected_d']}")
print(f" training ADF p-value: {search['train_adf_pval']:.4f}")
print(f" training correlation with the level: {search['train_corr']:.4f}")
test_result = ffd_full_window(log_prices.tail(spy.height - train_end), d=search["selected_d"])
test_values = test_result["transformed"].drop_nulls().to_numpy()
if len(test_values) > 50:
test_pval = adfuller(test_values, autolag="AIC")[1]
print(f"Test ADF p-value at the selected d: {test_pval:.4f}")
print(f"Smallest stationary order over the full sample, for comparison: {first_stationary}")
```
The order selected on the training cut need not match the one the full sample would
give, and on this sample it does not. Neither outcome tells you anything on its own:
two searches over the same grid can land on the same point for perfectly good reasons.
What makes a selection safe is which observations the procedure was allowed to read, not
whether its answer happens to agree with a search that read more.
## The library helpers, and the convention they use
`find_optimal_d` searches a range for the smallest order that passes, and
`fdiff_diagnostics` reports the ADF result, the correlation and the weight count at a
given order. Both call the boundary-partial transform directly, with no validity mask,
so their ADF test reads a series whose early values came from a partial filter.
That is not a different opinion about the same question; it is a different question, and
on this sample the difference is large enough to change what you would do. Compare the
window width the helper's answer implies against the length of the sample it was
measured on.
```python
optimal = find_optimal_d(log_prices, d_range=(0.0, 1.0), step=0.05)
diagnostics = fdiff_diagnostics(log_prices, d=optimal["optimal_d"])
print(f"find_optimal_d selected d = {optimal['optimal_d']:.2f}")
print(f" ADF p-value: {optimal['adf_pvalue']:.4f}")
print(f" correlation with the level: {optimal['correlation']:.4f}")
print(f" window it implies: {diagnostics['n_weights']} sessions")
print(f" sessions in the sample: {spy.height}")
print(f" weight sum: {diagnostics['weight_sum']:.4f}")
```
Read the last three lines together. The order the helper selected needs a window longer
than the sample it was selected on, so under the full-window convention not one
observation would have a complete window and the transform is undefined on this data.
Every value it tested was a partial application of a filter that never fits. The helper
is doing what it says; the convention it assumes is the one that has to be checked
before its answer is used.
## Three distributions
The last comparison is what each transform does to the distribution of values, which is
a different question from what it does to the path.
```python
fig = make_subplots(
rows=1,
cols=3,
subplot_titles=[
"Log price",
"Simple return",
f"Fractional difference, d={D_EQUITIES}",
],
)
for column, values in enumerate(
[
log_prices.drop_nulls().to_numpy(),
spy["close"].pct_change().drop_nulls().to_numpy(),
equity_result["transformed"].drop_nulls().to_numpy(),
],
start=1,
):
fig.add_trace(
go.Histogram(x=values, nbinsx=50, marker=dict(color=COLORS["blue"])), row=1, col=column
)
fig.add_vline(x=0, line=dict(color=COLORS["neutral"], dash="dash", width=1), row=1, col=2)
fig.update_xaxes(title_text="Log price", row=1, col=1)
fig.update_xaxes(title_text="Return", row=1, col=2)
fig.update_xaxes(title_text="FFD value", row=1, col=3)
fig.update_yaxes(title_text="Count", row=1, col=1)
fig.update_layout(
height=350,
showlegend=False,
title="Differencing narrows the distribution; the fractional order stops part way",
)
show_plotly_with_alt(
fig,
"Three histograms side by side, each on its own horizontal scale. The log price "
"spreads across more than a full unit with several separate humps. The simple return "
"is one narrow spike centred on zero, marked by a dashed reference line. The "
"fractional difference spans about the same width as the return but is centred near "
"the level shown in the weight table rather than on zero, and its shape is broader "
"and less peaked.",
)
```
The log price is spread out and multimodal, which is what a wandering level looks like
as a histogram. The return is a narrow spike at zero: stationary and carrying nothing
about where the price was. The fractional difference spans roughly the same width as
the return, so it is comparable in scale, and it sits away from zero because of the
surviving fraction of the level from the weight table.
## Key takeaways
1. **The order controls one trade and pays for it twice.** A lower order keeps more of
the level and tests stationary less strongly, and it also needs a wider window, so it
costs more warmup observations.
2. **The boundary convention is a choice that changes the answer.** The
boundary-partial form keeps every row and computes the early ones with a shorter
filter; the full-window form drops them. Diagnostics computed under one convention do
not transfer to the other, which is why the library helper here selects an order the
full-window convention cannot use at all.
3. **Report the sample loss with the feature.** It is the number of rows at the start of
every series that a model must not read, and it changes with the order and the
truncation threshold.
4. **Fix the order rather than searching for it.** A fixed order per asset class is not
optimal anywhere and is not estimated from anything. Where a search is unavoidable,
confine it to a training cut and take the smallest order that passes.
5. **The transform is not exactly stationary.** Truncation leaves the weights summing to
a small positive number, so the output carries that fraction of the level and drifts
with it. It passes the test; it is not free of the level.
**Known limitations.** The ADF test decides stationarity here, and it is one test with
known low power against a slowly mean-reverting alternative, so an order it accepts is
not established as sufficient. The asset-class orders are conventions rather than
measurements. And the panel section applies one order per class over one nine-year
window; a longer sample containing a different volatility regime can move which symbols
pass.
**Next**: `04_kalman_filter` for extracting a latent level from a noisy series, and
`05_spectral_features` for describing a series by its frequencies.


Reproduit dans son intégralité avec attribution, conformément à la licence de la source. Licence: MIT
Ce résumé a été rédigé par l’agent de recherche de Stratmill à partir de la source originale ; il n’en est pas une copie.