The cross-slice inference surface is two function pairs, split on whether the slices share dates:
- Cross-sectional / date-aligned —
slice_pairwise_test/slice_joint_test(sector, size bucket, liquidity tier). - Date-disjoint —
slice_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 ¶
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: |
required |
metric
|
MetricBase
|
A metric instance whose module declares
|
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 |
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
|
|
DataFrame
|
order. |
DataFrame
|
effect size), |
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): |
DataFrame
|
|
DataFrame
|
date-cluster count is |
DataFrame
|
|
DataFrame
|
asymptotic χ²; |
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
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
|
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 ¶
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: |
required |
metric
|
MetricBase
|
A metric instance whose module declares
|
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 |
DataFrame
|
p_value, stat_type, reference_dist, df_num, df_denom, |
DataFrame
|
multiplicity) |
DataFrame
|
mechanism columns disclose the reference: |
DataFrame
|
|
DataFrame
|
|
DataFrame
|
|
DataFrame
|
count is |
DataFrame
|
over-reject). |
DataFrame
|
no family-internal correction to apply. |
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
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: |
required |
metric
|
MetricBase
|
A metric instance whose module declares
|
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'
|
rng_seed
|
int | None
|
Reproducibility seed for the |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Long-form |
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 |
DataFrame
|
date counts (disjoint spans differ in length). |
DataFrame
|
signed |
DataFrame
|
scale. The mechanism columns disclose the path (constant across |
DataFrame
|
rows): |
DataFrame
|
(the disjoint-sample reference has no finite-cluster denominator); |
DataFrame
|
|
DataFrame
|
or |
DataFrame
|
|
DataFrame
|
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
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 |
TypeError
|
Metric is not slice-test-eligible (no |
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-diagonal — Var(μ_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: |
required |
metric
|
MetricBase
|
A metric instance whose module declares
|
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'
|
rng_seed
|
int | None
|
Reproducibility seed for the |
None
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Single-row |
DataFrame
|
p_value, stat_type, reference_dist, df_num, df_denom, |
DataFrame
|
multiplicity) |
DataFrame
|
columns |
DataFrame
|
disclose the reference: |
DataFrame
|
(restriction rank) and |
DataFrame
|
finite-cluster denominator); |
DataFrame
|
|
DataFrame
|
|
DataFrame
|
|
DataFrame
|
omnibus has no family-internal correction. |
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
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 |
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.
icdrops any date belowMIN_IC_ASSETS_HARD); asectorcut 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(...) |