factrix.metrics.spanning ¶
Spanning regression — single-factor test and multi-factor selection.
spanning_alpha: does a single factor have alpha after controlling for
base factors? Standard factor research tool (Barillas-Shanken (2017)).
greedy_forward_selection: given a pool of PASS factors, iteratively
select those with incremental alpha (Stage 2).
Both use factor return time series (quantile spread series), not information coefficient (IC). This is a single-factor artifact, not a jointly-estimated Barra/APT factor return.
Notes
Pipeline. Regression of factor return time-series on base-factor returns (time-series step); Newey-West (NW) heteroskedasticity-and-autocorrelation-consistent (HAC) t on alpha. The greedy stepwise selection variant inflates t-stats and is not for inference.
References
- Barillas & Shanken (2017), "Which Alpha?"
- Feng, Giglio & Xiu (2020), "Taming the Factor Zoo: A Test of New Factors."
factrix.metrics.spanning.SpanningResult
dataclass
¶
Result of a single spanning regression for one candidate factor.
factrix.metrics.spanning.spanning_alpha ¶
spanning_alpha(factor_spread: DataFrame, base_spreads: dict[str, DataFrame] | None = None, overlap_periods: int = 1, *, expected_warnings: tuple[str, ...] = ()) -> MetricResult
Test whether a factor has alpha after controlling for base factors.
Runs: factor_spread = alpha + beta_1 * base_1 + ... + epsilon If alpha is significantly != 0, the factor provides incremental value.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
factor_spread
|
DataFrame
|
DataFrame with |
required |
base_spreads
|
dict[str, DataFrame] | None
|
Mapping of base factor name → DataFrame with |
None
|
overlap_periods
|
int
|
Overlap horizon of the spread series. Spreads built
from |
1
|
expected_warnings
|
tuple[str, ...]
|
Warning codes the caller declares; a declared code
is still recorded, the |
()
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
MetricResult with value=alpha, t_stat, significance. When |
MetricResult
|
|
MetricResult
|
regressing the candidate spread on nothing returns its own mean, not |
MetricResult
|
an incremental alpha — so the metric short-circuits to the standard |
MetricResult
|
not-computable result ( |
MetricResult
|
"no_base_factors" |
MetricResult
|
injecting base spreads, so this metric is only computable when called |
MetricResult
|
directly; through |
MetricResult
|
attr: |
MetricResult
|
returning the raw spread mean. |
MetricResult
|
Two regimes the HAC reference does not fix are flagged on the |
MetricResult
|
candidate spread — the regressand, whose long-run variance the |
MetricResult
|
alpha standard error estimates: |
MetricResult
|
attr: |
MetricResult
|
autocorrelated after the |
MetricResult
|
attr: |
MetricResult
|
strided sample falls below 30 periods. See |
MetricResult
|
|
MetricResult
|
rates. Separately, |
MetricResult
|
attr: |
MetricResult
|
resolved bandwidth estimated from too few lag products on samples of |
MetricResult
|
at least 30 periods. |
Notes
The Huberman-Kandel (1987) mean-variance spanning test in its single-candidate form — the single-asset case of the Gibbons-Ross-Shanken (1989) \(F\)-test, reported as a \(t\) with a HAC standard error rather than the homoskedastic GRS statistic. On the common-date intersection of the candidate and base spread series, run ordinary least squares (OLS)
and test \(H_0: \alpha = 0\) against a two-sided alternative. Because
this is one restriction, its Bartlett bandwidth, finite-sample
variance scale, and effective reference degrees of freedom come from
:func:factrix._stats.hac._resolve_scalar_wald_hac. This scalar HAR
contract is distinct from the narrow multi-restriction Wald rule; the
centralized formula and path map live in
reference/statistical-methods.
A rejection means the candidate's spread carries return the base set does not span. \(\hat\alpha\) is on the same per-period scale as the input spreads.
The alpha t-stat uses a Newey-West HAC standard error (Bartlett
kernel, automatic bandwidth) — the convention for spanning alphas
in the factor-model literature. An earlier version used the
homoskedastic OLS SE on the stated assumption that inputs are
non-overlap spreads; nothing in a (date, spread) frame can
verify that, so the SE is now robust to whatever the caller
passes. On a genuinely non-overlapping iid series the HAC t costs
1–2 points of size (6.5% at n = 120 against a nominal 5%); on
one with AR(0.6) residuals the OLS SE it replaces rejects at 33%
and does not improve with sample size. See
:func:factrix._ols.ols_alpha for the measured table.
References
- Barillas & Shanken (2017). "Which Alpha?" Review of Financial Studies, 30(4), 1316–1338. Spanning-test framework for nested factor models.
Examples:
Build a spread series via
:func:~factrix.metrics.quantile.compute_spread_series, then
test its alpha standalone:
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.quantile import compute_spread_series
>>> from factrix.metrics.spanning import spanning_alpha
>>> import polars as pl
>>> panel = compute_forward_return(
... fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
... forward_periods=5,
... ).with_columns(base=pl.col("factor").rank().over("date"))
>>> spreads = compute_spread_series(panel, factor_cols=["factor", "base"])
>>> result = spanning_alpha(
... spreads["factor"], base_spreads={"base": spreads["base"]}
... )
>>> result.name == ""
True
factrix.metrics.spanning.greedy_forward_selection ¶
greedy_forward_selection(factor_spreads: dict[str, DataFrame], base_spreads: dict[str, DataFrame] | None = None, significance_threshold: float = 2.0, max_factors: int = 20, suppress_snooping_warning: bool = False, *, expected_warnings: tuple[str, ...] = ()) -> MetricResult
Greedy forward selection with backward elimination.
No static panel-shape thresholds are declared (sample_threshold=SampleThreshold()) because the descriptive result carries no single hypothesis test and does not short-circuit like value/p_value metrics.
WARNING — data snooping / selection bias:
Stepwise selection over a candidate pool of K factors inflates
the per-selected-factor t-stat by an order-statistic factor
(typical estimates 2-4× on K=10-100 pools). The t-stats on
metadata["selected_factors"] are NOT valid for hypothesis testing —
they are conditional on survival, not draws from the t-null.
Use this function as a model-construction helper, not as
an inference tool. For post-selection significance, re-evaluate
the surviving set on a held-out window, or use a White (2000)
Reality Check / Hansen (2005) SPA procedure on the pre-selection
stage. The returned t_stats_inference_invalid=True flag
encodes this contract.
Algorithm
- Start with base factor set (e.g., Size, Value, Momentum spreads).
- For each candidate PASS factor, compute spanning alpha.
- Select the candidate with largest |alpha| if t-stat >= threshold.
- After each addition, backward-check all selected factors: re-run spanning regression for each against all others. Remove any whose alpha becomes insignificant.
- Repeat until no candidate has significant alpha.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
factor_spreads
|
dict[str, DataFrame]
|
Mapping of factor name → DataFrame with |
required |
base_spreads
|
dict[str, DataFrame] | None
|
Mapping of base factor name → DataFrame with |
None
|
significance_threshold
|
float
|
Minimum |t-stat| for selection (default 2.0). |
2.0
|
max_factors
|
int
|
Maximum number of factors to select. |
20
|
suppress_snooping_warning
|
bool
|
Silence the one-shot |
False
|
expected_warnings
|
tuple[str, ...]
|
Warning codes the caller declares; a declared code
is still recorded, the |
()
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
A descriptive :class: |
MetricResult
|
the count of surviving (selected) factors and |
MetricResult
|
|
MetricResult
|
|
MetricResult
|
(each a list of :class: |
MetricResult
|
|
Notes
Iteratively run spanning_alpha(candidate, base ∪ selected);
add the candidate with the largest |alpha| whose |t| >=
threshold; after each add, re-test all selected factors against
the others and drop any that lose significance. Repeat until the
pool dries up or max_factors is hit.
factrix flags t_stats_inference_invalid = True because the
retained t-stats are conditional on selection — they are
order-statistic inflated and must not be read as draws from the
t-null. Use the result as a model-construction helper; verify
survivors on a held-out window.
References
- White (2000). "A Reality Check for Data Snooping." Econometrica, 68(5), 1097–1126. Bootstrap reality-check for data-snooping bias — the canonical correction this function does not apply (inflates t-stats by design).
- Harvey, Liu & Zhu (2016). "…and the Cross-Section of Expected Returns." Review of Financial Studies, 29(1), 5–68. Empirical case for raising t-thresholds; section on stepwise-selection bias.
Examples:
Greedy step-wise selection across two candidate spread series.
suppress_snooping_warning=True acknowledges the inflated-t
contract carried by the t_stats_inference_invalid metadata
flag:
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.quantile import compute_spread_series
>>> from factrix.metrics.spanning import greedy_forward_selection
>>> seeds = [0, 1]
>>> spreads = {
... f"cand_{s}": compute_spread_series(
... compute_forward_return(
... fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=s),
... forward_periods=5,
... ),
... overlap_periods=5,
... )["factor"]
... for s in seeds
... }
>>> result = greedy_forward_selection(
... spreads,
... suppress_snooping_warning=True,
... )
>>> result.metadata["t_stats_inference_invalid"]
True
Inputs are factor-return series, not raw panels
spanning is a post-PANEL consumer: both callables operate on
spread-series DataFrames (date, spread) produced by
compute_spread_series (or any equivalent factor-mimicking-portfolio
return series), not the raw (date, asset_id, factor, forward_return)
panel consumed by the other metrics in this cell.
spanning_alpha is not computable through evaluate
evaluate has no channel for supplying base_spreads, so
spanning_alpha run through it always sees an empty base. Under the
default strict=True this raises a UserInputError naming
no_base_factors; under strict=False it returns value=NaN with
metadata["reason"] == "no_base_factors" and a
WarningCode.METRIC_UNAVAILABLE warning. Call it directly with the
base spread series, as in the worked example below.
greedy_forward_selection is unaffected — an empty base is a valid
starting point for a selection run over the candidate pool.
Use cases¶
-
Single-factor incremental alpha
spanning_alpharegresses the candidate spread series on a set of base-factor spread series and tests \(H_0: \alpha = 0\) with a Newey-West HAC \(t\) — the Huberman-Kandel (1987) mean-variance spanning test in its single-candidate form (the single-asset case of the GRS 1989 \(F\)). Standard tool for "does this factor add anything beyond the existing model?" (Barillas-Shanken 2017). Passoverlap_periods=hwhen the spreads are built on \(h\)-period overlapping returns. The resulting rank-one alpha test follows the shared scalar HAR contract. -
Base factors are required
spanning_alphaneeds a base model to span against. Withbase_spreads=None(or empty) there is no incremental alpha to estimate — regressing the spread on nothing returns its own mean — so the metric short-circuits to the standard not-computable result (value=NaN,metadata["reason"] == "no_base_factors"). -
Greedy model construction over a pool
greedy_forward_selectioniteratively adds the candidate with largest \(|\alpha|\) above a \(|t|\) threshold, then backward-eliminates any selected factor that loses significance. Use as a model-construction helper only — the returned \(t\)-stats are selection-conditioned and not valid for inference.
Stepwise selection inflates t-stats
greedy_forward_selection searches the candidate pool and picks
by \(|\alpha|\); the per-selected-factor \(t\)-stat is order-statistic
inflated (typically 2-4x on pools of 10-100 candidates) and is
not a draw from the \(t\)-null (White 2000; Harvey-Liu-Zhu 2016).
The returned metadata["t_stats_inference_invalid"]=True flag encodes
this contract. For post-selection significance, re-evaluate survivors
on a held-out window, or use a Hansen (2005) SPA / White (2000)
Reality Check on the pre-selection stage.
Choosing a function¶
| Goal | Function |
|---|---|
| Single-factor spanning regression — incremental alpha vs base factors | spanning_alpha |
| Greedy multi-factor selection over a candidate pool (model-construction only) | greedy_forward_selection |
Worked example — single-factor spanning then greedy selection¶
compute_spread_series → spanning_alpha → greedy_forward_selection
import factrix as fx
from factrix.metrics.quantile import compute_spread_series
from factrix.metrics.spanning import (
spanning_alpha, greedy_forward_selection,
)
from factrix.preprocess import compute_forward_return
# Build a spread series for each factor on the same panel dates.
panels = {
name: compute_forward_return(
fx.datasets.make_cs_panel(
n_assets=200, n_dates=500, ic_target=ic, rng=s,
),
forward_periods=5,
)
for name, ic, s in [
("size", 0.05, 1),
("value", 0.06, 2),
("momentum", 0.08, 3),
("candidate",0.04, 4),
]
}
spreads = {
name: compute_spread_series(p, overlap_periods=5, n_groups=5)["factor"]
for name, p in panels.items()
}
# Single-factor: candidate vs the base set
out = spanning_alpha(
factor_spread = spreads["candidate"],
base_spreads = {k: spreads[k] for k in ("size", "value", "momentum")},
)
print(out.value, out.stat, out.p_value, out.metadata["r_squared"])
# 0.0011 1.83 0.068 0.21 (approximate)
# Multi-factor: greedy build a parsimonious set
pool = {k: spreads[k] for k in ("size", "value", "momentum", "candidate")}
sel = greedy_forward_selection(
factor_spreads = pool,
significance_threshold = 2.0,
max_factors = 4,
suppress_snooping_warning = True, # acknowledged: construction-only
)
for s in sel.metadata["selected_factors"]:
print(s.factor_name, s.alpha, s.t_stat)
# momentum 0.0028 4.10
# value 0.0019 2.51 (approximate; t_stats inflated, do not infer)
See also¶
-
compute_spread_series/quantile_spread
Produces the per-date spread series consumed here.
-
compute_factor_returns(preprocess)
Any factor-return / spread time series with
(date, spread)shape is a valid input; this is the upstream pipeline for the post-PANEL cell. -
slice_pairwise_test/slice_joint_test
Cross-slice inference on spanning alphas (Wald \(\chi^2\) + Holm / Romano-Wolf adjusted \(p\)).
-
Statistical methods
The rank-one alpha restriction uses the project's shared scalar HAR bandwidth, finite-sample variance scale, and effective degrees of freedom. The centralized formula also distinguishes this path from multi-restriction Wald tests.
-
Metric applicability reference
When this metric applies and the post-selection-inference contracts that govern
greedy_forward_selection. -
Individual × Continuous landing
Adjacent metrics in the same cell.