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-period 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-period 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.

They weight events differently, so their values are not comparable. compute_caar preserves factor magnitude — AR x factor, the Sefcik-Thompson (1986) magnitude-weighted CAAR — while bmp_z, corrado_rank and every event_quality metric use AR x sign(factor). On a {1, 10} intensity factor the two differ by a factor of ~4 and can differ in sign, because caar is dominated by the high-|factor| events and the siblings are not. Comparing caar.value to bmp_z.value or to event_hit_rate compares different estimands; comparing their p-values is fine, since all of them test the same \(H_0\).

Pass a .sign()-coerced factor column to compute_caar to put it on the siblings' footing. factrix does not coerce by default: magnitude weighting is the whole point of a graded event signal, and a metric that silently discarded it would be the more surprising default. compute_caar warns (SPARSE_MAGNITUDE_WEIGHTED) when the column is mixed-sign and not a clean ternary; a strictly positive intensity column is unambiguous and does not warn, but is still magnitude-weighted.

References

factrix.metrics.caar.compute_caar

compute_caar(data: DataFrame, *, factor_col: str = 'factor', return_col: str = 'forward_return', estimation_window: int = 60, overlap_periods: int = DEFAULT_FORWARD_PERIODS, expected_warnings: tuple[str, ...] = ()) -> DataFrame

Per-event-period weighted abnormal return series.

Magnitude is preserved — no .sign() coercion.

The abnormal return is a real abnormal return: an abnormal_return column on the input is used as-is (market-adjusted), and otherwise the asset's estimation-window mean, lagged by overlap_periods, is subtracted from return_col — see :func:~factrix.metrics._helpers._attach_abnormal_return. Events without enough history for that estimate carry a null abnormal return and are dropped with the other non-finite rows.

Output columns

date: event period (one row per period 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 periods, so this count is the per-period 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 grid (dense rank over every date in data, including non-event periods). Consumers that sub-sample for non-overlap independence measure the gap between kept event periods in these grid 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. n_events_dropped_non_finite: total number of event rows removed before aggregation because the selected return source or factor_col was non-finite. Broadcast as a constant on every row (the count is a whole-frame diagnostic, not a per-period one: a date whose events were all non-finite leaves the output entirely, so a per-period column could not carry it). Consumers surface it as metadata["n_events_dropped_non_finite"]. sparse_magnitude_weighted: whether the input factor is mixed-sign and not a clean {-1, 0, +1} ternary. Broadcast as a constant so downstream event tests can attach the structured warning code. event_weighting: "sign" when every non-zero factor has unit magnitude, otherwise "factor_magnitude". Unlike the warning above, this also identifies an all-positive graded intensity. return_scale: "per_period_simple" when the abnormal return is derived from return_col; "supplied_abnormal_return" when the input's abnormal_return column is used as-is.

Non-finite handling

polars' mean propagates float NaN (it only skips nulls), so a single NaN return on one event used to poison that whole date's caar — and a NaN caar then reaches _calc_t_stat downstream, which returns NaN and makes the metric withhold its test as degenerate_variance, mislabelling missing data as degeneracy. Event rows are therefore filtered to a finite selected return source and finite factor_col before the group_by (this is the producer boundary that the project convention makes responsible for dropping non-finite values), the surviving per-period mean is taken over finite events only, and the dropped count is both reported on the frame and warned about. Note factor_col needs its own guard: NaN != 0 evaluates to True in polars, so a NaN factor survives the event filter and would make _signed_car NaN.

factrix.metrics.caar.caar

caar(caar_df: DataFrame, *, overlap_periods: int = DEFAULT_FORWARD_PERIODS, expected_warnings: tuple[str, ...] = ()) -> MetricResult

CAAR significance: is mean CAAR significantly different from zero?

The event floor is dynamic — the minimum event-period count scales with the overlap_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 periods stays authoritative.

Parameters:

Name Type Description Default
caar_df DataFrame

Output of compute_caar() with columns date, caar, n_events, date_ordinal, n_events_dropped_non_finite, sparse_magnitude_weighted, event_weighting, return_scale. The diagnostic columns are optional (hand-built frames omit them); when present the scale and weighting are copied into metadata, the non-finite count is echoed into metadata["n_events_dropped_non_finite"], and the sparse-magnitude flag becomes WarningCode.SPARSE_MAGNITUDE_WEIGHTED on warning_codes.

required
overlap_periods int

Sampling interval for non-overlapping dates. Maps to config.overlap_periods — the return horizon used in compute_forward_return. Distinct from the post-event window that controls MFE/MAE.

DEFAULT_FORWARD_PERIODS

Returns:

Type Description
MetricResult

MetricResult with value=mean CAAR on the non-overlap subsample,

MetricResult

stat=t from that same subsample.

Notes

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

Windows are counted in panel periods. The estimation window behind the abnormal return is measured on the panel's distinct-date grid, not on the asset's own rows, so it spans estimation_window grid periods for every name; periods an asset is missing count as missing observations inside it and can push the window below min_samples, dropping the event. A ragged panel raises ragged_period_grid.

One sample behind every field. value, stat, p_value and n_obs are all computed on the event-spaced subsample. Earlier versions reported value as the mean of the full event-period series while stat/p_value/n_obs came from the subsample, so the published effect size did not belong to the published test — a headline CAAR could be positive while the t-stat that "supports" it was estimated on a different (often much smaller) sample. The full-series mean is still available as metadata["mean_caar_full"] with its size metadata["n_event_periods_full"]; use it as a descriptive summary only, never as the effect size for the reported \(p\).

Non-finite caar rows are dropped before the spacing pass. Order matters: the greedy walk keeps the first event of every admissible grid gap, so a null-caar date filtered afterwards would still have consumed its slot and blocked the next usable event — silently shrinking the subsample and shifting which dates it contains. Dropping first lets a usable neighbour take the slot.

The subsample is drawn grid-aware: the CAAR series is event-period-indexed (compute_caar keeps only factor != 0 rows), so its dates are irregular on the period grid. Sampling every overlap_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 panel grid) keeps an event only when its grid gap to the previously kept event is >= overlap_periods, so consecutive kept observations no longer share overlapping forward-return windows. The alternative — reindexing to a dense grid 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 grid walk keeps the event-only mean intact.

caar is an equal-weight calendar-time portfolio test: the inference unit is the event period. Same-period events are collapsed to one cross-asset mean (which absorbs same-period cross-sectional correlation by construction), and the t-test runs across those periods — so n counts event periods (the number of periods with an event), not events, and metadata["n_event_periods_sampled"] names it precisely. n_obs_axis is "events", the one token the whole event battery reports on: every member's sample is a set of non-overlapping event observations, and stacking their to_frame() rows must not yield two axis labels for one quantity. It uses non-overlap resampling rather than Newey-West (NW) heteroskedasticity-and-autocorrelation-consistent (HAC), the same convention as ic.

The abnormal return, and when its model is conservative. The per-period CAAR is a mean of abnormal returns — the asset's estimation-window mean subtracted, or a supplied abnormal_return column (metadata["abnormal_return_model"]; see :func:~factrix.metrics._helpers._attach_abnormal_return). The mean-adjusted model is identified when little of each event's window is other events' realised returns; above ESTIMATION_WINDOW_EVENT_SHARE_WARN of metadata["estimation_window_event_share"] the test fires ESTIMATION_WINDOW_CONTAMINATED and reads conservative. caar is the least affected member of the battery because the same-period average is taken before the variance (20 assets, 5% trigger: 4.7 / 2.3 / 5.0% at \(h = 1 / 5 / 21\)), but a single asset at \(h = 21\) rejects 0.0% of true nulls (nominal 5%).

Scale and weighting are explicit. Standard pipeline output carries metadata["return_scale"] == "per_period_simple" because :func:~factrix.preprocess.compute_forward_return divides the simple horizon return by forward_periods. Recover the cumulative simple CAR with value * EvaluationResult.forward_periods. Do not use overlap_periods: on a coarse evaluation grid it can be smaller than the economic horizon. A supplied abnormal_return column instead carries "supplied_abnormal_return" because factrix does not know its scale. metadata["event_weighting"] is "sign" for unit non-zero factors and "factor_magnitude" for graded factors, including strictly positive intensity signals.

The across-events siblings are complementary, not redundant: bmp_z is the across-events standardized-AR z-test with the Kolari-Pynnönen clustering correction on by default — 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, and — because it tests the event-period series rather than pooled event rows — to same-period clustering as well. The per-period 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, rng=0),
...     forward_periods=5,
... )
>>> caar_df = compute_caar(panel)
>>> result = caar(caar_df, overlap_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, overlap_periods: int = DEFAULT_FORWARD_PERIODS, kolari_pynnonen_adjust: bool = True, include_prediction_error_variance: bool = False, expected_warnings: tuple[str, ...] = ()) -> MetricResult

Boehmer-Musumeci-Poulsen Standardized Abnormal Return test.

The static event floor (MIN_EVENTS_HARD) gates the standardized-AR z-test on the count of events with a usable estimation-window volatility that survive the event-axis spacing pass; the warn floor scales with the overlap_periods parameter (the spacing stride), so the threshold is declared as a resolver (a callable sample_threshold) rather than a constant.

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. The abnormal return is mean-adjusted (or taken from a supplied abnormal_return column) — see Notes.

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 overlap_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 price-derived one-period 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 panel periods before each event for volatility estimation (default 60). Counted on the panel's own date grid, so an asset missing periods estimates on a shorter sample rather than a stretched window.

60
overlap_periods int

Return horizon for vol scaling (default 5), counted in panel periods. When using price-derived one-period vol, scales by 1/sqrt(overlap_periods) to match per-period forward_return.

DEFAULT_FORWARD_PERIODS
kolari_pynnonen_adjust bool

On by default. Apply the Kolari-Pynnönen (2010) adjustment for cross-sectional correlation of SAR. BMP assumes events are cross-sectionally independent; same-period events (earnings season, index rebalances, macro releases) share a common shock and break that. Measured on a true null with ρ = 0.5 (nominal 5%, \(h = 1\), 20 assets, 300 draws): 1 event per period 3.7% either way (the adjustment is the identity when no two events share a period — kolari_pynnonen_applied is False); 4 per period over 30 periods 19.7% → 4.7%; 10 per period 38.7% → 3.7%. What it cannot do is manufacture independent periods: once same-period events are correlated the effective sample is closer to the number of distinct event periods than to the event count, and the adjusted test's residual tracks that — 4 events per period over 4 / 8 / 15 / 30 periods: 14.3% / 10.0% / 7.3% / 4.7%, and 10 events per period over 8 / 15 / 30: 9.0% / 8.7% / 3.7% (SE ≈ 1.1–1.8 pp). The residual depends on the period count more than on how many events share each period, and is gone by ~30 periods. So when events share periods, FEW_EVENTS fires on metadata["n_event_periods"] (distinct event periods) below MIN_EVENTS_WARN, not on n_events; with one event per period the two coincide and the hard floor on n_events is the only gate. MIN_EVENTS_WARN (30) is the floor caar and corrado_rank already apply on this axis, reused so the three event-study tests share one grammar, and it is where the measured residual clears. False gives the unadjusted BMP for reproducing a source that reports it; metadata["kolari_pynnonen_applied"] / ["kolari_pynnonen_r"] disclose which statistic ran. Formula: \(z_{\mathrm{KP}} = z_{\mathrm{BMP}} / \sqrt{1 + (N_{\mathrm{eff}} - 1) \cdot \hat r}\) where \(\hat r\) is the one-way-ANOVA ICC(1) estimate of the within-period correlation of SAR and N_eff is the average events per event period. This is the plain design-effect deflator. The published K-P statistic carries an extra \((1 - \hat r)\) in the numerator, which factrix deliberately omits — see Notes for why. Vanilla BMP overstates significance when events cluster on the same date (earnings season, macro release), inflating z by factors of 1.5-2×. It is on by default and is the identity when no two events share a period, so there is nothing to trigger: clustering_hhi's events_per_period_mean tells you whether it will bite. (An earlier version of this docstring pointed at an HHI >= 0.3 rule; that threshold is unreachable — the HHI is bounded below by 1/D and is invariant to how many assets fire per date, so it cannot detect same-period clustering at all.)

True
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=min(20, estimation_window)) 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 is an approximation in that regime, and on the no-price path at \(h > 1\) as well: there \(T_{\mathrm{est}}\) counts overlapping forward-return rows that carry roughly \(W / h\) independent observations. 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\) = overlap_periods) when a price column is 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 \(1 / \sqrt{1 + (N_{\mathrm{eff}} - 1)\, \hat r}\).

Windows are counted in panel periods. Both the estimation-window mean and the volatility behind \(\sigma_i\) are measured on the panel's distinct-date grid rather than on the asset's own rows, so estimation_window spans that many grid periods for every name and an asset's missing periods count as missing observations inside the window instead of stretching it further back. A ragged panel raises ragged_period_grid.

Scope of the \(1/\sqrt{h}\) vol scale. It converts a one-period vol to the horizon of a forward return realised over \(h\) periods of the panel's own grid, so it is exact only on the unsampled full grid: on an evaluation grid sub-sampled after compute_forward_return the stamped \(h\) no longer counts the periods the return actually spans. The p-value is unaffected either way. The factor is common to every event, so it cancels in \(z = \mathrm{mean}(\mathrm{SAR}) / (\mathrm{std}(\mathrm{SAR}) / \sqrt{N})\) — numerator and denominator scale together — and the Kolari-Pynnönen deflator is a function of the SAR correlation, itself scale-free. Only the descriptive metadata["std_sar"] (and value, the mean SAR) shifts, by the constant \(\sqrt{h_{\text{stamped}} / h_{\text{true}}}\); read those two as scaled units, not as absolute vol multiples, on a resampled grid.

Which K-P deflator. Kolari-Pynnönen's published statistic multiplies by \(\sqrt{(1 - \hat r) / (1 + (N_{\mathrm{eff}} - 1)\hat r)}\). The \((1 - \hat r)\) numerator belongs to their setting, where the SAR variance is estimated within a single event period: a one-date cross-sectional variance under equicorrelation estimates only the idiosyncratic share \((1 - \hat r)\sigma^2\), so the numerator restores the missing between-date component. factrix pools SAR across all event periods, so std_sar already contains that component and re-applying \((1 - \hat r)\) would deflate \(z\) twice — anti-powered rather than merely conservative. The variant implemented here is therefore the pure design-effect deflator \(1/\sqrt{1 + (N_{\mathrm{eff}} - 1)\hat r}\), the standard clustered-sampling correction for a pooled variance. Callers who need the literal published constant can recover it from metadata["stat_uncorrected"] and metadata["kolari_pynnonen_r"].

Time-axis overlap. Events are first strided per asset so no two kept events on one name sit inside one forward-return window — the Brown-Warner (1985) non-overlap sampling convention, applied on the event axis rather than the period (:func:~factrix.metrics._helpers._sample_events_non_overlapping), and the same treatment caar applies to its event-period series. It is the complement of the K-P adjustment above, not a substitute: K-P deflates \(z\) for events sharing a period across names, this removes events sharing bars on one name. On a single-asset panel K-P is the identity by construction, so this pass is the only overlap discipline there; EVENT_WINDOW_OVERLAP reports what it removed.

Event validity. An event enters the test only when its estimation-window vol is finite and above EPSILON and its signed AR is finite. The two rejection reasons are reported separately (n_dropped_no_vol / n_dropped_non_finite_return, summing to n_dropped) because they mean different things: the first is a short history, the second is a hole in return_col. Both must be excluded — a NaN SAR propagates through mean/std and turns \(z\) into a silent 0 with \(p = 1\).

The abnormal return. \(\mathrm{AR}_i\) is a genuine abnormal return: the asset's estimation-window mean is subtracted from return_col — taken on the same estimation_window bars of one-bar returns as \(\sigma_i\) when price is present, on return_col rows lagged by overlap_periods otherwise — or an abnormal_return column on the input is used as supplied (see :func:~factrix.metrics._helpers._attach_abnormal_return for both models and metadata["abnormal_return_model"] / ["estimation_window_source"] for which ran).

Not robust to heavy skew at \(h > 1\) — use corrado_rank. With \(\hat\mu_i\) and \(\sigma_i\) estimated on the same window (the textbook form), a right-tail bar raises both, so under skew \(E[\hat\mu_i / \sigma_i] \neq 0\) and the standardised abnormal return inherits a bias of \(-\sqrt{h}\,E[\hat\mu_i / \sigma_i]\): the \(1/\sqrt{h}\) vol scaling shrinks the standardiser while the bias in the mean does not shrink. Measured on a standardised lognormal(0.9) iid null (20 assets, 300 draws, nominal 5%): mean SAR \(+0.04 / +0.08 / +0.16\) and size 5.5 / 18 / 32% at \(h = 1 / 5 / 21\); the same panels with the raw return supplied as abnormal_return (no \(\hat\mu\)) are unbiased (3.5 / 3.5 / 0.5%). Estimating the mean on the vol window rather than on lagged rows does not change this — it is the estimator, not the window. Signs that mix cancel it (4.3% with half the events short). corrado_rank (4.3%) and event_hit_rate (4.3%) are rank / sign tests and do not carry it. Earlier versions standardised the raw forward return while documenting mean-adjusted residuals; on a 20-asset panel drifting 0.08% per period with zero-information event dates that returned \(z = 5.34\) (\(p = 9\times10^{-8}\)) and rejected 50% of null draws; with the mean subtracted the drifting panel rejects exactly as often as the same panel without drift (4.3% at \(h = 1\), 3.7% at \(h = 5\), 20 assets, 300 draws). Events whose asset has too little history for the estimate are excluded and counted.

Conservative on dense triggers and long horizons. The mean is estimated from the asset's own history, which contains the realised forward returns of its earlier events; once those fill more than a quarter of the window (metadata["estimation_window_event_share"], ESTIMATION_WINDOW_CONTAMINATED) the per-event abnormal returns are negatively correlated and \(z\) under-rejects: 0.3% at \(h = 21\) on 20 assets, 0.0% on a single asset, 0.7% with 20 names firing on the same 40 periods at \(h = 5\) (nominal 5%). The market-adjusted branch does not remove it and costs the effect on same-period panels — see :func:~factrix.metrics._helpers._attach_abnormal_return for the measurements. Read a flagged p-value as an upper bound.

factrix simplifies the original BMP by omitting the prediction- error term from the standardiser and by using mean-adjusted rather than market-model residuals — adequate for the default Brown-Warner / MacKinlay event-study path; pair with the K-P adjustment when same-period shock sharing is expected. 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 implements with mean-adjusted residuals and, by default, no prediction-error correction.
  • 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, rng=0),
...     forward_periods=5,
... )
>>> result = bmp_z(panel, overlap_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.

caar.value is per period, not cumulative

Despite the name, caar.value is CAR / h, not the cumulative abnormal return of MacKinlay (1997) §4. compute_forward_return divides by forward_periods to put every horizon on a common per-period scale, and the event family reads that column, so the normalisation is inherited rather than undone here. With μ = 0.0008 per period over h = 5, the reported value is 0.001056 — the per-period average — not 5× that.

The contract is machine-readable as result.metadata["return_scale"] == "per_period_simple". Multiply by EvaluationResult.forward_periods to recover the cumulative quantity the name and the Brown-Warner / MacKinlay citations promise. Do not multiply by overlap_periods: that field controls inference and can be smaller than the economic horizon on a coarse evaluation grid. Reading caar.value directly as a 5-period event CAR is off by 5×. A caller-supplied abnormal_return instead reports "supplied_abnormal_return"; factrix does not guess that column's scale.

Inspect the event-weighting estimand

compute_caar multiplies abnormal return by the factor without silently coercing its magnitude. The producer column and downstream result.metadata["event_weighting"] therefore report:

Value Factor contract Estimand
"sign" Every non-zero factor has magnitude 1 Equal-event signed CAAR
"factor_magnitude" At least one non-zero factor has non-unit magnitude Factor-magnitude-weighted CAAR

Strictly positive graded intensity factors are in the second row even though they do not raise SPARSE_MAGNITUDE_WEIGHTED; that warning remains reserved for the ambiguous mixed-sign, non-ternary case.

Use cases

  • Per-event-period CAAR series


    The per-event-period 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-overlap_periods subsample of the per-event-period 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. The Kolari-Pynnönen same-period-correlation adjustment is on by default (identity when no two events share a period; 21.5% → 5.0% size at 4 events per period on a null); kolari_pynnonen_adjust=False gives the unadjusted BMP for matching a source that reports it.

  • 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-period 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-period 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 period
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 periods in the CAAR series
n_event_periods_sampled caar(...).metadata["n_event_periods_sampled"] Event dates kept by the 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-period 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, rng=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, overlap_periods=5)
print(out.value, out.stat, out.p_value)
# 0.0039  6.42  1.4e-09   (approximate)

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

See also