Skip to content

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 date, asset_id, factor_col and return_col.

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 2 * k names to form disjoint legs — dates with fewer are dropped.

5
factor_col str

Ranking column (default "factor").

'factor'
return_col str

Realised-return column (default "forward_return").

'forward_return'
tie_policy TiePolicy

Tie-break policy for the leg ranking — the same knob and the same values as quantile_spread / quantile_spread_vw / monotonicity. Either "ordinal" or "average"; anything else raises UserInputError. "ordinal" (default) breaks ties by row order, which keeps both legs exactly k names wide but fills them arbitrarily among tied values; "average" gives tied names a shared rank so a discrete signal cannot be split by sort artefacts. Note what that means for a fixed-k leg: a tied block wider than k puts no name inside the cutoff, so the leg is empty and the date drops out (the drop-rate advisory reports it). That is the honest answer — five names cannot be picked out of a ten-way tie without an arbitrary rule — but it costs sample, so on a heavily tied factor prefer quantile_spread with tie_policy="average", whose legs are defined by rank fraction rather than count. The realised per-period tie ratio is reported in metadata["tie_ratio"] and a median above the shared threshold raises a UserWarning. Only _all_dates_degenerate used to guard this, and it fires only when the factor is constant everywhere — a 3-level signal ranked ordinally into fixed-k legs reported an arbitrary split as a spread with no tie diagnostic at all.

'ordinal'
inference NonOverlapping | NeweyWest | StationaryBootstrap

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

NON_OVERLAPPING
alternative PValueAlternative

Requested tail for the headline mean-spread test. Keep "two-sided" for discovery; pre-specify a one-sided direction.

'two-sided'

Returns:

Type Description
MetricResult

MetricResult with value = mean spread, stat = t on the

MetricResult

spread series, p-value from the cross-section-aware significance

MetricResult

path. metadata["cross_sectional_dispersion"] carries the

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:

\[\text{spread}_t = \frac1k \sum_{i \in \mathrm{top}_k} r_{i,t} - \frac1k \sum_{i \in \mathrm{bot}_k} r_{i,t}.\]

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_spread is the fixed-count long-short spread: per non-overlapping date it longs the k highest-factor names and shorts the k lowest, then tests the spread across time. Quintile bucketing (quantile_spread, n_groups=5) degrades when n_assets < 30 — each bucket holds only a few names and the breakpoints are unstable. Fixing the count k keeps each leg's composition stable regardless of n_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 inference member defines, run on the strided or full spread series as that member declares; with n_assets < MIN_ASSETS_WARN the metric attaches few_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 than 2·k names are dropped; if none qualify the metric short-circuits with max_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