factrix.metrics.oos_decay ¶
Out-of-sample (OOS) persistence analysis for any time-indexed series.
This tool is agnostic to what the series represents — it only knows about IS/OOS splits on a time-indexed numeric sequence.
Notes
Pipeline. Time-series only, IS/OOS window split on a 1-D series;
descriptive decay diagnostic (no formal H_0). oos_decay is the
single-split primitive; oos_decay_splits is the robustness
workflow that runs it over a pre-declared set of split fractions.
Input. DataFrame with date, value (IC series, CAAR series,
spread series), one row per period.
Output. MetricResult with value = survival ratio +
sign-flip / status detail in metadata.
factrix.metrics.oos_decay.oos_decay ¶
oos_decay(series: DataFrame, value_col: str = 'value', is_ratio: float = 0.7, survival_threshold: float = 0.5, *, expected_warnings: tuple[str, ...] = ()) -> MetricResult
Single-split out-of-sample (OOS) survival ratio with sign-flip detection.
Splits the sorted series at is_ratio (IS = first is_ratio * n
rows, OOS = remainder), computes |mean_OOS| / |mean_IS| (the
survival ratio), and checks for an IS/OOS sign flip.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
series
|
DataFrame
|
DataFrame with |
required |
value_col
|
str
|
Numeric column to evaluate. Null, NaN and ±inf
observations are dropped and the drop is recorded in
|
'value'
|
is_ratio
|
float
|
Fraction of the retained periods allocated to IS (default
|
0.7
|
survival_threshold
|
float
|
Minimum survival ratio for |
0.5
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
UserInputError
|
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
MetricResult with: |
MetricResult
|
|
MetricResult
|
short-circuit, and NaN when |
MetricResult
|
undefined 0/0 there, not a survival of zero. Reporting |
MetricResult
|
VETOED a factor with a large out-of-sample mean as "fully |
MetricResult
|
decayed" when the honest reading is "in-sample carried no signal |
MetricResult
|
to decay from". That case raises |
MetricResult
|
|
MetricResult
|
— the gate must not read "cannot assess" as "passed". |
MetricResult
|
|
MetricResult
|
attached; a t-stat at |
MetricResult
|
~ 0 and would invite mis-reading the diagnostic as a |
MetricResult
|
significance test) |
MetricResult
|
|
Notes
One call is one split. A regime change landing near the cut point
can reverse this gate, so for a fraction-robust read use
:func:oos_decay_splits, which runs this same primitive over a set of
fractions the caller declares up front, purges the overlapping
in-sample tail, and reports one aggregate verdict plus every
individual split. A caller-side loop over is_ratio does neither:
the overlapping-horizon leakage at each boundary stays unpurged, and
the rule that turns the per-split ratios into one verdict is never
declared.
Descriptive only — no p_value is emitted.
Split validity. is_ratio must be strictly inside (0, 1)
and is validated up front: is_ratio=1.0 used to produce an empty
OOS slice whose polars mean() is None, and float(None)
then raised a bare TypeError from deep inside the metric. Beyond
that, the min_periods floor bounds the series length, not the
split, so an extreme ratio can still leave one side with fewer than
two observations; that short-circuits with the usual
reason="insufficient_oos_periods" rather than reporting a
survival ratio computed from a single point.
Input contract. The split is a count of periods on the series'
own distinct-date grid, taken positionally after sorting by date, so
the series must carry one row per period. A duplicated date has no
defined position in that sort and can put the same chronological
period on both sides of the boundary; it is rejected rather than
aggregated under a guessed rule (see :class:UserInputError above).
Non-finite observations are dropped first, so n_obs and the split
index both count the periods that actually reached the means.
Threshold domain. survival_threshold is validated as a finite
fraction inside (0, 1]. Outside that range the knob stops gating
and starts forcing: <= 0 PASSes every series a ratio exists for,
float("nan") VETOES every one of them, and True silently
reads as 1.0. A threshold above 1 would demand out-of-sample
amplification rather than survival — read value directly for
that question.
References
- McLean-Pontiff (2016): post-publication returns ~58% lower than in-sample, with ~32% of that drop attributable to publication itself (the remaining ~26% is the pure out-of-sample decay).
- Lopez-de-Prado (2018): CPCV for robust train/test split.
Examples:
Survival on a per-date information coefficient (IC) series from
: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.oos_decay import oos_decay
>>> panel = compute_forward_return(
... fx.datasets.make_cs_panel(n_assets=80, n_dates=240, rng=0),
... forward_periods=5,
... )
>>> series = compute_ic(panel)["factor"].rename({"ic": "value"}).select("date", "value")
>>> result = oos_decay(series)
>>> result.name == ""
True
factrix.metrics.oos_decay.oos_decay_splits ¶
oos_decay_splits(series: DataFrame, value_col: str = 'value', split_fractions: Sequence[float] = DEFAULT_SPLIT_FRACTIONS, survival_threshold: float = 0.5, forward_periods: int = DEFAULT_FORWARD_PERIODS, *, expected_warnings: tuple[str, ...] = ()) -> MetricResult
Purged out-of-sample (OOS) survival over a pre-declared set of splits.
Robustness validation of :func:oos_decay, not a second search over it.
A single cut point can reverse the single-split gate when a regime change
lands near it; this runs the same primitive at every fraction in a set
the caller declares before seeing any result, and reports one
aggregate verdict plus every individual split.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
series
|
DataFrame
|
DataFrame with |
required |
value_col
|
str
|
Numeric column to evaluate. |
'value'
|
split_fractions
|
Sequence[float]
|
The declared cut points, each strictly inside
|
DEFAULT_SPLIT_FRACTIONS
|
survival_threshold
|
float
|
Minimum aggregate survival ratio for
|
0.5
|
forward_periods
|
int
|
Length in periods of the forward-return window the
series values were built from (default
:data: |
DEFAULT_FORWARD_PERIODS
|
expected_warnings
|
tuple[str, ...]
|
Declared warning codes, passed to each split. |
()
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
MetricResult with: |
MetricResult
|
|
MetricResult
|
splits. NaN when any declared split could not be assessed — the |
MetricResult
|
aggregate is defined over the set that was declared, not over |
MetricResult
|
whichever subset happened to compute, and a silently narrowed |
MetricResult
|
sample would read as a verdict. |
MetricResult
|
|
MetricResult
|
drop), on the |
MetricResult
|
|
MetricResult
|
|
MetricResult
|
|
Notes
Aggregate rule. status="PASS" requires all three of: every
declared split assessable, no split sign-flipped, and the median
survival ratio at or above survival_threshold. Direction and
magnitude are aggregated differently on purpose — a sign flip says
the factor predicts the wrong way over some tail, which a majority
vote would launder, while a magnitude below the bar at one cut point
is exactly the arbitrariness the median is here to absorb. The median
is fixed rather than exposed as a knob: choosing an aggregate after
seeing the per-split ratios is the search this workflow closes off.
value is still reported when the gate vetoes on a sign flip — it
is the ratio that ran, and the veto is recorded in status and
n_sign_flips rather than by corrupting the number.
Purge. forward_periods periods are dropped off the end of
each in-sample window, counted on the series' own distinct-date grid
after the non-finite drop (never calendar time) — the same grid
the split index is taken on. Dropping observations only removes
periods from that grid, so the resulting gap spans at least
forward_periods periods of the producing panel's grid, and more
wherever the band it covers had missing values: on a 104-period
series with 4 non-finite observations inside the band,
forward_periods=5 leaves a gap of 10 panel periods. Purging wider
than the horizon is safe — it only removes in-sample observations —
where purging narrower would leave the leakage the gap exists for.
A value stamped at period t built from a
forward_periods-period forward return is realised over
(t, t + forward_periods], so without the gap the last in-sample
observations are partly realised inside the out-of-sample window —
the boundary leakage Lopez-de-Prado (2018)
purges. The gap comes off the in-sample side only; the out-of-sample
window is what is being validated and is never shortened for it. A
series with no forward window (a contemporaneous spread) still
passes forward_periods=1, which drops one period — conservative,
and cheaper than a knob that turns the guard off.
Multiplicity. This is robustness validation, not an additional
model search, and the distinction is what keeps it honest. The split
set is declared up front, every split is reported, the aggregate is
fixed, and no p_value is emitted anywhere — so there is nothing
to correct and no best split to select. Re-running with different
fraction sets until one passes converts it into an uncorrected
search; the pre-declaration is the only thing preventing that, and
the library cannot enforce it for you.
References
- Lopez-de-Prado (2018): purging and embargoing overlapping train/test windows; CPCV.
- McLean-Pontiff (2016): post-publication
decay the
survival_thresholddefault is calibrated against.
Examples:
>>> import factrix as fx
>>> from factrix.metrics.ic import compute_ic
>>> from factrix.metrics.oos_decay import oos_decay_splits
>>> from factrix.preprocess import compute_forward_return
>>> panel = compute_forward_return(
... fx.datasets.make_cs_panel(n_assets=80, n_dates=240, rng=0),
... forward_periods=5,
... )
>>> series = compute_ic(panel)["factor"].select("date", "ic")
>>> result = oos_decay_splits(series, value_col="ic", forward_periods=5)
>>> result.metadata["split_fractions"]
(0.6, 0.7, 0.8)
>>> len(result.metadata["splits"])
3
Descriptive only — no formal \(H_0\)
oos_decay emits a survival ratio + sign-flip detail;
no p_value is attached and stat is None. A \(t\)-test at the
MIN_OOS_PERIODS_HARD floor would have power \(\approx 0\) and would
invite mis-reading the diagnostic as a significance test. Callers
routing this output into Benjamini-Hochberg-Yekutieli (BHY) / gate logic must read status
("PASS" / "VETOED") and sign_flipped, not a probability.
Use cases¶
-
Persistence read on a factor-return series
oos_decayis a standalone series diagnostic — input is a 1-D(date, value)series, typically information coefficient (IC) fromcompute_ic, spread fromcompute_spread_series, or any other factor-mimicking-portfolio return series. Reports \(|\mathrm{mean}_{\text{OOS}}| / |\mathrm{mean}_{\text{IS}}|\) on a singleis_ratiosplit. -
One row per period, finite values only
The split is a count of periods on the series' own distinct-date grid, taken positionally after a sort by date. A duplicated date has no defined position in that sort, so it could put the same chronological period on both sides of the boundary; it raises
UserInputErrorrather than being aggregated under a rule the caller never chose. Null / NaN / \(\pm\infty\) observations are dropped first and recorded inmetadata, son_obsand the split index count the same periods. -
survival_thresholdis a retention fraction
Its domain is the finite half-open interval \((0, 1]\), validated at construction. Outside it the knob stops gating and starts forcing: \(\le 0\) passes every series a ratio exists for,
float("nan")vetoes every one of them, andTruereads as1.0. A threshold above 1 would demand out-of-sample amplification rather than survival — readvaluedirectly for that question. -
Sign-flip veto
Any split with opposite-signed IS and out-of-sample (OOS) means flips
sign_flipped = Trueand forcesstatus = "VETOED"— IC sign-flip OOS means the factor predicts the wrong direction, not just a weaker one. McLean & Pontiff (2016) report average OOS decay around 32 %; factrix's defaultsurvival_threshold = 0.5sits inside that window. -
Sweep the split fraction with
oos_decay_splits
One
oos_decaycall is oneis_ratio, and a regime change landing near that cut point can reverse the gate.oos_decay_splitsruns the same primitive over a set of fractions the caller declares up front (default(0.6, 0.7, 0.8)), purges the overlapping in-sample tail, and reports one aggregate verdict plus every individual split.
Robustness sweep — oos_decay_splits¶
Robustness validation, not another model search
The split set is declared before the sweep runs, every split is reported, the aggregate rule is fixed, and no \(p\)-value is emitted anywhere — so there is nothing to correct for and no best split to select. Re-running with different fraction sets until one passes turns it back into an uncorrected search. The pre-declaration is the only thing preventing that, and the library cannot enforce it for you.
Aggregate rule. status = "PASS" requires all three of:
- every declared split assessable (a split that could not be computed
withholds the aggregate:
valueis NaN,reason = "unassessable_splits"— "cannot assess" must not read as "passed"); - no split sign-flipped;
- the median survival ratio at or above
survival_threshold.
Direction and magnitude are aggregated differently on purpose. A sign flip
says the factor predicts the wrong way over some contiguous tail, which the
single-split primitive already treats as a hard veto — a majority vote over
splits would launder it. A magnitude below the bar at one cut point is
exactly the arbitrariness the median is there to absorb (50 % breakdown
point, and order-invariant, so the declaration order cannot move the
verdict). The median is fixed rather than exposed as a knob: choosing an
aggregate after seeing the per-split ratios is the search this closes off.
When the gate vetoes on a sign flip, value still carries the median that
ran — the veto lives in status and n_sign_flips, not in a corrupted
number.
Purge gap. A value stamped at period \(t\) built from a
forward_periods-period forward return is realised over
\((t,\, t + \texttt{forward\_periods}]\), so the last in-sample observations
are partly realised inside the out-of-sample window. oos_decay_splits
drops forward_periods periods off the end of each in-sample window —
counted on the series' own distinct-date grid after the non-finite drop,
never calendar time — which is the purge of Lopez de Prado (2018). Because
that drop only removes periods, the resulting gap spans at least
forward_periods periods of the producing panel's grid (a 104-period series
with 4 non-finite observations inside the band gives a 10-panel-period gap at
forward_periods=5). Purging wider than the horizon is safe; purging
narrower would leave the leakage the gap exists for. The gap comes off the
in-sample side only: the out-of-sample window is the thing being
validated and is never shortened to protect the window it is validated
against.
from factrix.metrics import oos_decay_splits
out = oos_decay_splits(
ic_df, value_col="ic", split_fractions=(0.6, 0.7, 0.8), forward_periods=5,
)
print(out.value, out.metadata["status"]) # median ratio, aggregate gate
for split in out.metadata["splits"]: # provenance, ascending
print(split["split_fraction"], split["n_is"], split["n_oos"],
split["survival"], split["sign_flipped"], split["status"])
Choosing a function¶
| Goal | Function |
|---|---|
Single-split OOS survival + sign-flip gate on a (date, value) series |
oos_decay |
| Purged survival across a pre-declared set of splits, with provenance | oos_decay_splits |
Worked example — IC series fed into oos_decay¶
compute_ic → oos_decay
import factrix as fx
from factrix.metrics.ic import compute_ic
from factrix.metrics.oos_decay import oos_decay
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 = oos_decay(ic_df, value_col="ic")
print(out.value, out.metadata["status"], out.metadata["sign_flipped"])
# 0.93 PASS False (approximate)
print(out.metadata["is_ratio"],
out.metadata["mean_is"], out.metadata["mean_oos"])
# 0.7 0.0771 0.0719 (approximate)
# One call is one split. For a fraction-robust read, declare the split
# set up front and let oos_decay_splits purge and aggregate it.
from factrix.metrics import oos_decay_splits
swept = oos_decay_splits(ic_df, value_col="ic", forward_periods=5)
print(swept.value, swept.metadata["status"], swept.metadata["n_sign_flips"])
See also¶
-
compute_ic/compute_spread_series
Canonical producers of the
(date, value)series this diagnostic consumes. -
positive_rate/trend
Sibling series diagnostics on the same input shape — sign significance and slope detection. Pair with
ooswhen both in-sample magnitude and out-of-sample persistence matter. -
by_slice
Per-slice survival summaries (regime / universe / sector).
-
Metric applicability reference
When this metric applies and the sample-size guards that gate it (
MIN_OOS_PERIODS_HARD * 2floor; per-splitMIN_OOS_PERIODS_HARDon each side). -
Series diagnostics landing
Adjacent axis-agnostic series diagnostics.