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 andbmp_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 withincluster_windowperiods. 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 |
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
|
3
|
expected_warnings
|
tuple[str, ...]
|
Warning codes the caller declares; a declared code
is still recorded, the |
()
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
MetricResult with value=HHI, metadata includes the cross-sectional |
MetricResult
|
( |
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:
Use cases¶
-
Gate the CAAR independence assumption
Read
value(Herfindahl-Hirschman index (HHI) on the event-period histogram) andmetadata["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_meanis the cross-sectional axis — the Kish effective cluster size, and the quantitybmp_z/event_hit_rate/event_icfeed 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_burstsis the temporal axis — the share of events whose same-asset predecessor sits withincluster_windowperiods, 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¶
-
caar/bmp_z
The downstream tests whose independence assumption this metric gates.
bmp_z(kolari_pynnonen_adjust=True)is the formal correction. -
signal_density
Inverse firing frequency — pair with
clustering_hhisince bars-per-event ignores temporal concentration. -
Metric applicability reference
Confounded-event handling and within-asset event clustering notes.
-
Individual × Sparse landing
Adjacent event-study metrics in the same cell.