Non-Stationarity

SkillCommerce & finance

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

Use Non-Stationarity in Claude, ChatGPT or Ahel Desktop

Free. Sign in, add Non-Stationarity and connect your AI. About a minute.

Also: Claude Code · Cursor · Codex

Then ask your AI: use the Non-Stationarity skill

Details

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

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

Non-StationarityStart free

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
22
Forks
11
Last commit
Oct 2026
Advanced
Item type
skill
Key
ml4t-non-stationarity
Source
github.com/ml4t/skills