Skip to content

factrix.metrics.monotonicity

Monotonicity test for cross-sectional panels.

Measures whether factor quantile groups exhibit monotonic return ordering. Per-period: split into n_groups by factor rank, compute mean return per group, Spearman corr between group index and return.

Notes

Pipeline. Per-period Spearman corr between quantile index and group mean return (cross-section step), then non-overlapping cross-asset t on the per-period series.

Input. DataFrame with date, asset_id, factor, forward_return.

factrix.metrics.monotonicity.monotonicity

monotonicity(data: DataFrame, overlap_periods: int = DEFAULT_FORWARD_PERIODS, n_groups: int = 10, factor_cols: Sequence[str] = ('factor',), return_col: str = 'forward_return', tie_policy: TiePolicy = 'ordinal', direction: MRDirection = 'increasing', n_resamples: int = 999, rng: Rng = None, *, expected_warnings: tuple[str, ...] = ()) -> dict[str, MetricResult]

Quantile return monotonicity — Patton-Timmermann (2010) MR test.

value / stat = the MR statistic J = min_i mean_t Delta_{i,t}, the smallest average adjacent bucket-return difference, in return units. p_value is its stationary-bootstrap p.

Parameters:

Name Type Description Default
data DataFrame

Panel with date, asset_id, factor, forward_return.

required
n_groups int

Number of quantile groups (default 10 for a ~2000-name universe). Use 5 for n_assets < 1000, 3 for n_assets < 200.

10
tie_policy TiePolicy

Bucketing tie-break policy — the same knob and values as quantile_spread / k_spread, see _assign_quantile_groups. Either "ordinal" or "average"; anything else raises UserInputError. Under "ordinal" the realised per-period tie ratio is reported in metadata["tie_ratio"]; when its median exceeds TIE_RATIO_WARN_THRESHOLD the result carries WarningCode.HIGH_TIE_RATIO and echoes a UserWarning unless the code is declared in expected_warnings.

'ordinal'
direction MRDirection

Which monotone pattern H1 asserts — "increasing" (default) or "decreasing" in bucket index. Declare it from the factor's hypothesis; running both and reporting the smaller p is a two-sided search charged at a one-sided level.

'increasing'
n_resamples int

Bootstrap resamples for the MR null distribution. Must be at least BOOTSTRAP_RESAMPLES_FLOOR — the shared floor every factrix entry point reporting an inference drawn from resamples enforces. Below it the empirical p is dominated by resampling noise: the Monte-Carlo SE of the reported p is sqrt(p(1-p)/n_resamples), reported as metadata["p_value_mc_se"]. At the default 999 resamples a p near 0.05 carries ~0.7pp of MC SE, so 0.043 and 0.058 are one draw apart; raise n_resamples to shrink it, since no amount of data does.

999
rng Rng

Bootstrap seed — an int, None, or a numpy.random.Generator. None resolves one and reports it in metadata["seed"], so a run stays reproducible after the fact; a Generator is used as-is and advanced, and metadata["seed"] is None because only its owner can reproduce the draw. An unseeded re-run redraws the null, moving the p by roughly metadata["p_value_mc_se"].

None
expected_warnings tuple[str, ...]

Warning codes the caller declares as part of the study design. The structured record is retained; only the per-call UserWarning echo is suppressed.

()

Returns:

Type Description
dict[str, MetricResult]

MetricResult with value = stat = the MR statistic and

dict[str, MetricResult]

p_value from the bootstrap. The descriptive Spearman summaries

dict[str, MetricResult]

stay in metadata.

Notes

Per non-overlap date t, assets are bucketed into n_groups by factor rank and each bucket's mean return is taken. The MR test then works on the adjacent differences Delta_{i,t} = mu_{i,t} - mu_{i-1,t}: J = min_i mean_t Delta_{i,t}, with H0: min_i E[Delta_i] <= 0 ("the relation is not monotonically increasing") against H1: min_i E[Delta_i] > 0. The null distribution comes from recentring the per-period difference matrix at zero — the least-favourable configuration under H0 — and resampling whole blocks of periods with the stationary bootstrap, which keeps both the within-period cross-bucket dependence and the serial dependence across periods. This is the test Patton-Timmermann (2010) actually propose, and the one this metric previously cited without implementing.

Why the headline is no longer mean |Spearman|. mean_t |rho_t| has a large null floor that depends on n_groups: on a factor drawn independently of returns (T=400, N=100) it reads 0.66 at n_groups=3, 0.43 at 5 and 0.27 at 10, because E|rho| > 0 under H0 by Jensen's inequality. A reader seeing "value = 0.43, Patton-Timmermann (2010)" took it as MR evidence when it was the noise floor for five buckets. The Spearman summaries remain in metadata as descriptive shape statistics — mean_abs_spearman (magnitude, always >= 0) and mean_signed (direction consistency) — where a high magnitude with a near-zero signed mean still tells the useful story that the factor sorts returns but flips sign across dates.

The secondary signed-Spearman t-test remains two-sided: it asks whether the mean signed correlation differs from zero in either direction. Its signed_spearman_alternative metadata labels that tail explicitly, because the headline is a different, one-sided MR hypothesis whose alternative="greater" must not be borrowed for this p-value.

Deviation from the paper. Patton-Timmermann bootstrap the raw (unstudentised) differences, which is what runs here. Their studentised variant is not implemented.

If the post-stride cross-section cannot fill n_groups, the short-circuit result reports n_obs as the median finite factor cross-section on that same non-overlapping sample. It does not report the raw-panel median across periods that the buckets never use.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.monotonicity import monotonicity
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=200, n_dates=180, rng=0),
...     forward_periods=5,
... )
>>> result = monotonicity(
...     panel, overlap_periods=5, n_groups=5, n_resamples=199, rng=0
... )
>>> result["factor"].name == ""
True

Use cases

  • Decile-curve monotonicity test


    The Patton-Timmermann (2010) MR test on the quantile-bucket return curve: \(J = \min_i \overline{\Delta}_i\) over the adjacent bucket differences, with \(H_0\) that the relation is not monotone and a stationary-bootstrap p. A strictly increasing decile curve is a stronger requirement than a positive long-short spread, which only needs the two end buckets to separate.

  • Magnitude vs direction, as descriptive shape


    metadata["mean_abs_spearman"] (magnitude, \(\geq 0\)) and metadata["mean_signed"] (direction consistency) still read separately: high magnitude with a near-zero signed mean flags a factor that sorts returns but flips direction across dates. They are metadata, not the headline — \(\mathbb{E}|\rho| > 0\) under \(H_0\) by Jensen, so mean \(|\rho|\) has an n_groups-dependent noise floor (0.67 / 0.42 / 0.27 at \(K = 3 / 5 / 10\)) that reads like evidence. The accompanying signed_spearman_p_value is explicitly two-sided and reports signed_spearman_alternative="two-sided"; it is separate from the headline MR test's one-sided alternative="greater".

Per-bucket cardinality floor

min_assets_per_group = 50 (Patton-Timmermann 2010) — the slice- test function downscales n_groups automatically so each bucket meets the floor; below it, per-date bucket means are noise-dominated and the rank statistic is unreliable. Defaults: n_groups=10 for universes around 2000 stocks; drop to 5 for \(n_{assets} < 1000\) and 3 for \(n_{assets} < 200\).

Worked example — Spearman direction test on quantile-bucket returns

monotonicity on a synthetic cross-sectional panel

import factrix as fx
from factrix.metrics.monotonicity import monotonicity
from factrix.preprocess import compute_forward_return

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

# monotonicity returns dict[str, MetricResult], keyed by factor column
out = monotonicity(panel, overlap_periods=5, n_groups=10, rng=0)["factor"]
print(out.value, out.p_value,
      out.metadata["mr_adjacent_diffs"],
      out.metadata["mean_abs_spearman"])
# value = stat = J, the smallest average adjacent bucket-return
# difference (return units); p_value is its bootstrap p under
# H0 "not monotonically increasing"; mr_adjacent_diffs shows which
# step binds; mean_abs_spearman is the descriptive magnitude.

Declare the direction, do not search it

direction="increasing" (default) or "decreasing" states what \(H_1\) asserts. Running both and reporting the smaller p is a two-sided search charged at a one-sided level. For a factor hypothesised to be negatively related to returns, pass direction="decreasing" (or flip the factor's sign upstream) rather than reading the increasing test and inverting it.

Deviation from the paper

Patton-Timmermann bootstrap the raw (unstudentised) adjacent differences, which is what runs here; their studentised variant is not implemented. The bootstrap is the library's shared stationary (Politis-Romano 1994) resampler with the Politis-White (2004) automatic block length, applied to the whole \((T, K-1)\) difference matrix under one row-index draw so within-period cross-bucket dependence is preserved. Empirical p uses the Davison-Hinkley \(+1\) smoothing shared with the rest of the library, so p is never exactly 0.

See also