factrix.metrics.k_spread ¶
Fixed-K Top-K vs Bottom-K long-short spread for small cross-sections.
Notes
Pipeline. Per non-overlapping date, select the top k and
bottom k names by factor rank, take the mean-return difference
(cross-section step), then test the per-period spread series across
time. Small cross-sections switch the headline test to a
FEW_ASSETS advisory.
Input. DataFrame with date, asset_id, factor, forward_return.
Output. Mean spread, with the per-period cross-sectional return dispersion reported alongside.
The small-n_assets counterpart of
:func:~factrix.metrics.quantile.quantile_spread. Quantile bucketing
(n_groups=5 ⇒ quintiles) degrades when n_assets < 30: each bucket
holds only a handful of names, so the spread is dominated by
individual assets and the quintile breakpoints are unstable. Fixing
the count k per leg keeps each leg's composition stable
regardless of n_assets, and the metric reports the contemporaneous
cross-sectional dispersion so the spread can be read relative to the
typical spread of returns that period.
factrix.metrics.k_spread.k_spread ¶
k_spread(data: DataFrame, overlap_periods: int = DEFAULT_FORWARD_PERIODS, k: int = 5, factor_col: str = 'factor', return_col: str = 'forward_return', tie_policy: TiePolicy = 'ordinal', inference: NonOverlapping | NeweyWest | StationaryBootstrap = NON_OVERLAPPING, *, alternative: PValueAlternative = 'two-sided', expected_warnings: tuple[str, ...] = ()) -> MetricResult
Fixed-K Top-K vs Bottom-K long-short spread.
Per non-overlapping date, the long leg is the mean forward return of
the k highest-factor names and the short leg the mean of the
k lowest; the spread is their difference. The mean spread is
tested across time.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Panel with |
required |
overlap_periods
|
int
|
Sampling stride for non-overlapping dates; match the forward-return horizon. |
DEFAULT_FORWARD_PERIODS
|
k
|
int
|
Number of names per leg (fixed count, not a quantile
fraction). A date needs at least |
5
|
factor_col
|
str
|
Ranking column (default |
'factor'
|
return_col
|
str
|
Realised-return column (default |
'forward_return'
|
tie_policy
|
TiePolicy
|
Tie-break policy for the leg ranking — the same knob and
the same values as |
'ordinal'
|
inference
|
NonOverlapping | NeweyWest | StationaryBootstrap
|
Headline significance method. |
NON_OVERLAPPING
|
alternative
|
PValueAlternative
|
Requested tail for the headline mean-spread test. Keep
|
'two-sided'
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
MetricResult with value = mean spread, |
MetricResult
|
spread series, p-value from the cross-section-aware significance |
MetricResult
|
path. |
MetricResult
|
mean per-period cross-sectional standard deviation of returns. |
Notes
Per qualifying date t (universe size n_assets_t >= 2 * k), with
\(\mathrm{top}_k\) / \(\mathrm{bot}_k\) the names ranked \(1..k\) /
n_assets_t - k + 1 .. n_assets_t by factor:
value = mean_t spread_t. The headline test is the non-overlapping
t on the strided spread series, Newey-West HAC on the full series,
or the stationary-bootstrap empirical p on the full series, according
to inference. A thin cross-section attaches FEW_ASSETS and
changes nothing else. The contemporaneous
cross-sectional dispersion \(\mathrm{std}_i(r_{i,t})\) is averaged
over dates and reported so the spread can be judged against the
period's return spread.
What "small" counts. The advisory reads the median per-period
number of usable names (metadata["median_cross_section"]), not
the count of distinct asset_id values in the panel: the legs
are formed date by date, so a universe listing 12 names at a time
while rotating through 200 over the sample is thin. The
universe-wide count is still what the 2 * k feasibility
short-circuit uses — that one is about whether the legs can exist
at all.
Non-finite observations. Rows with a non-finite factor or return
are dropped before ranking: rank(descending=True) sorts
NaN as the largest value, so a NaN factor would otherwise take
rank 1 and head the long leg. Every series column collapsed to a
scalar is then restricted to finite values — one NaN in the spread
series would make _calc_t_stat return NaN —
withholding the test as degenerate_variance — or make the
bootstrap raise.
Which sample each count describes. value, stat,
p_value and n_obs all come from the sample the selected
inference ran on. n_obs == metadata["n_periods"] is the
strided count under NON_OVERLAPPING but the full overlapping
count under NEWEY_WEST and STATIONARY_BOOTSTRAP;
metadata["n_periods_strided"] always carries the non-overlap
count and metadata["n_periods_full"] the overlapping one on
either full-series path. The dropped_periods /
n_periods_in / n_periods_out keys describe non-finite drops on
the strided series.
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.k_spread import k_spread
>>> panel = compute_forward_return(
... fx.datasets.make_cs_panel(n_assets=20, n_dates=180, rng=0),
... forward_periods=5,
... )
>>> result = k_spread(panel, overlap_periods=5, k=3)
>>> result.name == ""
True
Use cases¶
-
Small-N sibling of
quantile_spread
k_spreadis the fixed-count long-short spread: per non-overlapping date it longs thekhighest-factor names and shorts theklowest, then tests the spread across time. Quintile bucketing (quantile_spread,n_groups=5) degrades whenn_assets < 30— each bucket holds only a few names and the breakpoints are unstable. Fixing the countkkeeps each leg's composition stable regardless ofn_assets. Both name the selection convention, not a leg:quantile_spread(fraction) ↔k_spread(count). -
Cross-sectional dispersion reported alongside
metadata["cross_sectional_dispersion"]carries the mean per-period cross-sectional standard deviation of returns, so the headline spread can be read relative to the typical spread of returns that period rather than in isolation. -
Cross-section-aware significance
The headline test is the one the
inferencemember defines, run on the strided or full spread series as that member declares; withn_assets < MIN_ASSETS_WARNthe metric attachesfew_assets— the spread is a noisier estimate with few names per leg — and changes nothing else.metadata["method"]records the inference member that ran. Dates with fewer than2·knames are dropped; if none qualify the metric short-circuits withmax_assets_per_date.
Choosing a function¶
| Goal | Function |
|---|---|
| Long-short spread by extreme quantiles (large universe) | quantile_spread |
| Long-short spread by fixed count per leg (small universe) | k_spread |
Worked example — fixed-K spread on a small universe¶
k_spread on n_assets=20
import factrix as fx
from factrix.metrics.k_spread import k_spread
from factrix.preprocess import compute_forward_return
raw = fx.datasets.make_cs_panel(n_assets=20, n_dates=180, rng=2024)
panel = compute_forward_return(raw, forward_periods=5)
out = k_spread(panel, overlap_periods=5, k=3)
print(out.value, out.metadata["method"], out.metadata["cross_sectional_dispersion"])
# 0.0018 non-overlapping t-test 0.041 (approximate)
# A different inference member on the same metric. The stationary
# bootstrap keeps every period instead of striding and reports an
# empirical p, with the resampling knobs it ran with in metadata.
from factrix.inference import StationaryBootstrap
boot = k_spread(panel, overlap_periods=5, k=3,
inference=StationaryBootstrap(rng=0))
print(boot.value, boot.p_value, boot.n_obs)
# 0.000443 0.248 174
print(boot.metadata["n_resamples"], boot.metadata["seed"])
# 999 0
See also¶
-
quantile_spread
The fraction-based sibling: long-short spread by extreme quantiles, the idiomatic choice for large cross-sections.
-
top_concentration
Long-leg diversification (HHI effective bets) on the top bucket.
-
Metric applicability reference
When this metric applies and the sample-size guards that gate it.