Skip to content

factrix.metrics.clustering_hhi

Event clustering diagnostic for event signals.

When events cluster on the same dates, the independence assumption underlying the CAAR t-test is violated, potentially inflating the test statistic. The Herfindahl-Hirschman Index (HHI) on event dates quantifies this concentration.

Only meaningful for multi-asset panels (n_assets > 1). For single-asset event studies, clustering across assets is not applicable.

Notes

Pipeline. Static cross-section — single HHI computed once over the event-date histogram; no time-axis aggregation, no formal H₀ (descriptive concentration index).

factrix.metrics.clustering_hhi.clustering_hhi

clustering_hhi(data: DataFrame, *, factor_col: str = 'factor', cluster_window: int = 3, expected_warnings: tuple[str, ...] = ()) -> MetricResult

Event clustering Herfindahl index on event dates.

The static event floor (sample_threshold=SampleThreshold(min_events=MIN_EVENTS_HARD)) gates this descriptive diagnostic on the count of non-zero (event) observations.

Computes \(\mathrm{HHI} = \sum_d s_d^2\) where \(s_d = (\text{events on date } d) / (\text{total events})\). Herfindahl-Hirschman index (HHI) ranges from \(1/D\) (uniform) to \(1.0\) (all events on one date).

Read the three numbers together — HHI alone cannot answer "do my events cluster?" By construction \(\mathrm{HHI} \ge 1/D\) over \(D\) event dates, and it is invariant to how many assets fire on each date: 20 assets all firing on the same 40 dates — the maximum-cross-sectional-clustering design — scores \(\mathrm{HHI} = 0.025\) and \(\texttt{hhi\_normalized} = 0.000\), identical to one asset firing once on each of those dates. Any rule of the form "HHI above 0.3 means clustered" is therefore unreachable on a panel with more than a handful of event dates, and factrix no longer states one.

What HHI does measure is whether the events are spread evenly across the dates that have events. The two axes it misses are reported alongside it:

  • events_per_period_mean — the cross-sectional axis, and the one the Kolari-Pynnönen adjustment acts on. Above 1, events share periods and bmp_z's adjustment (on by default) has something to correct.
  • share_events_in_bursts — the temporal axis: the share of events whose same-asset predecessor sits within cluster_window periods. HHI is blind to this too, scoring 30 events on 30 consecutive periods exactly as it scores 30 events spread over 30 years.

Parameters:

Name Type Description Default
data DataFrame

Panel with date, asset_id, factor.

required
cluster_window int

Periods defining a temporal burst. An event counts as clustered in time when the same asset's previous event sits fewer than this many periods earlier, measured on the full panel calendar; the share of such events is reported as share_events_in_bursts.

3
expected_warnings tuple[str, ...]

Warning codes the caller declares; a declared code is still recorded, the UserWarning echo is silenced.

()

Returns:

Type Description
MetricResult

MetricResult with value=HHI, metadata includes the cross-sectional

MetricResult

(events_per_period_mean) and temporal (share_events_in_bursts)

MetricResult

concentration measures HHI itself cannot see.

Notes

\(\mathrm{HHI} = \sum_d s_d^2\) where \(s_d = (\text{events on date } d) / \text{total}\); ranges from \(1/D\) (uniform across \(D\) event dates) to \(1.0\) (all events on a single date). An event is a finite non-zero factor_col observation: non-finite factors are excluded rather than counted as events (a bare factor != 0 predicate is True for NaN in polars as in numpy). n_periods_effective \(= 1 / \mathrm{HHI}\); hhi_normalized \(= (\mathrm{HHI} - 1/D) / (1 - 1/D)\) rescales to \([0, 1]\) — 0 when every event date carries the same number of events, including under perfect cross-sectional clustering.

events_per_period_mean is the Kish effective cluster size \(n_0 = (N - \sum_d n_d^2 / N) / (D - 1)\), the same quantity :func:~factrix.metrics._helpers._estimate_within_date_icc feeds to the Kolari-Pynnönen deflator, so this diagnostic and the correction it points at read one number rather than two.

factrix reports these as descriptive concentration indices — no formal \(H_0\) — because the corrections they point at are applied automatically: bmp_z, event_hit_rate and event_ic deflate for same-period clustering by default, and every event significance test strides its event axis for the temporal kind.

Examples:

>>> import factrix as fx
>>> from factrix.metrics.clustering_hhi import clustering_hhi
>>> panel = fx.datasets.make_event_panel(n_assets=50, n_dates=400, rng=0)
>>> result = clustering_hhi(panel)
>>> result.name == ""
True

Use cases

  • Gate the CAAR independence assumption


    Read value (Herfindahl-Hirschman index (HHI) on the event-period histogram) and metadata["n_periods_effective"] \(= 1 / \mathrm{HHI}\). High HHI → the events that exist are concentrated on few of the dates that have events. This is one axis of three, and not the one the Kolari-Pynnönen adjustment acts on: HHI is invariant to how many assets fire per date, so 20 assets all firing on the same 40 dates scores exactly what one asset firing once on each of those dates scores.

  • See the axes HHI misses


    events_per_period_mean is the cross-sectional axis — the Kish effective cluster size, and the quantity bmp_z / event_hit_rate / event_ic feed to their Kolari-Pynnönen deflator. Above 1, events share periods and those corrections bite (they are on by default; there is no threshold to act on). share_events_in_bursts is the temporal axis — the share of events whose same-asset predecessor sits within cluster_window periods, the regime the event tests' non-overlap stride removes.

Worked example — HHI on event periods

clustering_hhi on a synthetic event panel

import factrix as fx
from factrix.metrics.clustering_hhi import clustering_hhi
from factrix.metrics.caar import bmp_z
from factrix.preprocess import compute_forward_return

# At this event rate the generator's calendar already puts several
# events on the same period, which is the regime the diagnostic reads.
raw   = fx.datasets.make_event_panel(
    n_assets=200, n_dates=500, event_rate=0.02, rng=2024,
)
panel = compute_forward_return(raw, forward_periods=5)

diag = clustering_hhi(panel)
print(diag.value,
      diag.metadata["n_periods_effective"],
      diag.metadata["hhi_normalized"],
      diag.metadata["events_per_period_mean"],
      diag.metadata["share_events_in_bursts"])

# events_per_period_mean > 1 is what the K-P adjustment acts on; it is on
# by default and is the identity when events do not share periods.
z = bmp_z(panel, kolari_pynnonen_adjust=True)

See also