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-date spread series across time. Small cross-sections switch the headline test to a block-bootstrap CI.

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

Output. Mean spread, with the per-date 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, forward_periods: int = 5, k: int = 5, factor_col: str = 'factor', return_col: str = 'forward_return', rng_seed: int = 0, inference: NonOverlapping | NeweyWest = NON_OVERLAPPING) -> 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
forward_periods int

Sampling stride for non-overlapping dates; match the forward-return horizon.

5
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'
rng_seed int

Seed for the small-N block-bootstrap branch (reproducible by default).

0
inference NonOverlapping | NeweyWest

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. The small-cross-section block bootstrap still takes precedence over either when it fires (HAC corrects autocorrelation, not heavy tails); the override is flagged in metadata.

NON_OVERLAPPING

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-date 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 follows the shared small-cross-section policy: with n_assets < MIN_ASSETS_WARN the per-date spread is heavy-tailed (few names per leg), so the t-test is replaced by a block-bootstrap CI; otherwise the non-overlapping t applies. metadata["method"] records which ran. 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.

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, seed=0),
...     forward_periods=5,
... )
>>> result = k_spread(panel, forward_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-date 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 follows the shared small-cross-section policy (_spread_significance): with n_assets < MIN_ASSETS_WARN the per-date spread is heavy-tailed (few names per leg), so the non-overlapping t is replaced by a block-bootstrap CI; metadata["method"] records which 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, seed=2024)
panel = compute_forward_return(raw, forward_periods=5)

out = k_spread(panel, forward_periods=5, k=3)
print(out.value, out.metadata["method"], out.metadata["cross_sectional_dispersion"])
# 0.0018  block-bootstrap CI  0.041   (approximate)

See also