Skip to content

factrix.metrics.trend

Information coefficient (IC) trend analysis using Theil-Sen estimator.

Theil-Sen is preferred over ordinary least squares (OLS) because it has a breakdown point of 29.3%, making it robust to outliers (e.g. COVID-era IC spikes).

Notes

Pipeline. Time-series only, Theil-Sen median pairwise slope on a 1-D series; CI from the rank-based pairwise slope distribution.

Input. DataFrame with date, value (typically an IC series).

Output. Slope + confidence interval for trend detection.

factrix.metrics.trend.ic_trend

ic_trend(series: DataFrame, value_col: str = 'value', overlap_periods: int = DEFAULT_FORWARD_PERIODS, *, name: str = 'ic_trend', adf_threshold: float | None = 0.1, expected_warnings: tuple[str, ...] = ()) -> MetricResult

Theil-Sen median slope of a time-indexed series.

Answers "is this factor getting better or worse over time?" - slope ≈ 0: stable - slope significantly < 0: decaying (crowding / alpha erosion) - slope significantly > 0: improving

Parameters:

Name Type Description Default
series DataFrame

DataFrame with date and value_col.

required
overlap_periods int

Overlap horizon of the input series, used as the non-overlapping sampling stride. The default input is a per-period IC series built from h-period forward returns, so consecutive observations share h - 1 bars; the trend test runs on every h-th observation. Pass 1 only when the series genuinely carries no overlap.

DEFAULT_FORWARD_PERIODS
name str

Emitted metric name, stashed in metadata and used as the method / cache key. Defaults to "ic_trend"; EventFactor.caar_trend / MacroPanelFactor.beta_trend pass their own names so method / cache key / primitive name stay three-point unified.

'ic_trend'
adf_threshold float | None

Augmented Dickey-Fuller (ADF) p-value above which the input is flagged as unit-root suspect. Default 0.10 is a conventional practitioner cutoff from the unit-root literature (folklore on the back of Stock-Watson (1988)'s broader review of trends in macroeconomic time series, with the specific 10% threshold closer to Stock 1994 Handbook of Econometrics §III than a direct prescription of the 1988 paper): at p > 0.10 we cannot reject I(1), so ordinary least squares (OLS) / Theil-Sen on the series reject the slope null at inflated rates regardless of the true trend. When None, the ADF check is skipped entirely and no adf_stat / adf_p / unit_root_suspected keys are written. When a float is provided it must lie in (0, 1). Detected unit roots set unit_root_suspected=True in metadata; the slope value is still returned (caller decides) but significance should be read with scepticism.

0.1

Returns:

Type Description
MetricResult

MetricResult with the Theil-Sen slope as value and the

MetricResult

Hamed-Rao Mann-Kendall tau / p-value as stat / p_value.

Notes

Theil-Sen median pairwise slope: slope = median{(y_j - y_i) / (j - i) : i < j} (Sen (1968)) reports the trend's magnitude; significance comes from the Mann-Kendall test (Mann (1945), Kendall (1975)) on the same ranks — stat is Kendall's tau between the sequence index and the series. That is the standard pairing for a non-parametric trend test: one rank-based framework supplies both numbers. An ADF unit-root pre-check on the input flags series for which the trend null is rejected at inflated rates regardless of the true trend, and now carries WarningCode.PERSISTENT_REGRESSOR rather than sitting in metadata alone. On a trend-free IC series the Mann-Kendall p measures 1.7-5.3% at a nominal 5% over T in 60/120/240 and h in 1/5 (reference/inference-calibration).

Two corrections for serial dependence. Striding does ~99% of the work; Hamed-Rao handles the residual persistence that striding cannot reach, and is not calibrated on its own. Mann-Kendall's null variance Var(S) = n(n-1)(2n+5)/18 counts every pairwise comparison as an independent draw. The default input to this metric — a per-period IC series over h-period forward returns — is MA(h-1) with rho_1 = 1 - 1/h (0.80 at h=5, 0.95 at h=21), so the raw test is wholesale miscalibrated and does not improve with T. So:

  1. The series is sub-sampled to non-overlapping observations at stride overlap_periods (the same _sample_non_overlapping machinery positive_rate uses) before anything is computed. This is exactly calibrated for the overlap channel.
  2. The Mann-Kendall variance on the survivors carries the Hamed-Rao (1998) n/n* inflation for the residual persistence striding cannot remove (an AR factor is still autocorrelated after striding). Reported as metadata["variance_inflation"].

Measured rejection frequency on a true null (slope 0), 600 replications per cell:

series raw MK Hamed-Rao only strided + HR
T=60, h=5 0.405 0.215 0.045
T=240, h=5 0.367 0.162 0.038
T=120, h=21 0.683 0.312 0.022
T=240, h=21 0.673 0.265 0.033
T=120, iid 0.035 0.032 0.032
T=120, AR(0.6) 0.338 0.183 0.183
T=120, AR(0.9) 0.667 0.357 0.357

The last two rows are the disclosed limit: striding does nothing for a persistent series at h = 1, and Hamed-Rao only halves the excess. Read the other way round, in every overlap cell the strided and strided+Hamed-Rao columns agree to within 1pp — the stride is the fix, and Hamed-Rao earns its place only on the h = 1 autocorrelated rows. Those runs carry WarningCode.SERIAL_CORRELATION_DETECTED, fired off the lag-1 autocorrelation of the strided series against PERSISTENT_SERIES_AUTOCORR.

The size fix costs power, and at long horizons costs most of it. Against a drift of 2 sd over the sample, raw Mann-Kendall versus strided + Hamed-Rao:

cell raw MK strided + HR
T=60, h=5 0.865 0.360
T=120, h=5 0.971 0.713
T=240, h=5 0.999 0.952
T=120, h=21 0.888 0.086
T=240, h=21 0.951 0.425

The raw column is not a fair comparison — it is the power of a test that rejects 37–68% under the null — but the absolute level of the corrected column is the number to plan around. At h = 21 the stride leaves 12 survivors at T = 120 and the metric is effectively powerless there; it needs roughly 240+ periods before it can detect anything, and more still to be comparable with the h = 5 cells.

Why not a t backed out of the CI. An earlier version derived SE ≈ (ci_high - ci_low) / 2 / 1.96 from scipy's normal-based 95% rank interval and read slope / SE against t(n - 2). Under a true null that combination rejected 2.4–3.8% at a nominal 5% for n = 10–30 — conservative, but by mixing a normal-derived SE with a t reference rather than by design, and it made a perfectly linear series (zero-width CI) a special case that had to be hand-mapped to p = 0. Mann-Kendall sits at 4.6–5.4% on the same nulls and handles the perfect line naturally (tau = ±1, exact p).

Constant series. A flat series has no rank ordering to test: tau is undefined, so stat / p_value are None with WarningCode.DEGENERATE_VARIANCE, while value (the zero slope) is still reported — the same convention every other metric uses for a dispersion-free sample. The Theil-Sen CI is kept in metadata as a descriptive interval; ci_excludes_zero is informational and no longer drives the p.

factrix uses Theil-Sen rather than OLS because its 29.3% breakdown point absorbs information coefficient (IC) outliers (e.g. COVID-era spikes) that would dominate an OLS slope. The confidence interval is descriptive; Mann-Kendall supplies the inferential reference.

References

Sen 1968: Theil-Sen median pairwise slope. Lou-Polk 2022: momentum-crowding evidence as one suggestive economic channel for time-varying IC; McLean-Pontiff 2016 is the cleaner cite for post-publication IC decay specifically. Hamed-Rao 1998: variance-inflation correction making Mann-Kendall valid under serial correlation. Stock-Watson 1988: practitioner unit-root background for the ADF persistence flag. Dickey-Fuller 1979: ADF persistence diagnostic on the input series. MacKinnon 1996: ADF p-value response surface used by _adf_pvalue_interp.

Examples:

Trend on the per-period IC series produced by :func:~factrix.metrics.ic.compute_ic:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.ic import compute_ic
>>> from factrix.metrics.trend import ic_trend
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
...     forward_periods=5,
... )
>>> ic_df = compute_ic(panel)["factor"]
>>> result = ic_trend(ic_df, value_col="ic")
>>> result.name == ""
True

Use cases

  • Detect alpha decay / crowding on an information coefficient (IC) series


    ic_trend is a standalone series diagnostic — input is a 1-D (date, value) series, typically the per-date IC from compute_ic. Slope \(\approx 0\) = stable; slope significantly \(< 0\) = decaying (crowding / alpha erosion in the Lou-Polk 2022 sense); slope significantly \(> 0\) = improving.

  • Outlier-robust slope via Theil-Sen


    Median pairwise slope \(\mathrm{median}\{(y_j - y_i)/(j - i): i < j\}\) has a 29.3 % breakdown point — absorbs IC outliers (e.g. COVID- era spikes) that would dominate an ordinary least squares (OLS) slope. Theil-Sen supplies magnitude and a descriptive interval; Hamed-Rao Mann-Kendall supplies the test statistic and p-value.

  • Unit-root pre-check on the input


    adf_threshold (default 0.10, a conventional practitioner cutoff) drives an augmented Dickey-Fuller (ADF) persistence diagnostic on the strided tested series. Above the cutoff the slope null is rejected at inflated rates regardless of the true trend; the slope value is still returned but metadata["unit_root_suspected"] = True flags it for sceptical reading. Pass adf_threshold=None to skip the check entirely.

  • Reusable on any post-PANEL series


    The name= argument lets EventFactor.caar_trend / MacroPanelFactor.beta_trend pass their own primitive names so method / cache key / primitive name stay three-point unified — the same Theil-Sen primitive serves caar series, rolling-\(\beta\) series, and spread series, not just IC.

Worked example — IC series fed into ic_trend

compute_ic → ic_trend with ADF persistence check

import factrix as fx
from factrix.metrics.ic import compute_ic
from factrix.metrics.trend import ic_trend
from factrix.preprocess import compute_forward_return

raw   = fx.datasets.make_cs_panel(
    n_assets=100, n_dates=1000, ic_target=0.08, rng=2024,
)
panel = compute_forward_return(raw, forward_periods=5)

# The series diagnostic consumes (date, value); the value column on
# the compute_ic output is named ``ic``.
ic_df = compute_ic(panel)["factor"]
out   = ic_trend(ic_df, value_col="ic")
print(out.value, out.stat, out.p_value)
# -3.2e-05  -0.91  0.36   (approximate; flat slope)
print(out.metadata["ci_low"], out.metadata["ci_high"],
      out.metadata["ci_excludes_zero"])
# -1.0e-04  3.5e-05  False
print(out.metadata["adf_p"], out.metadata["unit_root_suspected"])
# 0.003  False   (stationary; slope read is trustworthy)

See also

  • compute_ic / compute_spread_series


    Canonical producers of the (date, value) series this diagnostic consumes — IC series, spread series, or any other factor-mimicking- portfolio return series.

    api/metrics/ic →

  • positive_rate / oos


    Sibling series diagnostics on the same input shape — sign significance and IS/out-of-sample (OOS) persistence.

    api/metrics/positive_rate →

  • compute_rolling_common_beta


    Common-factor source of a rolling-\(\beta\) series for stability trend analysis via the same primitive.

    api/metrics/common_beta →

  • Statistical methods


    Theil-Sen breakdown point, ADF persistence diagnostic (MacKinnon response surface), and Hamed-Rao Mann-Kendall inference.

    reference/statistical-methods →

  • Metric applicability reference


    When this metric applies and the sample-size guards that gate it (\(n \geq 10\)).

    reference/metric-applicability →

  • Series diagnostics landing


    Adjacent axis-agnostic series diagnostics.

    api/metrics/series-tools →