Non-Stationarity

SkillCommerce & finance

Handle changing statistical properties in financial time series. Use when features or model performance degrade over time.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Non-Stationarity skill

What this skill tells your AI

The instructions your AI receives, as published by ml4t/skills in concepts/non-stationarity/SKILL.md and read by ahel’s review.

Financial time series have means, variances, and correlations that change over time. A model trained on 2015-2019 low-volatility data will underperform in a 2020 regime shift if it assumes fixed parameters.

The Problem

Global normalization (subtracting the full-sample mean and dividing by the full-sample standard deviation) embeds future information into every observation. It also assumes the distribution is stable, which is false for financial data. Post-2008 interest rates, COVID volatility, and factor decay are all examples of structural shifts that invalidate fixed-parameter assumptions.

A model trained on globally normalized features will overfit to the training regime and degrade when the regime changes.

The Pattern

WRONG


# Global normalization: uses future data and assumes stationarity
X_norm = (X - X.mean(axis=0)) / X.std(axis=0)

CORRECT

import polars as pl

# Expanding normalization: only uses past data, adapts to changing distribution
features = pl.DataFrame({"feat": feat_values, "timestamp": dates})

features = features.with_columns(
    feat_norm=(
        (pl.col("feat") - pl.col("feat").shift(1).cum_mean())
        / pl.col("feat").shift(1).rolling_std(window_size=252)
    )
)

Detection: ADF + KPSS Together

Run both tests. They have opposite null hypotheses, so agreement is strong evidence:

from statsmodels.tsa.stattools import adfuller, kpss

adf_stat, adf_pval, *_ = adfuller(series)
kpss_stat, kpss_pval, *_ = kpss(series, regression="c")

stationary = (adf_pval < 0.05) and (kpss_pval > 0.05)  # both agree
ADF rejects?KPSS rejects?Conclusion
YesNoStationary
NoYesNon-stationary
YesYesTrend-stationary (difference first)
NoNoInconclusive (get more data)

Mitigation Strategies

ApproachWhen to useTrade-off
Expanding windowDefault safe choiceSlow to adapt, no lookahead
Rolling window (e.g., 252d)Faster adaptation neededMore variance, loses early data
First differencingRemove trend/unit rootLoses level information
Regime conditioningKnown structural breaksRequires regime labels

Guardrails

  • X.mean() or X.std() without .expanding() or .rolling() is a red flag in any feature pipeline.
  • Shorter rolling windows adapt faster but have higher estimation variance - 126d to 504d is the typical range.
  • Test stationarity on raw features before modeling; non-stationary inputs produce unstable coefficients.
  • Monitor feature distributions in production - a mean shift > 2 sigma signals model retraining.

Production Implementation

ml4t-diagnostic provides stationarity testing utilities:

from ml4t.diagnostic.evaluation.stationarity import analyze_stationarity

stationarity = analyze_stationarity(feature_series, include_tests=["adf", "kpss"])
print(stationarity.consensus)
print(stationarity.summary_df)

Checklist

  • Stationarity tests (ADF + KPSS) run on all features before modeling
  • Normalization uses expanding or rolling window, never global statistics
  • Rolling window length chosen deliberately (not default)
  • Feature distributions monitored for structural breaks in production
  • Non-stationary series differenced or transformed before use

Signals

GitHub stars
20
Forks
11
Last commit
Sep 2026
Advanced
Item type
skill
Key
ml4t-non-stationarity
Source
github.com/ml4t/skills