Skip to content

factrix.metrics.concentration

Top-bucket concentration analysis for cross-sectional panels.

Measures whether top-bucket (long-leg) alpha is concentrated in a few stocks or broadly distributed, using Herfindahl-Hirschman index (HHI) inverse.

Notes

Pipeline. Per-period HHI inverse on top-bucket weights (cross-section step) → per-period ratio series, then non-overlapping sample. Descriptive: no p-value is reported (see the Notes on top_concentration for the measurement that withdrew it).

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

factrix.metrics.concentration.top_concentration

top_concentration(data: DataFrame, overlap_periods: int = DEFAULT_FORWARD_PERIODS, q_top: float = 0.2, factor_col: str = 'factor', return_col: str = 'forward_return', weight_by: ConcentrationWeight = 'abs_factor', *, expected_warnings: tuple[str, ...] = ()) -> MetricResult

Top-bucket concentration via Herfindahl-Hirschman index (HHI) inverse.

Per date, selects top q_top stocks by factor rank, computes HHI of their weights, and returns 1/HHI as the effective number of independent bets.

Parameters:

Name Type Description Default
data DataFrame

Panel with date, asset_id, factor (and forward_return if weight_by="alpha_contribution").

required
q_top float

Fraction of top-ranked stocks to include (default 0.2 = top 20%). Must lie strictly inside (0, 1); 0 leaves no top bucket and 1 selects the whole cross-section, so neither defines a top-bucket concentration and both raise UserInputError.

0.2
weight_by ConcentrationWeight

HHI weight convention. Either "abs_factor" or "alpha_contribution"; anything else raises UserInputError. - "abs_factor" (default): weight by |factor|. Answers "how concentrated is the density itself in the top bucket". Conservative, density-level. Assumes a zero-centred factor: |f| reads as signal strength only when zero is the neutral point. The HHI of |f| is not location-invariant — the same top bucket reads eff_n 9.7 z-scored, 7.8 shifted by −1, 10.0 shifted by −10 — so on a raw factor whose values never change sign the number is an artefact of the level. Such a factor raises WarningCode.ONE_SIGNED_FACTOR; centre it first (cross-sectional z-score) or use alpha_contribution. - "alpha_contribution": weight by the magnitude of each name's realised contribution |sign(factor) · forward_return|. Captures risk-concentration: the top bucket's realised return is dominated by a few outliers. Note the absolute value — a single big winner and a single big loser both register as concentration, which is the right framing for risk but NOT for signed-alpha attribution. If you need the latter, apply HHI downstream on the signed sign(factor) · forward_return series yourself.

'abs_factor'

Returns:

Type Description
MetricResult

MetricResult with value = mean(1/HHI) across dates.

MetricResult

Higher = more diversified top bucket. Descriptive:

MetricResult

p_value, stat and alternative are None — see Notes.

Notes

The metric is descriptive; there is no p-value. Earlier versions reported a one-sided t against \(H_0: \mathbb{E}[r] \geq 0.5\). That test is withdrawn because the 0.5 reference ratio sits far inside the null for any realistic panel, not near it: on a Gaussian factor with abs_factor weights the null diversification ratio is ~0.91, roughly nine null standard deviations above the boundary. Measured on make_cs_panel(ic_target=0.0), 50 assets, q_top=0.2, overlap_periods=5, seed 20260830 + replication, it rejected 0 of 300 draws at a nominal 5% at 10 tested periods and 0 of 300 at 48 tested periods. A p-value that cannot reject at any sample size carries no information, so it is no longer emitted.

This is a boundary choice, not a degrees-of-freedom effect. Earlier releases attributed the conservatism to the df = 2 admitted by MIN_PORTFOLIO_PERIODS_HARD = 3; the measurement above shows the rejection rate is 0 at every sample size, so short samples were never the cause. Read value (mean effective number of names) and ratio_eff_to_total against a reference you choose for your universe.

Per non-overlap date \(t\) with top-bucket members \(Q^{\mathrm{top}}(t)\) (size \(n^{\mathrm{top}}\)), define weights \(w_i\) by weight_by and form the Herfindahl \(\mathrm{HHI}_t = \sum_i (w_i / \sum_j w_j)^2\). Effective independent bets \(n^{\mathrm{eff}}_t = 1 / \mathrm{HHI}_t\). The per-period diversification ratio \(r_t = n^{\mathrm{eff}}_t / n^{\mathrm{top}}\) is averaged into metadata["ratio_eff_to_total"] and reported as is.

Membership is a count, not a percent-rank threshold. With \(n_t\) finite factor values on date \(t\), the bucket is the \(k_t = \max(1, \lfloor n_t \cdot q_{\mathrm{top}} \rfloor)\) highest by descending ordinal rank. The alternative — an inclusive percent-rank cutoff \(\mathrm{rank}/n \geq 1 - q_{\mathrm{top}}\) — is off by one (n=10, q=0.2 selects 3 names; n=100 selects 21), because the boundary rank itself satisfies the inequality. factrix takes the strict count so the bucket size matches the requested fraction at every \(n\). Non-finite factor values are excluded from \(n_t\) and can never be selected; counting them (pl.len()) would shrink every bucket on a partially missing date and empty it outright once more than \(1 - q_{\mathrm{top}}\) of the names are missing.

tie_policy from Config does not apply to that cutoff — ordinal ranking is used unconditionally, because HHI measures concentration among the selected stocks rather than their bucketing, and an average-rank cutoff would return a variable number of names. tie_ratio is still recorded in metadata as a data-quality diagnostic (high tie_ratio → unstable membership across re-rankings).

Non-finite weights. Under weight_by="alpha_contribution" a selected name with no realised return has no weight. Such names are removed from the HHI and from n_top, so \(n^{\mathrm{eff}}_t / n^{\mathrm{top}}_t\) compares like with like; metadata["n_top_members_dropped"] records how many (date, asset) pairs went that way.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.concentration import top_concentration
>>> panel = compute_forward_return(
...     fx.datasets.make_cs_panel(n_assets=80, n_dates=180, rng=0),
...     forward_periods=5,
... )
>>> result = top_concentration(panel, overlap_periods=5, q_top=0.2)
>>> result.name == ""
True

Use cases

  • Signal concentration in the long leg


    weight_by="abs_factor" (default) — Herfindahl-Hirschman index (HHI) on \(|\text{factor}|\) inside the top-\(q\) bucket. Answers "how concentrated is the signal itself in the long leg". Conservative; depends only on factor values, not on realised returns.

  • Realised risk concentration


    weight_by="alpha_contribution" — HHI on \(|\text{sign}(\text{factor}) \cdot \text{forward\_return}|\). Captures whether the long-leg's realised return is dominated by a few outliers. Absolute value: a single big winner and a single big loser both register as concentration (the right framing for risk, not for signed-alpha attribution).

  • Diversification test, one-sided


    value = mean(1/HHI) (effective number of independent bets) and metadata["ratio_eff_to_total"] is \(r = n^{\text{eff}} / n^{\text{top}}\). Descriptive: p_value, stat and alternative are None — the former one-sided \(t\) against \(H_0: \mathbb{E}[r] \geq 0.5\) is withdrawn because 0.5 sits far inside the null (measured null ratio ~0.91, 0 of 300 rejections at a nominal 5%). Compare \(r\) against a reference you choose for your universe.

Worked example — top-bucket HHI on a synthetic panel

top_concentration with both weighting modes

import factrix as fx
from factrix.metrics.concentration import top_concentration
from factrix.preprocess import compute_forward_return

raw   = fx.datasets.make_cs_panel(
    n_assets=500, n_dates=500, ic_target=0.08, rng=2024,
)
panel = compute_forward_return(raw, forward_periods=5)

# FactorDensity-level concentration (no return dependence)
sig = top_concentration(panel, overlap_periods=5, q_top=0.2,
                        weight_by="abs_factor")
print(sig.value, sig.metadata["ratio_eff_to_total"])
# 90.1  0.90   (|factor| weights on a Gaussian factor are near-uniform)

# Realised-return concentration (risk framing)
risk = top_concentration(panel, overlap_periods=5, q_top=0.2,
                         weight_by="alpha_contribution")
print(risk.value, risk.metadata["ratio_eff_to_total"])
# 58.4  0.58   (realised contributions are the outlier-driven axis)

# Both are descriptive: there is no p-value to read.
assert sig.p_value is None and sig.stat is None

See also

  • quantile_spread / quantile_spread_vw


    The long-short spread on the same top / bottom buckets — pair the EW-vs-VW spread gap with concentration to disentangle small-cap vs few-name alpha.

    api/metrics/quantile →

  • notional_turnover / breakeven_cost


    Implementation feasibility on the same long-short construction.

    api/metrics/tradability →

  • Statistical methods


    One-sided \(t\) on the per-date diversification ratio, DDOF convention, sample-size guards.

    reference/statistical-methods →

  • Metric applicability reference


    When this metric applies and the sample-size guards that gate it (MIN_PORTFOLIO_PERIODS_HARD / MIN_PORTFOLIO_PERIODS_WARN).

    reference/metric-applicability →

  • Individual × Continuous landing


    Adjacent metrics in the same cell.

    api/metrics/individual-continuous →