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 ¶
slice_pairwise_test(data: DataFrame, metric: MetricBase, *, by: str, factor_col: str, overlap_periods: int | None = None, strict: bool = True, expected_warnings: tuple[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: |
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 |
overlap_periods
|
int | None
|
The evaluation-grid overlap the joint HAC bandwidth
resolves at (this test has no sample floor to gate).
Normally omitted — :func: |
None
|
strict
|
bool
|
Accepted so the four slice-test entry points share one
signature shape; inert here. This path gates no slice on the
metric's |
True
|
expected_warnings
|
tuple[str, ...]
|
:class: |
()
|
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
|
|
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 ¶
slice_joint_test(data: DataFrame, metric: MetricBase, *, by: str, factor_col: str, overlap_periods: int | None = None, strict: bool = True, expected_warnings: tuple[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: |
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 |
overlap_periods
|
int | None
|
The evaluation-grid overlap the joint HAC bandwidth
resolves at (this test has no sample floor to gate). Normally
omitted — :func: |
None
|
strict
|
bool
|
Accepted so the four slice-test entry points share one
signature shape; inert here. This path gates no slice on the
metric's |
True
|
expected_warnings
|
tuple[str, ...]
|
:class: |
()
|
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
|
|
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', overlap_periods: int | None = None, n_resamples: int = 999, rng: Rng = None, strict: bool = True, expected_warnings: tuple[str, ...] = ()) -> 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-period 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'
|
overlap_periods
|
int | None
|
The evaluation-grid overlap the sample floor,
bootstrap block-length floor, and analytic per-slice HAC bandwidth
resolve at. Normally omitted — :func: |
None
|
n_resamples
|
int
|
|
999
|
rng
|
Rng
|
Reproducibility seed for the |
None
|
strict
|
bool
|
|
True
|
expected_warnings
|
tuple[str, ...]
|
:class: |
()
|
Returns:
| Name | 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, min_periods, seed, |
|
DataFrame
|
reason) |
|
DataFrame
|
are each slice's own date counts (disjoint spans differ in length) |
|
DataFrame
|
and |
|
DataFrame
|
panel's stamped or declared |
|
DataFrame
|
pair, |
|
DataFrame
|
(NaN |
|
DataFrame
|
non-rejection), or |
|
DataFrame
|
|
|
DataFrame
|
into a wider family. |
|
DataFrame
|
signed |
|
DataFrame
|
|
|
path |
DataFrame
|
|
DataFrame
|
|
|
DataFrame
|
|
|
DataFrame
|
Welch-Satterthwaite |
|
DataFrame
|
their own HAC variances is the Welch two-sample setting, and the |
|
DataFrame
|
asymptotic χ²₁ used earlier over-rejected at short slices); and |
|
DataFrame
|
|
|
DataFrame
|
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
ValueError
|
Fewer than two slice values, any slice with fewer than
two dates, or ( |
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', overlap_periods: int | None = None, n_resamples: int = 999, rng: Rng = None, strict: bool = True, expected_warnings: tuple[str, ...] = ()) -> 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 an
F_{K-1, ν} reference with a Satterthwaite ν, while "bootstrap" calibrates the
statistic against its own block-bootstrap null. The bootstrap path is
still known to over-reject in the measured K=5 short-slice cells.
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'
|
overlap_periods
|
int | None
|
The evaluation-grid overlap the sample floor,
bootstrap block-length floor, and analytic per-slice HAC bandwidth
resolve at. Normally omitted — :func: |
None
|
n_resamples
|
int
|
|
999
|
rng
|
Rng
|
Reproducibility seed for the |
None
|
strict
|
bool
|
|
True
|
expected_warnings
|
tuple[str, ...]
|
:class: |
()
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Single-row |
DataFrame
|
stat, p_value, stat_type, reference_dist, df_num, df_denom, |
DataFrame
|
multiplicity, min_periods, seed, short_slice_periods, |
DataFrame
|
warning_codes, unexpected_warning_codes, reason) |
DataFrame
|
statistic; |
DataFrame
|
|
DataFrame
|
stamped or declared |
DataFrame
|
and |
DataFrame
|
row. |
DataFrame
|
the test raised (today only |
DataFrame
|
|
DataFrame
|
|
DataFrame
|
attr: |
DataFrame
|
attr: |
DataFrame
|
|
DataFrame
|
code is gated on, next to the |
DataFrame
|
read against. The mechanism |
DataFrame
|
columns |
DataFrame
|
disclose the reference: |
DataFrame
|
(restriction rank); |
DataFrame
|
|
DataFrame
|
Satterthwaite |
DataFrame
|
pairwise Welch reference — or |
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 ( |
TypeError
|
Metric is not slice-test-eligible. |
Warns:
| Type | Description |
|---|---|
UserWarning
|
The excess is not the reference distribution ( |
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.
One signature shape¶
All four entry points take the same parameters, so moving a call between the cross-sectional and the date-disjoint path is a one-word edit:
def slice_test(data, metric, *, by, factor_col,
overlap_periods=None, strict=True, expected_warnings=()): ...
The slice_period_* pair adds the three knobs its resampling machinery
needs — method, n_resamples, rng — and nothing else differs.
Two of the shared knobs are inert on the cross-sectional pair, kept so the shape holds rather than because they do work there:
| Knob | slice_period_* |
slice_pairwise_test / slice_joint_test |
|---|---|---|
strict |
gates each slice on the metric's SampleThreshold floor; False returns unavailable rows (see below) |
inert — this path gates no slice on a floor; a slice too thin to produce a per-date series collapses the inner join and raises <2 aligned dates, which is not a shortage strict=False could admit |
expected_warnings |
live on slice_period_joint_test (see below); validated but silent on slice_period_pairwise_test |
validated, but this path raises no WarningCode of its own, so there is nothing to keep quiet |
Validation is the part that is never inert: an unknown code is rejected at
every one of the four, on the same contract as evaluate, because a
declaration that marks nothing is always a typo.
Evaluation-grid overlap (overlap_periods)¶
The joint NW HAC bandwidth is floored at the evaluation-grid overlap —
overlapping forward returns make each per-date series autocorrelated, and a
kernel that does not cover the overlap under-estimates the variance. There
is no sample floor to gate here, so the overlap drives the bandwidth alone.
Resolution follows evaluate's contract, the same one the slice_period_*
pair applies: the stamp left by compute_forward_return is the truth, a
declared overlap_periods= disagreeing with it is rejected, and an
unstamped (self-attached forward_return) panel must declare it rather than
fall back to a silent default.
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
UserInputError on by (<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.
The refusal is symmetric¶
Each pair refuses the other's partition, before its metric runs, and names the entry point that fits:
| You called | With slices that | You get |
|---|---|---|
slice_pairwise_test / slice_joint_test |
share <2 dates (date-disjoint) | UserInputError on by: <2 aligned dates … use slice_period_pairwise_test / slice_period_joint_test for date-disjoint partitions. |
slice_period_pairwise_test / slice_period_joint_test |
share ≥2 dates in any pair (date-aligned) | UserInputError on by: slices 'tech' and 'fin' share 120 dates … use slice_pairwise_test / slice_joint_test for date-aligned partitions. |
The period-family refusal is a hard error, not a warning: those tests treat
each slice as an independent sample with block-diagonal cross-slice
covariance, so slices sharing dates would have their common shocks counted
as independent evidence and the p-values would be anticonservative with
nothing in the result marking it. Partially overlapping spans are refused
on the same rule — the shared periods carry the shared shock whether they
are 10% of the span or all of it. Slices sharing exactly one date are
tolerated: a date-axis partition truncated at a regime boundary can leave
one common period, which
slice_boundary_truncation already
describes.
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 |
Finite-sample note |
|---|---|---|---|
"bootstrap" (default) |
Independent stationary block bootstrap (Politis-White block length, floored at overlap_periods); pairwise bootstrap-t uses a per-draw block SE |
Romano-Wolf step-down on the same joint studentized roots | Pairwise overlap cells are near nominal; the omnibus still over-rejects in measured K=5 short-slice cells |
"analytic" |
Per-slice Newey-West HAC, Welch-style pairwise contrast | Holm step-down | Fast and deterministic; better calibrated for measured K=5 pairwise contrasts |
Under method="bootstrap" both take n_resamples= (default 999, floored
at 199) and rng= — the library-wide resampling knobs, see
Resampling knobs.
"analytic" ignores both.
Neither method is uniformly better in small samples. See Joint period tests on short slices before interpreting a short-regime omnibus or choosing the pairwise path.
Each slice's per-period series must clear the metric's own sample floor,
resolved at the panel's stamped or declared overlap_periods — the same
floor by_slice short-circuits on. The bootstrap block length is also never
shorter than that known dependence horizon. Otherwise the test raises
UserInputError on by
rather than return an uncalibrated contrast. Plan the partition against
sample_requirements
(e.g. positive_rate() needs 10 periods per slice at overlap_periods=1,
50 at the default 5). Resolution follows evaluate's contract: the stamp
left by compute_forward_return is the truth, a declared
overlap_periods= disagreeing with it is rejected, and an unstamped
(self-attached forward_return) panel must declare it — the same value
by_slice(..., overlap_periods=) takes, so the descriptive and the
inferential path never gate at different floors.
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, min_periods, reason) — per-slice n_periods_*
because disjoint spans differ in length, min_periods the floor they
were gated on. In either slice test a pair whose contrast variance
collapses carries NaN in stat / p_raw / p_adj (no test, not a
non-rejection) and is left out of the Holm family; the period tests name
it reason="degenerate_variance". The omnibus is a block-diagonal Wald
χ² returning (k_slices, n_periods_min, stat, p_value, stat_type,
reference_dist, df_num, df_denom, multiplicity, min_periods, reason).
Non-strict batch mode (slice_period_* only)¶
strict=False is the counterpart of evaluate(strict=False) for batch
regime research, where one thin regime must not abort a sweep over
factors × samples. A slice below the floor no longer raises; the affected
rows come back in the same schema with reason="insufficient_periods"
and NaN in stat / p_*:
- joint — the omnibus restriction spans every slice, so one thin
slice makes the whole test unavailable: a single row with
n_periods_min(the shortest slice) againstmin_periods. - pairwise — every pair touching a thin slice is an unavailable row; the remaining pairs are tested and form their own multiplicity family (identical to running the test on the valid slices alone).
reason is null on a tested row, so reason.is_null() is the filter to
apply before folding rows from many runs into one family.
Warning contract (slice_period_joint_test)¶
The slicing functions follow the same marked, never dropped contract as
evaluate(..., expected_warnings=). The omnibus has one advisory —
short_slice_joint_test, fired when K >= 3 and the shortest slice has
fewer than 150 periods, the regime where the joint Wald over-rejects
(8–9% analytic and 10–15% bootstrap at a nominal 5% for K=5 with
50–90-period slices; see the
function's Warns: block for the measured grid). It is delivered on the
row, not only on stderr:
| Column | Meaning |
|---|---|
warning_codes |
every WarningCode the test raised (list[str], empty when clean) |
unexpected_warning_codes |
the subset not declared via expected_warnings — the alert view |
short_slice_periods |
the calibration threshold (150) the code is gated on |
k_slices and n_periods_min already carry the values it was read
against, so a stacked frame from a batch of regime tests answers which
cells tripped it, whether the caller had declared it, and why. Declaring
the code — slice_period_joint_test(..., expected_warnings=("short_slice_joint_test",))
— keeps it in warning_codes, drops it from unexpected_warning_codes
and stops the per-call UserWarning echo, so a sweep over candidates and
rebalance frequencies prints nothing for an accepted limitation while the
audit record survives. Undeclared codes keep the echo. Unknown codes are
rejected. by_slice takes the same argument and forwards it to every
per-slice evaluate, applying it to its own slice_boundary_truncation
record as well. slice_period_pairwise_test and the cross-sectional slice
tests accept the same argument on the same contract — a typo is refused
there too — but raise no advisory today, so they carry no such columns and
a declaration has nothing to mark.
Inference path (cross-sectional slice tests)¶
slice_pairwise_test and slice_joint_test run one analytic path and
take no estimator or multiple-testing argument:
| Function | Inference | Multiplicity | stat column carries |
|---|---|---|---|
slice_pairwise_test |
Joint Newey-West HAC over the (T, K) per-date metric panel, slice-clustered; per-pair Wald via a single-row restriction on the joint variance | Holm step-down on p_raw → p_adj (multiplicity="holm") |
Wald statistic; mean_diff carries the signed effect |
slice_joint_test |
Same joint HAC; omnibus Wald over all K−1 contrasts | — (one test) | Omnibus Wald statistic |
Holm is applied internally because the pairwise family is generated by
the function itself; the user-assembled families that bhy_adjust
serves are a different object. A bootstrap path exists only where slices
do not share dates: slice_period_pairwise_test(method="bootstrap") and
slice_period_joint_test(method="bootstrap") resample each period
slice independently (see Date supports: aligned vs disjoint).
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(...) |