Skip to content

factrix.metrics.event_horizon

Multi-horizon event analysis — how does the density behave across time?

Answers
  • Is there pre-event leakage? (T-6..T-1 should be ~0)
  • At which horizon is the density strongest?
  • Does the alpha persist or decay quickly?
Metrics

compute_event_returns — per-event, per-offset raw return data event_around_return — return profile summary at each offset

Notes

Pipeline. Per-event return profile across k offsets (per-event step); a descriptive curve only — no hypothesis test, so p_value is None and the per-horizon hit_rate in per_offset is a raw fraction, not a tested statistic.

factrix.metrics.event_horizon.event_around_return

event_around_return(data: DataFrame, *, price_data: DataInput | None = None, offsets: list[int] | None = None, factor_col: str = 'factor', price_col: str = 'price', expected_warnings: tuple[str, ...] = ()) -> MetricResult

Return profile at multiple offsets around event date.

No static panel-shape thresholds are declared (sample_threshold=SampleThreshold()) because this is a multi-horizon summary metric whose available offsets and event counts are factor-context-dependent.

Summarizes per-offset: mean, median, p25, p75, hit_rate, n.

Offsets are steps on the panel's period grid (see :func:~factrix.metrics._primitives.compute_event_returns), as is the per-asset unconditional bar return the pre-event observations are taken against.

The primary value is the pre-event leakage score: the mean over pre-event offsets of |cross-event mean single-bar excess return|, where "excess" is over each event asset's unconditional bar return, signed like the return it is subtracted from (sign(factor) * asset_baseline). It is a mean of per-offset means, not a pre-event CAR — see Notes. High leakage → density may be reactive, not predictive; read it against metadata["leakage_null_scale"], which is what the score is worth under no leakage at all.

Parameters:

Name Type Description Default
data DataFrame

Panel with date, asset_id, factor, price.

required
price_data DataInput | None

Optional complete date, asset_id, price panel. Events come from data; offsets and the per-asset unconditional bar-return baselines use this full price grid. Pass the raw panel when data came from compute_forward_return.

None
offsets list[int] | None

Defaults to [-6, -3, -1, 1, 6, 12, 24].

None

Returns:

Type Description
MetricResult

MetricResult with per-offset stats and audit counts in metadata.

MetricResult

Every published per_offset[k] includes eligible, computed,

MetricResult

censored, and censor_reasons; n remains an alias for the

MetricResult

computed count. When price data is unavailable, the censor audit

MetricResult

explains why no path was computed. When the unconditional baseline

MetricResult

cannot be computed from valid positive prices, the whole curve is

MetricResult

withdrawn and per_offset is empty rather than publishing counts

MetricResult

from the discarded raw-path computation. Both cases return a

MetricResult

short-circuit MetricResult (value=NaN) so all metrics share a

MetricResult

single return contract.

Notes

For each offset k: mean, median, p25, p75, hit_rate across events with valid signed_return. The headline value = mean_{k < 0} |mean_k| summarises pre-event leakage — a healthy density has flat pre-event means.

factrix uses |mean| rather than absolute returns to avoid rewarding offsets where positive and negative pre-event drifts cancel — leakage with consistent direction would be missed by mean(|return|).

There is no zero to compare against, and the score is not a test. \(E|\bar x| \approx 0.8\,\sigma/\sqrt{n} > 0\) under a true null, so the headline is strictly positive by construction and shrinks as the event count grows: the same clean factor scores lower on more events. The doc's old "should be ~0" target was therefore unattainable, and two further things used to be confounded into it. Unconditional drift is now removed — every offset is an excess over sign(factor) * metadata["baseline_bar_return_by_asset"][asset_id], compounded over positive offsets, so neither heterogeneous asset drift nor unequal event counts manufacture alpha. The panel-level baseline_bar_return is only an equal-asset-weighted diagnostic. The returns are signed, so each asset baseline must be too: an earlier version subtracted the unsigned drift and scored a short-signed event at twice the drift (\(t \approx -400\) on a 0.2%-per-period trend). The null scale itself is published as metadata["leakage_null_scale"]. Per offset, se and t say how much of a mean is signal. Nothing here is a hypothesis test: p_value stays None (see the warning on the doc page for why).

What the leakage headline is (and is not). It is the mean of the absolute values of per-offset cross-event means of single-bar pre-event returns. It is not a pre-event CAR and not a cumulative run-up: compute_event_returns defines each k <= 0 offset as the one-bar return at that lag (prices[idx+k] / prices[idx+k-1] - 1), deliberately, so a single leaking bar stays localised instead of being smeared across every longer lag. Consequences to keep in mind when reading the number:

  • Its scale is one bar's return regardless of how far back the offsets reach, so it does not grow with the pre-event window and is not comparable to a post-event cumulative offset (those are cumulative from a common idx + 1 entry).
  • Averaging |mean_k| over a sparse offset grid (default -6, -3, -1) samples three isolated bars; it neither covers nor sums the bars between them. A clean score means "these sampled bars show no directional drift", not "the whole pre-event window is flat". Pass a dense negative offsets list to actually sweep the window.
  • Offsets with fewer than 5 events contribute no mean and are simply skipped in the average (they appear in per_offset with mean=None), so the headline can rest on fewer offsets than were requested.

Examples:

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

Offset conventions

Defaults: offsets = [-6, -3, -1, 1, 6, 12, 24]. Offset 0 is the event date itself and is excluded from the defaults; user-supplied offsets lists are honoured verbatim.

\(k\) Anchor Formula Sign-adjusted
\(k > 0\) (post-event) Cumulative from \(t+1\) entry price[t+1+k] / price[t+1] − 1 Yes — multiplied by sign(factor). The reading is signal quality.
\(k < 0\) (pre-event) Single bar at offset price[t+k] / price[t+k−1] − 1 Yes — multiplied by sign(factor), so directional leakage does not cancel across long and short events.
\(k = 0\) (corner) Single bar at event price[t] / price[t−1] − 1 Yes — multiplied by sign(factor). Pass with care; the event-day bar is usually contaminated by the announcement itself.

The pre/post asymmetry is intentional. Mixing the two conventions on a single chart (post-event cumulative + pre-event single-bar) is the default factrix presentation; downstream consumers should not re-cumulate the pre-event leg.

Three quantities, one curve

per_offset[k] reports an abnormal return, never a raw one, and the metric's headline value is a third quantity again. Keeping them apart is the difference between reading event alpha and reading the drift the asset had anyway.

Quantity What it is Where it appears
Raw signed mean return The formula in the table above, sign-adjusted, with nothing subtracted Not published; recover its mean as mean + benchmark
Abnormal (excess) return Each event's raw return minus its own asset's signed benchmark per_offset[k]["mean"], and every dispersion key beside it
Leakage score Mean of the absolute abnormal returns over the negative offsets the metric's value

Each event uses its own asset's unconditional return over the same horizon as the offset it is subtracted from: that asset's single-period mean for offsets at or below zero, and (1 + asset_mean)**k - 1 for offset \(k > 0\). The benchmark is also multiplied by sign(factor) before subtraction. Pooling asset drifts first would leave an event-composition residue at one-bar offsets and a Jensen gap after compounding; both would read as event alpha on a panel that merely trends.

Because the summary contains many events, per_offset[k]["benchmark"] is the event-weighted mean signed benchmark actually subtracted. It is the exact amount needed to recover the raw signed mean as mean + benchmark; no one scalar can undo the event-specific subtraction from the median or quantiles. benchmark_weighting names this contract. baseline_bar_return_by_asset publishes the underlying single-period mapping. baseline_bar_return remains a compact equal-asset-weighted panel diagnostic, and n_assets_in_baseline reports how many assets entered it; the pooled diagnostic is not used to price events.

Descriptive only — no p-value is produced

event_around_return runs no hypothesis test: p_value is None, and per_offset[k] carries {benchmark, mean, median, p25, p75, hit_rate, n} — the hit_rate is a raw fraction of positive signed returns, not a binomial test, and no offset carries a p.

That is deliberate rather than an omission, because the reported curve breaks the independence any such test would need, twice over. Adjacent post-event offsets are serially correlated within the same event — \(k = 6\) and \(k = 12\) share the \(t+1\) entry price and overlap on bars \([t+2, t+7]\) — so offsets are not separate trials. And events on one asset closer than the horizon share bars with each other; unlike the event significance tests (caar, bmp_z, corrado_rank, event_hit_rate), this metric applies no non-overlap sampling, because thinning the sample would only cost resolution on a curve nobody tests. Read the curve as a shape, and take significance from those metrics instead. See also the confounded-event note on within-asset event clustering.

Invalid prices withdraw the curve

event_around_return needs finite per-asset unconditional bar-return baselines. Any observed non-finite or non-positive price invalidates that baseline, so the metric returns value=NaN, an empty per_offset mapping, and WarningCode.METRIC_UNAVAILABLE with reason="invalid_price_data". A baseline that no valid price could form at all takes the same branch — empty per_offset, same warning — under reason="no_finite_baseline_returns" (no asset yielded a finite single-period return, e.g. a panel whose prices never fall on two adjacent periods); n_invalid_prices is 0 there, which is what separates the two. Raw paths may already have been formed before the baseline check, so n_events / n_obs still report the distinct events that computation actually used. Their per-offset computed/censored counts are deliberately withheld because the curve they would describe was discarded. This is the explicit short-circuit exception to the censor-audit contract below. Missing (null) observations remain allowed on ragged panels. If an asset can form an event path but has no adjacent-period returns from which to estimate its own baseline, its affected event-offset rows are censored as missing_asset_baseline rather than priced from another asset's drift. If no event row has an asset baseline, the metric short-circuits with reason="no_event_asset_baselines". Fix invalid observed prices rather than interpreting a contaminated finite hit rate.

Complete price paths and censoring audit

An evaluation panel produced by compute_forward_return has a shorter tail than its raw price input. Pass that raw panel as price_data so an event that remains eligible can still reach every available offset:

import factrix as fx
from factrix.metrics.event_horizon import event_around_return

raw = fx.datasets.make_event_panel(n_assets=50, n_dates=400, rng=7)
panel = fx.preprocess.compute_forward_return(raw, forward_periods=5)
out = fx.evaluate(
    panel,
    price_data=raw,
    metrics={"path": event_around_return(offsets=[-1, 6, 12, 24])},
    factor_cols=["factor"],
    strict=False,
)["factor"].metrics["path"]

offsets= are counts of periods on the grid the walk reads: the evaluation panel's own distinct dates without price_data, the price panel's grid with it. Passing price_data therefore re-bases every offset, and an offset chosen for a coarse evaluation grid reaches a different distance on the raw grid. See Offsets and windows are counted on the grid that supplies them; evaluate_horizons forwards its raw panel automatically, so its existing event_around_return results change accordingly.

Each per_offset[k] reports eligible, computed, censored, and a censor_reasons count mapping. Reasons distinguish an out-of-grid offset, missing entry/exit price, invalid denominator, missing asset, and missing price column. n remains the computed count used for the summary statistic.

Use cases

  • Right-size the event window


    Read the post-event mean curve over \(k = 1 \ldots K\) to locate the horizon where signed drift peaks before reverting. Drives the choice of EventConfig.event_window_post for downstream MFE/MAE and CAAR work.

  • Pre-event leakage check


    Inspect \(k < 0\) mean returns: a healthy signal has flat pre-event means. The headline event_around_return.value is \(\mathrm{mean}_{k < 0} |\mathrm{mean}_k|\), summarising the leakage score in a single number.

Choosing a function

Goal Function
Per-event, per-offset raw return table for custom plots / cuts compute_event_returns
Per-offset summary (mean / median / quartiles / hit-rate) with pre-event leakage headline event_around_return

Worked example — leakage score + per-offset curve

compute_event_returns → event_around_return on a synthetic event panel

import factrix as fx
from factrix.metrics.event_horizon import (
    compute_event_returns, event_around_return,
)

panel = fx.datasets.make_event_panel(
    n_assets=200, n_dates=500, event_rate=0.02,
    post_event_drift_bps=40.0, rng=2024,
)

rets = compute_event_returns(panel, offsets=[-6, -3, -1, 1, 6, 12, 24])
print(rets.head())
# ┌────────┬────────────┬──────────┬────────────────┐
# │ offset ┆ date       ┆ asset_id ┆ signed_return  │
# ├────────┼────────────┼──────────┼────────────────┤
# │   -6   ┆ 2024-01-04 ┆ A0001    ┆  0.0012        │
# │    1   ┆ 2024-01-04 ┆ A0001    ┆  0.0041        │
# │  ...   ┆ ...        ┆ ...      ┆ ...            │
# └────────┴────────────┴──────────┴────────────────┘

out = event_around_return(panel)
print(out.value)                              # mean |pre-event mean|
print(out.metadata["per_offset"][6]["mean"])  # post-event signed mean at k=6
# 0.0007   0.0094   (approximate)

See also