Skip to content

The cross-slice inference surface is two function pairs, split on whether the slices share dates:

  • Cross-sectional / date-alignedslice_pairwise_test / slice_joint_test (sector, size bucket, liquidity tier).
  • Date-disjointslice_period_pairwise_test / slice_period_joint_test (market regime, calendar period, in/out-of-sample). See Date supports: aligned vs disjoint.

factrix.slice_pairwise_test

slice_pairwise_test(data: DataFrame, metric: MetricBase, *, by: str, factor_col: str) -> DataFrame

Cross-slice pairwise Wald contrasts on a per-date metric panel.

Data-first counterpart of :func:factrix.by_slice: partitions a raw panel on by, builds each slice's per-date metric series via the metric's producer, aligns on date, and runs the analytic Newey-West HAC + slice-cluster Wald on every slice pair. Cross-sectional only (slices must share dates).

Parameters:

Name Type Description Default
data DataFrame

Raw long-format panel — same input contract as :func:factrix.evaluate (date, asset_id, <factor_col>, forward_return). Must contain by; compose it upstream if needed.

required
metric MetricBase

A metric instance whose module declares per_date_series (ic() / fm_beta() / positive_rate()). The bare class is rejected.

required
by str

Column whose values define the slice partition.

required
factor_col str

The single factor column to score per slice.

required

Returns:

Type Description
DataFrame

Long-form pl.DataFrame with columns ``(slice_a, slice_b,

DataFrame

n_obs, mean_diff, stat, p_raw, p_adj, stat_type, reference_dist,

DataFrame

df_num, df_denom, multiplicity)``; one row per ordered slice pair

DataFrame

(a, b) with a before b in the partition's iteration

DataFrame

order. mean_diff is the signed μ_a − μ_b (direction /

DataFrame

effect size), stat the Wald statistic, and p_adj the Holm

DataFrame

step-down family-wise correction across the K(K-1)/2 pairs. The

DataFrame

trailing five columns disclose the active mechanism (constant

DataFrame

across rows): stat_type="wald"; reference_dist="F" with

DataFrame

df_num=1 (single contrast) and df_denom=n_obs-1 — the

DataFrame

date-cluster count is T=n_obs, so the finite-sample

DataFrame

F_{1, T-1} reference is used in place of the over-rejecting

DataFrame

asymptotic χ²; multiplicity="holm".

Raises:

Type Description
UserInputError

metric is not a metric instance, or factor_col is absent.

ValueError

Fewer than two slice values, or fewer than two dates aligned across all slices (e.g. a date-disjoint partition).

TypeError

Metric is not slice-test-eligible (no per_date_series capability / no producer).

Examples:

Pairwise information coefficient (IC) contrasts across two sectors on a synthetic cross-sectional panel — partition on a sector column, score ic per sector, contrast the per-date series:

>>> import polars as pl
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics import ic
>>> raw = fx.datasets.make_cs_panel(n_assets=100, n_dates=250)
>>> panel = compute_forward_return(raw, forward_periods=5)
>>> assets = panel["asset_id"].unique().sort().to_list()
>>> sector = {a: ("tech" if i % 2 else "fin")
...           for i, a in enumerate(assets)}
>>> panel = panel.with_columns(
...     pl.col("asset_id").replace_strict(sector).alias("sector")
... )
>>> pairs = fx.slice_pairwise_test(
...     panel, ic(), by="sector", factor_col="factor"
... )
>>> pairs.columns
['slice_a', 'slice_b', 'n_obs', 'mean_diff', 'stat', 'p_raw', 'p_adj', 'stat_type', 'reference_dist', 'df_num', 'df_denom', 'multiplicity']

factrix.slice_joint_test

slice_joint_test(data: DataFrame, metric: MetricBase, *, by: str, factor_col: str) -> DataFrame

Omnibus Wald χ² that all K slice means are equal.

The joint restriction is β_0 = β_1 = … = β_{K-1}, encoded as K-1 contrasts against the first slice; the Wald statistic follows χ²_{K-1} under H₀. Same data-first contract and cross-sectional (shared-date) limitation as :func:slice_pairwise_test.

Parameters:

Name Type Description Default
data DataFrame

Raw long-format panel (see :func:slice_pairwise_test).

required
metric MetricBase

A metric instance whose module declares per_date_series. The bare class is rejected.

required
by str

Column whose values define the slice partition.

required
factor_col str

The single factor column to score per slice.

required

Returns:

Type Description
DataFrame

Single-row pl.DataFrame with columns ``(n_obs, k_slices, stat,

DataFrame

p_value, stat_type, reference_dist, df_num, df_denom,

DataFrame

multiplicity).stat`` is the joint Wald statistic. The

DataFrame

mechanism columns disclose the reference: stat_type="wald",

DataFrame

reference_dist="F", df_num=K-1 (restriction rank) and

DataFrame

df_denom=n_obs-1p_value is the finite-sample

DataFrame

F_{K-1, T-1} survival of stat / (K-1) (the date-cluster

DataFrame

count is T=n_obs, so the asymptotic χ² reference would

DataFrame

over-reject). multiplicity is None — a single omnibus has

DataFrame

no family-internal correction to apply.

Raises:

Type Description
UserInputError

metric is not a metric instance, or factor_col is absent.

ValueError

Fewer than two slice values, or fewer than two dates aligned across all slices.

TypeError

Metric is not slice-test-eligible.

Examples:

Joint omnibus test that mean information coefficient (IC) is identical across two sectors (see :func:slice_pairwise_test for the panel construction):

>>> import polars as pl
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics import ic
>>> raw = fx.datasets.make_cs_panel(n_assets=100, n_dates=250)
>>> panel = compute_forward_return(raw, forward_periods=5)
>>> assets = panel["asset_id"].unique().sort().to_list()
>>> sector = {a: ("tech" if i % 2 else "fin")
...           for i, a in enumerate(assets)}
>>> panel = panel.with_columns(
...     pl.col("asset_id").replace_strict(sector).alias("sector")
... )
>>> joint = fx.slice_joint_test(
...     panel, ic(), by="sector", factor_col="factor"
... )
>>> joint["df_num"][0]
1

factrix.slice_period_pairwise_test

slice_period_pairwise_test(data: DataFrame, metric: MetricBase, *, by: str, factor_col: str, method: Method = 'bootstrap', rng_seed: int | None = None) -> DataFrame

Pairwise cross-slice contrasts for a date-disjoint partition.

Date-disjoint counterpart of :func:slice_pairwise_test: partitions a raw panel on by, builds each slice's per-date metric series via the metric's producer, and contrasts every slice pair as independent samples (no date inner-join). The right tool for regime analysis (bull / bear, high-vol / low-vol) and other time-disjoint splits (calendar period, in/out-of-sample), where the cross-sectional pair would raise <2 aligned dates.

Parameters:

Name Type Description Default
data DataFrame

Raw long-format panel — same input contract as :func:factrix.evaluate (date, asset_id, <factor_col>, forward_return). Must contain by; compose it upstream if needed.

required
metric MetricBase

A metric instance whose module declares per_date_series (ic() / fm_beta() / positive_rate()). The bare class is rejected.

required
by str

Column whose values define the slice partition (regime label, calendar bucket, …).

required
factor_col str

The single factor column to score per slice.

required
method Method

"bootstrap" (default) runs an independent stationary block bootstrap per slice with Romano-Wolf step-down p_adj; "analytic" runs Newey-West HAC per slice with Welch-style pairwise contrasts and Holm p_adj. Use "bootstrap" for short regimes (T ≈ 30-80); "analytic" for long spans (T ≳ 100) when you want speed / determinism.

'bootstrap'
rng_seed int | None

Reproducibility seed for the "bootstrap" path (ignored by "analytic"). None draws from system entropy. This is plumbing, not a statistical knob — block length, B, and scheme are fixed by sensible defaults.

None

Returns:

Type Description
DataFrame

Long-form pl.DataFrame with columns ``(slice_a, slice_b,

DataFrame

n_periods_a, n_periods_b, mean_diff, stat, p_raw, p_adj, stat_type,

DataFrame

reference_dist, df_num, df_denom, multiplicity)``; one row per

DataFrame

ordered slice pair (a, b). n_periods_* are each slice's own

DataFrame

date counts (disjoint spans differ in length). mean_diff is the

DataFrame

signed μ_a − μ_b; stat the studentized contrast on a χ²₁

DataFrame

scale. The mechanism columns disclose the path (constant across

DataFrame

rows): stat_type="wald", df_num=1 and df_denom=None

DataFrame

(the disjoint-sample reference has no finite-cluster denominator);

DataFrame

reference_dist is "bootstrap_null" (method="bootstrap")

DataFrame

or "chi2" (method="analytic", asymptotic χ²₁); and

DataFrame

multiplicity the family-wise correction ("romano_wolf" for

DataFrame

"bootstrap", "holm" for "analytic").

Raises:

Type Description
UserInputError

metric is not a metric instance, factor_col is absent, or method is invalid.

ValueError

Fewer than two slice values, any slice with fewer than two dates, or any slice whose per-date series is below the metric's own SampleThreshold floor (the size at which :func:factrix.by_slice short-circuits the metric to NaN).

TypeError

Metric is not slice-test-eligible (no per_date_series capability / no producer).

factrix.slice_period_joint_test

slice_period_joint_test(data: DataFrame, metric: MetricBase, *, by: str, factor_col: str, method: Method = 'bootstrap', rng_seed: int | None = None) -> DataFrame

Omnibus block-diagonal Wald χ² that all K disjoint-slice means are equal.

Date-disjoint counterpart of :func:slice_joint_test. The restriction is μ_0 = μ_1 = … = μ_{K-1} (K-1 contrasts against the first slice); because the slices are independent samples, the cross-slice covariance is block-diagonalVar(μ_k) on the diagonal, zero off it. Both methods share the same Wald quadratic form; they differ in how the null is referenced, mirroring the pairwise path: "analytic" uses the χ²_{K-1} asymptotic distribution, while "bootstrap" calibrates the statistic against its own block-bootstrap null (so a short-regime omnibus stays small-sample robust instead of leaning on χ² asymptotics). Useful for regime analysis: a single test of "does this factor's edge differ across regimes at all?" before drilling into pairs.

Parameters:

Name Type Description Default
data DataFrame

Raw long-format panel (see :func:slice_period_pairwise_test).

required
metric MetricBase

A metric instance whose module declares per_date_series. The bare class is rejected.

required
by str

Column whose values define the slice partition.

required
factor_col str

The single factor column to score per slice.

required
method Method

"bootstrap" (default) sources each Var(μ_k) from an independent stationary block bootstrap; "analytic" from a per-slice Newey-West HAC. See :func:slice_period_pairwise_test.

'bootstrap'
rng_seed int | None

Reproducibility seed for the "bootstrap" path (ignored by "analytic").

None

Returns:

Type Description
DataFrame

Single-row pl.DataFrame with columns ``(k_slices, stat,

DataFrame

p_value, stat_type, reference_dist, df_num, df_denom,

DataFrame

multiplicity).stat`` is the joint Wald statistic. The mechanism

DataFrame

columns

DataFrame

disclose the reference: stat_type="wald", df_num=K-1

DataFrame

(restriction rank) and df_denom=None (disjoint samples have no

DataFrame

finite-cluster denominator); reference_dist is "chi2"

DataFrame

p_value from the χ²_{K-1} survival function — for

DataFrame

method="analytic", or "bootstrap_null" for

DataFrame

method="bootstrap". multiplicity is None — a single

DataFrame

omnibus has no family-internal correction.

Raises:

Type Description
UserInputError

metric is not a metric instance, factor_col is absent, or method is invalid.

ValueError

Fewer than two slice values, any slice with fewer than two dates, or any slice whose per-date series is below the metric's own SampleThreshold floor (the size at which :func:factrix.by_slice short-circuits the metric to NaN).

TypeError

Metric is not slice-test-eligible.

Cross-slice statistical-test function pair. Both take a date-keyed DataFrame (data-first) and a metric callable; the by column carries the slice identifier; the functions partition by by, line up per-date metric series across slices, and report inference on whether the slices' means differ.

The two functions answer different statistical questions:

Function Question Output shape
slice_pairwise_test "Which pairs differ?" — K(K−1)/2 contrasts with family-internal multiple-testing correction One row per pair: (slice_a, slice_b, n_obs, mean_diff, stat, p_raw, p_adj, stat_type, reference_dist, df_num, df_denom, multiplicity)
slice_joint_test "Do any slices differ at all?" — single omnibus Wald χ² One row: (n_obs, k_slices, stat, p_value, stat_type, reference_dist, df_num, df_denom, multiplicity)

Both functions sit in the View class: their headline output is a comparison test result. They do not participate in Benjamini-Hochberg-Yekutieli (BHY) family expansion — adjusted p is a within-slice-family closure, not a cell-level discovery commitment.

Metric capability requirement

The metric callable's module must declare per_date_series (a top-level capability function returning a (date, value) long-form frame); information coefficient (IC), Fama-MacBeth, and positive_rate ship with this declaration. A metric without it raises TypeError at the function call site.

See the docstring Examples blocks above for the canonical per-sub-universe construction (compute_ic per sector, concatenated with a sector label column).

Date supports: aligned vs disjoint

slice_pairwise_test / slice_joint_test join all slices on date and run inference on the intersected rows. Joint Newey-West (NW) heteroskedasticity-and-autocorrelation-consistent (HAC) over the (T, K) per-date metric panel needs aligned rows so cross-slice covariance enters through the joint kernel. Slices with disjoint date supports (e.g. regimes split by time period) yield zero aligned rows and these functions raise ValueError (<2 aligned dates). Date-shared slices — universe, sector, market-cap tier — are their intended use case.

A <2 aligned dates error has two distinct causes, and the message distinguishes them:

  • Date-disjoint partition — the slices share fewer than two raw dates by construction (the case above). The message names the date-disjoint partition and points at slice_period_*.
  • Date-aligned but metric-dropped — the slices do share dates, but the per-slice metric dropped most of its per-date values, so the joined panel still collapses below two rows. The usual cause is too few assets per slice (e.g. ic drops any date below MIN_IC_ASSETS_HARD); a sector cut with thin cross-sections triggers it. The message reports the raw shared-date count and blames the thin universe — widen each slice's asset universe or use a coarser partition.

For genuinely time-disjoint slices, reach for slice_period_pairwise_test / slice_period_joint_test. They build the same per-slice per-date series but do not inner-join — each slice is treated as an independent sample with block-diagonal cross-slice covariance. A two-valued method flag selects the estimator:

method Per-slice SE Pairwise p_adj Best for
"bootstrap" (default) Independent stationary block bootstrap (Politis-White automatic block length) Romano-Wolf step-down Short regimes (T ≈ 30-80); never invalid
"analytic" Per-slice Newey-West HAC, Welch-style pairwise contrast Holm step-down Long spans (T ≳ 100); fast, deterministic

Pairwise output is (slice_a, slice_b, n_periods_a, n_periods_b, mean_diff, stat, p_raw, p_adj, stat_type, reference_dist, df_num, df_denom, multiplicity) — per-slice n_periods_* because disjoint spans differ in length. The omnibus is a block-diagonal Wald χ² returning (k_slices, stat, p_value, stat_type, reference_dist, df_num, df_denom, multiplicity).

Estimator dispatch

Estimator Inference path stat column carries
WaldNWCluster (default) Joint NW HAC over the (T, K) per-date metric panel; per-pair Wald χ² via single-row restriction matrix on the joint variance Wald χ²
BlockBootstrap Joint block-bootstrap on the same panel; per-pair p from \|mean diff\| against the bootstrap null distribution Signed mean diff

BlockBootstrap shares one set of block indices across all pair diffs per draw, so the bootstrap distribution preserves cross-pair dependence — the joint structure Romano-Wolf step-down relies on.

slice_joint_test accepts only WaldNWCluster; the omnibus Wald χ² has no canonical bootstrap analogue, so the function steers callers to slice_pairwise_test if a bootstrap path is wanted.

Multiple-testing correction (slice_pairwise_test only)

Method Default for Notes
"holm" WaldNWCluster (default) Holm step-down — conservative under arbitrary dependence
"romano_wolf" BlockBootstrap Step-down using the joint bootstrap distribution; near-optimal for date-shared slices (universe / sector)
"bonferroni" Manual opt-in For literature / cross-tool reproduction

multiple_testing="romano_wolf" with an analytic estimator raises ValueError — RW needs a bootstrap distribution that analytic estimators do not produce.

Cross-axis composition

The functions accept a single by column. For cross-axis slice analysis (regime × universe), compose a composite label upstream with pl.concat_str(...):

ic_df = ic_df.with_columns(
    pl.concat_str(["regime", "universe"], separator="_").alias("regime_x_universe")
)
slice_pairwise_test(ic_df, ic, by="regime_x_universe")

Two-way interaction decomposition (main effect + interaction with double-clustered SE) is a different statistical object and is reserved for the future factor_decomposition function.

Responsibility boundaries

Need Use
Descriptive per-slice metric values (no test) by_slice
Which slice pairs differ statistically slice_pairwise_test
Whether any slice differs (omnibus) slice_joint_test
FDR-adjusted survivor selection across factors bhy(results, ...)
Multi-factor leaderboard rendering compare(...)