Skip to content

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

factrix.metrics.spanning.SpanningResult dataclass

SpanningResult(factor_name: str, alpha: float, t_stat: float, selected: bool)

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 date, spread for the candidate factor.

required
base_spreads dict[str, DataFrame] | None

Mapping of base factor name → DataFrame with date, spread. Required: with no base factors there is nothing to span against, so the metric short-circuits (see Returns).

None
overlap_periods int

Overlap horizon of the spread series. Spreads built from h-period overlapping forward returns carry MA(h-1) residual autocorrelation, so this value is passed to the shared scalar rank-one HAC resolver. Leave at 1 for genuinely non-overlapping spreads (the output of compute_spread_series, which strides to the rebalance schedule). evaluate injects the panel's stamped horizon; a standalone call declares it.

1
expected_warnings tuple[str, ...]

Warning codes the caller declares; a declared code is still recorded, the UserWarning echo is silenced.

()

Returns:

Type Description
MetricResult

MetricResult with value=alpha, t_stat, significance. When

MetricResult

base_spreads is missing or empty the alpha is not defined —

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 (value=NaN, ``metadata["reason"] =

MetricResult

"no_base_factors").factrix.evaluate`` has no channel for

MetricResult

injecting base spreads, so this metric is only computable when called

MetricResult

directly; through evaluate it reports itself unavailable via

MetricResult

attr:~factrix.WarningCode.METRIC_UNAVAILABLE rather than silently

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:~factrix.WarningCode.SERIAL_CORRELATION_DETECTED when it stays

MetricResult

autocorrelated after the overlap_periods stride, and

MetricResult

attr:~factrix.WarningCode.UNRELIABLE_SE_SHORT_PERIODS when that

MetricResult

strided sample falls below 30 periods. See

MetricResult

reference/inference-calibration for the measured size and firing

MetricResult

rates. Separately,

MetricResult

attr:~factrix.WarningCode.HAC_BANDWIDTH_ILL_CONDITIONED reports a

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)

\[ r_t = \alpha + \sum_{k=1}^{K} \beta_k\, f_{k,t} + \varepsilon_t, \qquad t = 1, \dots, T, \]

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
  1. Start with base factor set (e.g., Size, Value, Momentum spreads).
  2. For each candidate PASS factor, compute spanning alpha.
  3. Select the candidate with largest |alpha| if t-stat >= threshold.
  4. After each addition, backward-check all selected factors: re-run spanning regression for each against all others. Remove any whose alpha becomes insignificant.
  5. Repeat until no candidate has significant alpha.

Parameters:

Name Type Description Default
factor_spreads dict[str, DataFrame]

Mapping of factor name → DataFrame with date, spread.

required
base_spreads dict[str, DataFrame] | None

Mapping of base factor name → DataFrame with date, spread. If None, starts with an empty base.

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 UserWarning. Only set when the caller has explicitly acknowledged that the returned t-stats are for model-construction, not inference.

False
expected_warnings tuple[str, ...]

Warning codes the caller declares; a declared code is still recorded, the UserWarning echo is silenced.

()

Returns:

Type Description
MetricResult

A descriptive :class:~factrix._results.MetricResult: value is

MetricResult

the count of surviving (selected) factors and p_value is

MetricResult

None. The ordered factor lists ride in metadata —

MetricResult

selected_factors / eliminated_factors / all_candidates

MetricResult

(each a list of :class:SpanningResult) plus

MetricResult

t_stats_inference_invalid=True.

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_alpha regresses 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). Pass overlap_periods=h when 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_alpha needs a base model to span against. With base_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_selection iteratively 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.

    api/metrics/quantile →

  • 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.

    api/preprocess →

  • slice_pairwise_test / slice_joint_test


    Cross-slice inference on spanning alphas (Wald \(\chi^2\) + Holm / Romano-Wolf adjusted \(p\)).

    api/slice-test →

  • 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.

    reference/statistical-methods →

  • Metric applicability reference


    When this metric applies and the post-selection-inference contracts that govern greedy_forward_selection.

    reference/metric-applicability →

  • Individual × Continuous landing


    Adjacent metrics in the same cell.

    api/metrics/individual-continuous →