Skip to content

factrix.metrics.caar

CAAR (Cumulative Average Abnormal Return) significance tests.

Tests \(H_0\): event abnormal return = 0, using two complementary methods: compute_caar — per-event-date weighted abnormal return series caar — CAAR t-test (parametric, non-overlapping sampling) bmp_z — BMP standardized AR test (robust to event-induced variance)

Notes

caar and bmp_z are complementary inferential tests on the per-event-date abnormal-return series. caar is the parametric cross-event \(t\)-test; bmp_z is the standardized-AR \(z\)-test that is robust to event-induced variance.

References

factrix.metrics.caar.compute_caar

compute_caar(data: DataFrame, *, factor_col: str = 'factor', return_col: str = 'forward_return') -> DataFrame

Per-event-date weighted abnormal return series.

Magnitude is preserved — no .sign() coercion.

Output columns

date: event date (one row per date carrying at least one event). caar: cross-asset mean of the (signed/magnitude-weighted) abnormal return on that date. n_events: number of events (non-zero factor rows) collapsed into this date's caar. The downstream caar() test is an equal-weight calendar-time portfolio across event dates, so this count is the per-date portfolio breadth — surfaced for transparency (a date built on 1 event vs 500 is otherwise indistinguishable), not used to weight or drop dates. date_ordinal: 0-based position of the date on the full input calendar (dense rank over every date in data, including non-event dates). Consumers that sub-sample for non-overlap independence measure the gap between kept event dates in these calendar steps rather than in event-index steps — the rank is computed before the factor != 0 filter, so a gap of k means k underlying periods elapsed, not k events. On an event-only series the two diverge under sparse or clustered events, so the ordinal is what makes the forward-return overlap window measurable downstream.

factrix.metrics.caar.caar

caar(caar_df: DataFrame, *, forward_periods: int = 5) -> MetricResult

CAAR significance: is mean CAAR significantly different from zero?

The event floor is dynamic — the minimum event-date count scales with the forward_periods parameter (non-overlapping stride) — so it is declared as a resolver (a callable sample_threshold) rather than a constant. Pre-flight counts non-zero factor rows as a loose upper bound; this in-body short-circuit on event dates stays authoritative.

Parameters:

Name Type Description Default
caar_df DataFrame

Output of compute_caar() with columns date, caar, n_events, date_ordinal.

required
forward_periods int

Sampling interval for non-overlapping dates. Maps to config.forward_periods — the return horizon used in compute_forward_return. Distinct from EventConfig.event_window_post which controls MFE/MAE.

5

Returns:

Type Description
MetricResult

MetricResult with value=mean CAAR, stat=t from non-overlapping sampling.

Notes

\(t = \mathrm{mean}(\mathrm{CAAR}) / (\mathrm{std}(\mathrm{CAAR}) / \sqrt{n})\) on a non-overlap subsample of the per-event-date \(\mathrm{CAAR}\) series; \(H_0: \mathbb{E}[\mathrm{CAAR}] = 0\).

The subsample is drawn calendar-aware: the CAAR series is event-date-indexed (compute_caar keeps only factor != 0 rows), so its dates are calendar-irregular. Sampling every forward_periods-th row (index distance) would mis-handle both regimes — sparse events get further thinned (power loss), clustered events inside one forward-return window are admitted as independent (iid violated, \(t\) inflated). Instead a greedy pass over date_ordinal (each event's position on the full calendar) keeps an event only when its calendar gap to the previously kept event is >= forward_periods, so consecutive kept observations no longer share overlapping forward-return windows. The alternative — reindexing to a dense calendar with zero-fill before fixed-stride sampling — was rejected: the zero padding would dominate the subsample and distort the iid mean estimator this path is built around; the greedy calendar walk keeps the event-only mean intact.

caar is an equal-weight calendar-time portfolio test: the inference unit is the event date. Same-date events are collapsed to one cross-asset mean (which absorbs same-date cross-sectional correlation by construction), and the t-test runs across those dates — so n counts event dates (the number of periods with an event), not events. It uses non-overlap resampling rather than Newey-West (NW) heteroskedasticity-and-autocorrelation-consistent (HAC), the same convention as ic.

The across-events siblings are complementary, not redundant: bmp_z is the across-events standardized-AR z-test with an optional Kolari-Pynnönen clustering correction — use it when events are heavily clustered or across-events power is wanted; and corrado_rank is the non-parametric rank test robust to heavy-tailed event returns. The per-date portfolio breadth behind this test is surfaced as n_events (the compute_caar series) and total_events (this result's metadata).

References
  • Brown & Warner (1985). "Using Daily Stock Returns: The Case of Event Studies." Journal of Financial Economics, 14(1), 3–31. Daily event-study t-test specification at standard sample sizes.
  • MacKinlay (1997). "Event Studies in Economics and Finance." Journal of Economic Literature, 35(1), 13–39. Event-window vocabulary.

Examples:

Chain from :func:compute_caar output:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.caar import compute_caar, caar
>>> panel = compute_forward_return(
...     fx.datasets.make_event_panel(n_assets=50, n_dates=400, seed=0),
...     forward_periods=5,
... )
>>> caar_df = compute_caar(panel)
>>> result = caar(caar_df, forward_periods=5)
>>> result.name == ""
True

factrix.metrics.caar.bmp_z

bmp_z(data: DataFrame, *, factor_col: str = 'factor', return_col: str = 'forward_return', estimation_window: int = 60, forward_periods: int = 5, kolari_pynnonen_adjust: bool = False, include_prediction_error_variance: bool = False) -> MetricResult

Boehmer-Musumeci-Poulsen Standardized Abnormal Return test.

The static event floor (sample_threshold=SampleThreshold(min_events=MIN_EVENTS_HARD)) gates the standardized-AR z-test on the count of events with a usable estimation-window volatility.

Standardizes each event's abnormal return by the asset's pre-event residual volatility, making the test robust to event-induced variance inflation that biases the ordinary CAAR \(t\)-test.

Uses price column for estimation-window volatility if available; falls back to per-asset historical forward_return std otherwise. The fallback std is lagged by forward_periods so the estimation window ends before each event's own forward return (which spans (t, t+h]) rather than leaking the event AR into its own standardiser; it remains a coarser, horizon-overlapping vol proxy than a daily-price std and raises WarningCode.BMP_RETURN_VOL_FALLBACK (metadata["vol_source"] records which path ran).

Steps
  1. For each event (\(\text{factor} \neq 0\)), look back estimation_window periods of the same asset's returns to estimate \(\sigma_i\).
  2. Scale \(\sigma_i\) to match the forward_return horizon.
  3. \(\mathrm{SAR}_i = \mathrm{AR}^{\mathrm{signed}}_i / \sigma^{\text{scaled}}_i\).
  4. \(z = \mathrm{mean}(\mathrm{SAR}) / (\mathrm{std}(\mathrm{SAR}) / \sqrt{N})\).

Parameters:

Name Type Description Default
data DataFrame

Full panel (including non-event rows) with date, asset_id, factor, forward_return. Must include enough history for estimation window.

required
estimation_window int

Number of periods before each event for volatility estimation (default 60).

60
forward_periods int

Return horizon for vol scaling (default 5). When using price-derived daily vol, scales by 1/sqrt(forward_periods) to match per-period forward_return.

5
kolari_pynnonen_adjust bool

When True, apply the Kolari-Pynnönen (2010) adjustment for cross-sectional correlation of SAR: \(z_{\mathrm{KP}} = z_{\mathrm{BMP}} \cdot \sqrt{(1 - \hat r) / (1 + (N_{\mathrm{eff}} - 1) \cdot \hat r)}\) where \(\hat r\) is the ICC-style within-date correlation of SAR and N_eff is the average events per event date. Vanilla BMP overstates significance when events cluster on the same date (earnings season, macro release), inflating z by factors of 1.5-2×. Enable this when the event-study clustering_hhi diagnostic is high (≥ 0.3) or when you otherwise expect same-date shock sharing.

False
include_prediction_error_variance bool

When True, inflate the per-event standardiser by \(\sqrt{1 + 1/T_{\mathrm{est}}}\) (with \(T_{\mathrm{est}}\) = estimation_window) to absorb the prediction-error variance of the mean-adjusted residual forecast — the strict Boehmer-Musumeci-Poulsen (1991) denominator. Default is False, preserving the prior factrix denominator (residual std only). Under mean-adjusted residuals + a single estimation_window the correction scales every SAR by the same constant, so mean_SAR and std_SAR shrink by \(1/\sqrt{1 + 1/T_{\mathrm{est}}}\) but the \(z\) statistic is invariant: the flag documents the strict standardiser, it does not move inference in this regime. Per-event \(T_i\) variation (which would move \(z\)) requires a market-model extension and is out of scope here.

Caveat: rolling_std(min_samples=20) accepts events with as few as 20 prior returns, so the effective \(T_i\) for early-history events can be smaller than estimation_window. The constant correction is therefore an approximation in that regime; ensure every event has at least estimation_window prior returns when the strict denominator matters.

False

Returns:

Type Description
MetricResult

MetricResult(value=mean_SAR, p_value=p_bmp, stat=z_bmp, ...).

Notes

For each event \(i\): estimate pre-event vol \(\sigma_i\) over the estimation_window, scaled to the forward horizon by \(1/\sqrt{h}\) (with \(h\) = forward_periods) when daily prices are available; \(\mathrm{SAR}_i = \mathrm{AR}^{\mathrm{signed}}_i / \sigma_i\); aggregate to \(z = \mathrm{mean}(\mathrm{SAR}) / (\mathrm{std}(\mathrm{SAR}) / \sqrt{N})\). With kolari_pynnonen_adjust=True, scale \(z\) by \(\sqrt{(1 - \hat r) / (1 + (N_{\mathrm{eff}} - 1)\, \hat r)}\).

factrix simplifies the original BMP by omitting the prediction- error term from the standardiser (using mean-adjusted residuals rather than market-model residuals) — adequate for the default Brown-Warner / MacKinlay event-study path; pair with the K-P adjustment when clustering_hhi flags same-date shock sharing. Pass include_prediction_error_variance=True for the strict BMP denominator \(\sigma_i \cdot \sqrt{1 + 1/T_{\mathrm{est}}}\).

References
  • Boehmer, Musumeci & Poulsen (1991). "Event-study Methodology Under Conditions of Event-induced Variance." Journal of Financial Economics, 30(2), 253–272. The BMP standardised AR test factrix simplifies (mean-adjusted residuals, no prediction-error correction by default).
  • Kolari & Pynnönen (2010). "Event Study Testing with Cross-sectional Correlation of Abnormal Returns." Review of Financial Studies, 23(11), 3996–4025. Clustering- adjusted BMP variant; enabled via kolari_pynnonen_adjust=True on this function.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.caar import bmp_z
>>> panel = compute_forward_return(
...     fx.datasets.make_event_panel(n_assets=50, n_dates=400, seed=0),
...     forward_periods=5,
... )
>>> result = bmp_z(panel, forward_periods=5)
>>> result.name == ""
True

Event-study contracts

signed_car, the estimation_window consumed by bmp_z, and factrix's confounded-event handling are documented in Metric applicability § Event-study contracts. factrix computes CAR (sum of per-period abnormal returns), not BHAR; see the same section for the distinction.

Use cases

  • Per-event-date CAAR series


    The per-event-date weighted abnormal return series from a long-format panel. Consumed by caar for the significance test, and (where the magnitude-weighted form is wanted) available for per-slice summaries.

  • Mean-CAAR significance, non-overlapping


    Test \(H_0: \mathbb{E}[\mathrm{CAAR}] = 0\) on the every-forward_periods subsample of the per-event-date CAAR series to avoid the autocorrelation induced by overlapping forward returns. Default parametric test for the event-sparse cell.

  • Event-induced variance, BMP \(z\)-test


    Standardise each event's abnormal return by the asset's pre-event residual volatility before pooling. Robust to event-induced variance inflation that biases the ordinary CAAR \(t\)-test; pair with kolari_pynnonen_adjust=True when the event-date Herfindahl-Hirschman index (HHI) flags same-date shock sharing.

  • Magnitude-weighted CAAR


    With a continuous factor column, compute_caar returns the per-event regression-slope statistic in the Sefcik-Thompson (1986) lineage rather than the textbook equal-weighted MacKinlay CAAR — see the docstring for the input-contract table.

Choosing a function

Goal Function
Per-event-date CAAR table for downstream inspection / slicing compute_caar
Mean-CAAR significance, deterministic non-overlap subsample caar
Variance-robust event-induced significance (BMP standardised \(z\)) bmp_z

Event counts

compute_caar collapses same-date event rows before the caar test runs. The event-study path therefore exposes these related counts:

Field Where to read it Meaning
n_events compute_caar(...).select("date", "n_events") Raw event rows collapsed into each event date
total_events caar(...).metadata["total_events"] Sum of raw non-zero event rows behind the study
n_event_periods caar(...).metadata["n_event_periods"] Distinct event dates in the CAAR series
n_event_periods_sampled caar(...).metadata["n_event_periods_sampled"] Event dates kept by the calendar-aware non-overlap sampler used for the t-test

MetricResult.n_obs equals n_event_periods_sampled, because that is the sample entering the headline p_value. A large gap between total_events and n_event_periods means events cluster on the same dates. A large gap between n_event_periods and n_event_periods_sampled means the forward-return windows overlap heavily, so the non-overlap sampler thins the effective test sample.

For asset-allocation policy events, make sure the sparse factor sign encodes the expected return direction, not just the raw event type. If +1 means "central-bank hike" but hikes are bearish for one asset group and bullish for another, map the raw event into an asset-specific expected-return signal before calling compute_caar, event_hit_rate, or profit_factor.

Worked example — per-event-date CAAR then mean significance

compute_caar → caar on a synthetic event panel

import factrix as fx
import polars as pl
from factrix.metrics.caar import compute_caar, caar, bmp_z
from factrix.preprocess import compute_forward_return

pl.Config.set_tbl_formatting("ASCII_MARKDOWN")

raw   = fx.datasets.make_event_panel(
    n_assets=200, n_dates=500, event_rate=0.02,
    post_event_drift_bps=40.0, seed=2024,
)
panel = compute_forward_return(raw, forward_periods=5)

caar_df = compute_caar(panel)
print(caar_df.head())
# ┌────────────┬───────────┐
# │ date       ┆ caar      │
# ├────────────┼───────────┤
# │ 2024-01-04 ┆  0.0041   │
# │ 2024-01-11 ┆  0.0037   │
# │ ...        ┆ ...       │
# └────────────┴───────────┘

out = caar(caar_df, forward_periods=5)
print(out.value, out.stat, out.p_value)
# 0.0039  6.42  1.4e-09   (approximate)

# Variance-robust alternative when same-date clustering is high:
z_bmp = bmp_z(panel, estimation_window=60, forward_periods=5,
                 kolari_pynnonen_adjust=True)

See also