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 |
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 |
('factor',)
|
return_col
|
str
|
Forward-return column shared across factors. |
'forward_return'
|
tie_policy
|
TiePolicy
|
See |
'ordinal'
|
Returns:
| Type | Description |
|---|---|
dict[str, DataFrame]
|
DataFrame with |
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
- Sample dates every
overlap_periodsrows (non-overlapping). - Per sampled date, assign each asset to a quantile group
0..n_groups-1 by
factor(see_assign_quantile_groupsfor tie_policy semantics). - Compute each
(date, group)mean return. - For each group g, average those per-date means with equal date weights.
Returns:
| Type | Description |
|---|---|
DataFrame
|
DataFrame with |
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.
|
NON_OVERLAPPING
|
alternative
|
PValueAlternative
|
Requested tail for the headline mean-spread test. Keep
|
'two-sided'
|
_precomputed_series
|
dict[str, DataFrame] | None
|
If provided, skip recomputing |
None
|
tie_policy
|
TiePolicy
|
Bucketing tie-break policy, see |
'ordinal'
|
Returns:
| Type | Description |
|---|---|
dict[str, MetricResult]
|
MetricResult with per-period mean spread, t-stat from the chosen |
dict[str, MetricResult]
|
|
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. UnderNON_OVERLAPPINGthat is the strided series; underNEWEY_WESTandSTATIONARY_BOOTSTRAPit is the full overlapping series, which is ~overlap_periodstimes 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_BOOTSTRAPpaths 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\)):
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 |
required |
weight_col
|
str
|
Column for value weighting (default |
'market_cap'
|
inference
|
NonOverlapping | NeweyWest | StationaryBootstrap
|
Headline significance method on the per-period spread —
the same knob, allowlist and default as |
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 |
True
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
MetricResult with per-period mean VW spread, t-stat, and p-value. |
MetricResult
|
Short-circuits if |
MetricResult
|
|
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_returnseries (withtop_return,bottom_return,universe_return) on a non-overlap-sampled panel. Pre-step forquantile_spread; also feedsspanning_alphaand anyseries/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 directionalalternative; the two leg p-values remain two-sided and reportmetadata["leg_alternative"] = "two-sided"because a directional hypothesis about their sum does not constrain each leg. -
Value-weighted spread for capacity diagnostics
quantile_spread_vwweights each bucket by laggedmarket_cap(or any caller-suppliedweight_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 sameinference=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 asmetric_unavailable. A successful result reports the post-lag breadth asmedian_cross_section; an insufficient-assets short-circuit reportsmax_assets_per_date.drop_rateis narrower: it counts period-level spread values lost after bucketing, not asset rows removed by the lag. Passlag_weights=Falsewhen the weights are already lagged. -
Per-bucket mean returns for monotonicity charts
compute_group_returnsfirst 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¶
-
monotonicity
Decile-curve direction-of-monotonicity test on the same buckets.
-
spanning_alpha
Does this spread series carry alpha after controlling for base factor spreads? Consumes
compute_spread_seriesoutput directly. -
notional_turnover/breakeven_cost/net_spread
Implementation feasibility on the same Q1/Qn long-short portfolio.
-
by_slice
Axis-agnostic slice dispatcher for per-slice spread summaries.
-
Statistical methods
Non-overlap \(t\) vs Newey-West (NW) heteroskedasticity-and-autocorrelation-consistent (HAC) on overlapping spreads, DDOF convention.
-
Individual × Continuous landing
Adjacent metrics in the same cell.