Skip to content

factrix.metrics.quantile

Quantile analysis for cross-sectional panels.

All spread series are time-indexed (date, value) and can be fed into any series/ tool.

Notes

Pipeline. Per-period long-short spread on quantile groups (cross-section step), then non-overlapping t on the spread series.

Input. DataFrame with date, asset_id, factor, forward_return.

Output. Spread series, long/short alpha decomposition.

factrix.metrics.quantile.compute_spread_series

compute_spread_series(data: DataFrame, overlap_periods: int = DEFAULT_FORWARD_PERIODS, n_groups: int = DEFAULT_N_GROUPS, factor_cols: Sequence[str] = ('factor',), return_col: str = 'forward_return', tie_policy: TiePolicy = 'ordinal', expected_warnings: tuple[str, ...] = ()) -> dict[str, DataFrame]

Per-period long-short spread series (non-overlapping).

Top bucket = highest factor rank; bottom bucket = lowest. Labels use top_return / bottom_return rather than q1_return / q5_return because the bucket width depends on n_groups — at n_groups=10 the bottom is Q10, not Q5.

Parameters:

Name Type Description Default
data DataFrame

Panel with date, asset_id, factor, forward_return.

required
overlap_periods int

Number of periods forward.

DEFAULT_FORWARD_PERIODS
n_groups int

Number of quantile groups.

DEFAULT_N_GROUPS
factor_cols Sequence[str]

Factor column names to score. All factors run in a single polars query (one with_columns + one group_by("date").agg(...) + one collect) regardless of n_assets. The n_assets == 1 case is just the general path specialised — no fast/slow path divergence.

('factor',)
return_col str

Forward-return column shared across factors.

'forward_return'
tie_policy TiePolicy

See _assign_quantile_groups. "ordinal" (default) keeps balanced bucket sizes; "average" keeps tied assets in the same bucket — prefer for low-cardinality factors.

'ordinal'

Returns:

Type Description
dict[str, DataFrame]

DataFrame with date, spread, top_return, bottom_return, universe_return.

Notes

Per non-overlapping date t::

top_return[t]    = mean_{i in Q_top} return[i, t]
bottom_return[t] = mean_{i in Q_bot} return[i, t]
spread[t]        = top_return[t] - bottom_return[t]

factrix uses non-overlap sub-sampling (stride overlap_periods) before bucketing, not overlapping panel re-balancing — keeps the spread series free of MA(h-1) autocorrelation so downstream non-overlap t-tests are valid without heteroskedasticity-and-autocorrelation-consistent (HAC).

Non-finite handling. A non-finite return_col value is treated as missing (polars mean propagates NaN, so one bad print would otherwise NaN out the bucket mean and the spread); a non-finite factor lands in no bucket and is excluded from _n_assets / _n_unique and universe_return, so both the diagnostics and the benchmark the long / short legs are measured against describe the cross-section that was actually ranked.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.quantile import compute_spread_series
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
...     forward_periods=5,
... )
>>> spreads = compute_spread_series(panel, overlap_periods=5, n_groups=5)
>>> spread_df = spreads["factor"]
>>> set(spread_df.columns) >= {"date", "spread", "top_return", "bottom_return"}
True

factrix.metrics.quantile.compute_group_returns

compute_group_returns(data: DataFrame, overlap_periods: int = DEFAULT_FORWARD_PERIODS, n_groups: int = DEFAULT_N_GROUPS, factor_col: str = 'factor', return_col: str = 'forward_return', tie_policy: TiePolicy = 'ordinal') -> DataFrame

Mean forward return per quantile bucket (for monotonicity charts).

Formula
  1. Sample dates every overlap_periods rows (non-overlapping).
  2. Per sampled date, assign each asset to a quantile group 0..n_groups-1 by factor (see _assign_quantile_groups for tie_policy semantics).
  3. Compute each (date, group) mean return.
  4. For each group g, average those per-date means with equal date weights.

Returns:

Type Description
DataFrame

DataFrame with group, mean_return sorted ascending by group.

DataFrame

Group 0 = lowest factor rank, n_groups-1 = highest.

Notes

mean_return[g] = mean_t(mean_i(return_col[i, t] | group=g)). Dates are equally weighted after each cross-sectional bucket mean is formed. This is the same cross-section-then-time aggregation order as the spread metrics and prevents dates with more usable names from dominating the curve.

Unbucketed names are excluded. A name whose factor is null or NaN on a date gets no quantile group, and those rows are dropped before aggregation — the returned frame carries exactly the groups 0..n_groups-1 that were populated, never a group=None row. Non-finite return_col values are likewise treated as missing (polars mean does not skip float NaN, so a single NaN return would otherwise poison the whole bucket mean).

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.quantile import compute_group_returns
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
...     forward_periods=5,
... )
>>> groups = compute_group_returns(panel, overlap_periods=5, n_groups=5)
>>> set(groups.columns) >= {"group", "mean_return"}
True

factrix.metrics.quantile.quantile_spread

quantile_spread(data: DataFrame, overlap_periods: int = DEFAULT_FORWARD_PERIODS, n_groups: int = DEFAULT_N_GROUPS, factor_cols: Sequence[str] = ('factor',), tie_policy: TiePolicy = 'ordinal', inference: NonOverlapping | NeweyWest | StationaryBootstrap = NON_OVERLAPPING, *, alternative: PValueAlternative = 'two-sided', expected_warnings: tuple[str, ...] = (), _precomputed_series: dict[str, DataFrame] | None = None) -> dict[str, MetricResult]

long-short spread (per-period mean).

Parameters:

Name Type Description Default
inference NonOverlapping | NeweyWest | StationaryBootstrap

Headline significance method on the per-period spread. fx.inference.NON_OVERLAPPING (default) runs the OLS t-test on the non-overlap stride subsample; fx.inference.NEWEY_WEST keeps every date and absorbs the MA(h-1) overlap in a HAC SE. fx.inference.STATIONARY_BOOTSTRAP keeps every date and reports an empirical block-bootstrap p-value. A thin cross-section only attaches FEW_ASSETS; it never changes the requested member.

NON_OVERLAPPING
alternative PValueAlternative

Requested tail for the headline mean-spread test. Keep "two-sided" for discovery; use "greater" or "less" only when that direction was fixed before inspecting the sample.

'two-sided'
_precomputed_series dict[str, DataFrame] | None

If provided, skip recomputing compute_spread_series.

None
tie_policy TiePolicy

Bucketing tie-break policy, see _assign_quantile_groups. Either "ordinal" or "average"; anything else raises UserInputError. When _precomputed_series is passed, this only affects the tie_ratio diagnostic — the series itself was already built.

'ordinal'

Returns:

Type Description
dict[str, MetricResult]

MetricResult with per-period mean spread, t-stat from the chosen

dict[str, MetricResult]

inference.

Notes

t = mean(spread) / (std(spread) / sqrt(n)) on the non-overlap spread series. H0: E[spread] = 0. The Newey-West (NW) heteroskedasticity-and-autocorrelation-consistent (HAC) route is the sibling that keeps the full overlapping series instead of striding — select it via inference=fx.inference.NEWEY_WEST. A thin cross-section (median_cross_section < MIN_ASSETS_WARN) attaches FEW_ASSETS on either route and changes nothing else.

Long/short alpha decomposition stays a descriptive OLS t-test on top_return - universe_return and universe_return - bottom_return regardless of inference — it attributes the spread to long-side vs short-side excess, it is not the headline H0. Leg p-values are always two-sided and metadata records leg_alternative="two-sided". A directional hypothesis about the sum does not imply that both component means have that direction. Each leg is tested on its own finite sample (n_periods_long_leg / n_periods_short_leg), which can be shorter than the spread sample when only one bucket was empty on a date.

Which sample each count describes. value, stat, p_value and n_obs always come from the same sample — the one the selected inference ran on:

  • n_obs == metadata["n_periods"]: the periods the headline test used. Under NON_OVERLAPPING that is the strided series; under NEWEY_WEST and STATIONARY_BOOTSTRAP it is the full overlapping series, which is ~overlap_periods times longer. Reporting the strided count beside a full-series p-value would misstate the sample the selected member consumed.
  • metadata["n_periods_strided"]: the non-overlap sample, always present so the two are comparable.
  • metadata["n_periods_full"]: the overlapping sample (NEWEY_WEST / STATIONARY_BOOTSTRAP paths only).
  • metadata["dropped_periods"] / n_periods_in / n_periods_out: the non-finite-drop bookkeeping on the strided series.

Non-finite observations. Every series column consumed here is restricted to finite values. One NaN in the spread would make the t-path report degenerate_variance (mislabelling missing data as a dispersion-free sample) or raise in the bootstrap path.

Thin cross-sections are judged by the median per-period count of finite factor values (metadata["median_cross_section"]), not by the number of distinct asset_id values in the panel. A single date's bucket mean can rest on few names even when the lifetime universe is large; FEW_ASSETS reports that fragility without changing the inference member.

References

Hansen-Hodrick 1980: overlapping-return autocorrelation, motivating the non-overlap stride.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.quantile import quantile_spread
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
...     forward_periods=5,
... )
>>> result = quantile_spread(panel, overlap_periods=5, n_groups=5)
>>> result["factor"].name == ""
True

factrix.metrics.quantile.quantile_spread_vw

quantile_spread_vw(data: DataFrame, overlap_periods: int = DEFAULT_FORWARD_PERIODS, n_groups: int = DEFAULT_N_GROUPS, factor_col: str = 'factor', return_col: str = 'forward_return', weight_col: str = 'market_cap', tie_policy: TiePolicy = 'ordinal', inference: NonOverlapping | NeweyWest | StationaryBootstrap = NON_OVERLAPPING, lag_weights: bool = True, *, alternative: PValueAlternative = 'two-sided', expected_warnings: tuple[str, ...] = ()) -> MetricResult

Value-weighted long-short spread — alpha concentration diagnostic.

Formula (per non-overlapping date \(t\)):

\[ \begin{aligned} \text{vw}_b[t] &= \frac{\sum_{i \in b} w_{i,t-1} \cdot \text{return}_{i, t \to t+h}}{\sum_{i \in b} w_{i,t-1}}, \quad b \in \{\text{bottom}, \text{top}\} \\ \text{spread}[t] &= \text{vw}_{\text{top}}[t] - \text{vw}_{\text{bottom}}[t] \\ \text{value} &= \mathrm{mean}_t\, \text{spread}[t], \quad t = \sqrt{n} \cdot \text{value} / \mathrm{std}(\text{spread}), \quad \text{DDOF}=1 \end{aligned} \]

Weights are lagged by one period on the sampled panel grid by default (lag_weights=True): a portfolio rebalanced at date t uses the market-cap observed at the previous rebalance, not at t. An asset absent at that exact prior grid period has no usable lag at t; factrix does not reach across the gap to an older row. Pairing contemporaneous market_cap[t] with forward_return[t→t+h] is a classic look-ahead trap — market cap measured on date t embeds news that the t→t+h return has not yet realized.

Pass lag_weights=False only when the caller has already supplied a lagged weight column (e.g., prior-month-end cap) and wants the function to treat it as observed at t.

Compare with equal-weighted quantile_spread: if VW spread much smaller (e.g., < 1/3 of EW), the alpha is driven by small-cap assets and may not survive capacity / liquidity constraints.

Parameters:

Name Type Description Default
data DataFrame

Panel with date, asset_id, factor, forward_return, market_cap (or whatever weight_col names).

required
weight_col str

Column for value weighting (default market_cap).

'market_cap'
inference NonOverlapping | NeweyWest | StationaryBootstrap

Headline significance method on the per-period spread — the same knob, allowlist and default as quantile_spread, so the equal-weighted / value-weighted pair is tested the same way on the same date set. fx.inference.NON_OVERLAPPING (default) runs the OLS t-test on the non-overlap stride subsample; fx.inference.NEWEY_WEST keeps every date and absorbs the MA(h-1) overlap in a HAC SE.

NON_OVERLAPPING
alternative PValueAlternative

Requested tail for the headline mean-spread test; fix a one-sided direction before inspecting the evaluated sample.

'two-sided'
lag_weights bool

When True (default), shift weight_col by 1 period on the non-overlap-sampled frame's distinct-date grid, within asset, before weighting. A row missing its exact prior-grid observation drops. When False, use weights as supplied.

True

Returns:

Type Description
MetricResult

MetricResult with per-period mean VW spread, t-stat, and p-value.

MetricResult

Short-circuits if weight_col is missing or post-sampling n <

MetricResult

MIN_PORTFOLIO_PERIODS_HARD.

Notes

Per non-overlapping date t, per bucket b in {bot, top}::

vw_b[t] = sum_{i in b} w[i, t-1] * return[i, t -> t+h]
          / sum_{i in b} w[i, t-1]
spread[t] = vw_top[t] - vw_bot[t]
value = mean_t spread[t];  t = sqrt(n) * value / std(spread)

factrix lags weights by one sampled grid period within asset by default (not one raw row) so the lag aligns with the rebalance stride. A ragged asset does not borrow a stale weight from two or more grid periods earlier: that weight is look-ahead-free but carries a longer, asset-dependent lag, and the grid rule keeps the lag equal to the rebalance stride for every asset. On a ragged panel this costs rows — an asset absent at period t also loses t+1. Successful results expose the surviving breadth as median_cross_section; an insufficient- assets short-circuit exposes max_assets_per_date. drop_rate instead counts period-level spreads lost after bucketing, not rows removed by the lag. Pass lag_weights=False when the supplied weights are already lagged. Under inference=NEWEY_WEST there is no stride — every date is its own rebalance — so the lag is one bar there, and the first date drops out for want of a lagged weight; contemporaneous weight × forward_return would embed look-ahead bias from market-cap moves that the forward return has not yet realized.

Missing names leave the weighted mean entirely. The sums in vw_b[t] run over the names of bucket b that have both a finite weight and a finite return. A name missing either is dropped from the numerator and the denominator — keeping its weight in the denominator only (the naive sum(w·r)/sum(w)) would pull the bucket return toward zero in proportion to the missing share, so a half-reported bucket would report half its true return.

The alternative convention re-normalises nothing and treats a missing return as 0 — defensible for a portfolio that really did hold cash, wrong for a panel where the return is merely unobserved. factrix assumes the latter: the bucket return is the value-weighted mean of what was observed.

When a leg has no weighted name at all, its bucket return is null and the date's spread is null and excluded from n_obs — not 0.0. sum() of an all-null polars column is 0, so the unguarded ratio would manufacture a 0.0 spread on a fully missing date and count it as a real observation, biasing both the mean and the t-stat toward zero.

Thin-cross-section diagnostics. This path used to build its buckets inline and call the t-test directly, so it emitted none of the diagnostics its equal-weighted sibling does: on an 8-name panel cut into 5 buckets — 1.6 names per leg — quantile_spread reported ('few_assets', 'thin_quantile_groups') and a UserWarning, while quantile_spread_vw reported clean. That is exactly backwards for the metric whose purpose is a capacity / robustness cross-check. It now routes through the same headline chokepoint (FEW_ASSETS on the median per-period finite-factor count) and raises the same thin-bucket advisory and THIN_QUANTILE_GROUPS code off the same threshold.

References

Hou-Xue-Zhang (2020): ~65% of anomalies fail \(|t| \geq 1.96\) once microcaps are mitigated via NYSE breakpoints and value weighting jointly.

Examples:

>>> import polars as pl
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.quantile import quantile_spread_vw
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
...     forward_periods=5,
... ).with_columns(pl.lit(1e6).alias("market_cap"))
>>> result = quantile_spread_vw(panel, overlap_periods=5, n_groups=5)
>>> result.name == ""
True

Use cases

  • Compute per-date long-short spread series


    Build the per-date spread = top_return - bottom_return series (with top_return, bottom_return, universe_return) on a non-overlap-sampled panel. Pre-step for quantile_spread; also feeds spanning_alpha and any series/ tool.

  • Mean-spread significance, equal-weighted


    Test \(H_0: \mathbb{E}[\text{spread}] = 0\) on the non-overlap spread series, with the long-vs-short alpha decomposition (top - universe, universe - bottom) attached so callers can attribute the spread to long-side vs short-side excess. The headline accepts a directional alternative; the two leg p-values remain two-sided and report metadata["leg_alternative"] = "two-sided" because a directional hypothesis about their sum does not constrain each leg.

  • Value-weighted spread for capacity diagnostics


    quantile_spread_vw weights each bucket by lagged market_cap (or any caller-supplied weight_col=). When the VW spread is much smaller than the EW spread, the alpha is concentrated in small names and may not survive capacity / liquidity constraints — Hou-Xue-Zhang (2020) found ~65% of factors disappear under VW. It takes the same inference= knob and the same thin-cross-section diagnostics as its equal-weighted sibling, so the comparison is like-for-like rather than one leg being quietly less guarded.

    The lag is one period on the sampled panel's distinct-date grid, within each asset. If an asset is absent at the exact preceding grid period, its current row has no lag and drops; an older stale weight is not substituted.

    A stale weight carried across the hole would be look-ahead-free too, but it is a longer, asset-dependent lag; one grid period keeps the weight lag equal to the rebalance stride and matches rank_turnover. The cost is rows: an asset absent at period t also loses t+1, so a ragged panel whose thin periods alternate with full ones can drop most of its sample and short-circuit as metric_unavailable. A successful result reports the post-lag breadth as median_cross_section; an insufficient-assets short-circuit reports max_assets_per_date. drop_rate is narrower: it counts period-level spread values lost after bucketing, not asset rows removed by the lag. Pass lag_weights=False when the weights are already lagged.

  • Per-bucket mean returns for monotonicity charts


    compute_group_returns first computes each date's quantile-bucket mean, then equal-weights those date means — the chart input that shows whether returns rise monotonically across deciles, before any formal test.

Choosing a function

Goal Function
Per-date long-short spread table for downstream inspection / slicing compute_spread_series
Per-bucket equal-date mean return (decile-curve chart input) compute_group_returns
Mean-spread significance, equal-weighted, non-overlap \(t\) (default) quantile_spread
Mean-spread significance, value-weighted (capacity / size-concentration check) quantile_spread_vw

Worked example — per-date spread then EW vs VW significance

compute_spread_series → quantile_spread → quantile_spread_vw on a synthetic cross-sectional panel

import factrix as fx
import polars as pl
from factrix.metrics.quantile import (
    compute_spread_series, quantile_spread, quantile_spread_vw,
)
from factrix.preprocess import compute_forward_return

raw   = fx.datasets.make_cs_panel(
    n_assets=200, n_dates=500, ic_target=0.08, rng=2024,
)
# The generator emits the canonical four columns only; value-weighting
# needs a `market_cap`, so attach a deterministic per-asset size ladder.
raw   = raw.with_columns(
    (1e9 * (1 + pl.col("asset_id").str.slice(1).cast(pl.Int64)))
    .alias("market_cap")
)
panel = compute_forward_return(raw, forward_periods=5)

# compute_spread_series returns dict[str, DataFrame] keyed by factor column
spread_series = compute_spread_series(panel, overlap_periods=5, n_groups=5)
spread_df = spread_series["factor"]
print(spread_df.head())
# ┌────────────┬───────────────┬─────────────────┬──────────────────┬──────────┐
# │ date       ┆ top_return    ┆ bottom_return   ┆ universe_return  ┆ spread   │
# ├────────────┼───────────────┼─────────────────┼──────────────────┼──────────┤
# │ 2020-01-02 ┆ -0.000843     ┆ -0.000621       ┆ -0.000129        ┆ -0.000222│
# │  ...       ┆  ...          ┆  ...            ┆  ...             ┆  ...     │
# └────────────┴───────────────┴─────────────────┴──────────────────┴──────────┘

# quantile_spread returns dict[str, MetricResult], keyed the same way
ew = quantile_spread(panel, overlap_periods=5, n_groups=5,
                     _precomputed_series=spread_series)["factor"]
print(ew.value, ew.stat, ew.metadata["long_alpha"], ew.metadata["short_alpha"])
# 0.0019  9.68  0.0010  0.0009   (approximate)

vw = quantile_spread_vw(panel, overlap_periods=5, n_groups=5,
                        weight_col="market_cap")
print(vw.value, vw.stat)
# 0.0020  8.85   (approximate — a VW spread well below EW would signal
#                 that the EW result rests on small-cap names)

# Same metric, a different inference member. The stationary bootstrap
# makes no asymptotic-normality assumption, so it is the second read
# when the spread distribution is in doubt; it keeps every period
# (no stride) and reports an empirical p from B resamples.
from factrix.inference import StationaryBootstrap

boot = quantile_spread(panel, overlap_periods=5, n_groups=5,
                       inference=StationaryBootstrap(rng=0))["factor"]
print(boot.value, boot.p_value, boot.n_obs)
# 0.001903  0.001  494
print(boot.metadata["n_resamples"], boot.metadata["seed"],
      round(boot.metadata["p_value_mc_se"], 5))
# 999  0  0.001   (the Monte-Carlo error of the empirical p itself)

See also