factrix.metrics.mfe_mae ¶
MFE/MAE — per-event price path excursion analysis.
Answers: "what does the price path look like after events?"
Requires bar-by-bar price data within the event window.
If price is not available, compute_mfe_mae returns an empty
DataFrame and mfe_mae returns a short-circuit MetricResult
(value=NaN, metadata["reason"]) — never None.
Metrics
mfe_mae — aggregate summary (MFE p50, MAE p25, ratio)
Notes
Pipeline. Per-event MFE / MAE excursion over a fixed window (per-event step), then cross-event quantile / ratio summary; descriptive (no formal H₀).
factrix.metrics.mfe_mae.mfe_mae ¶
mfe_mae(mfe_mae_df: DataFrame, *, expected_warnings: tuple[str, ...] = ()) -> MetricResult
Aggregate MFE/MAE statistics.
The static event floor (sample_threshold=SampleThreshold(min_events=MIN_EVENTS_HARD)) gates the summary on the per-event MFE/MAE count. Pre-flight reads the raw non-zero factor count as a loose upper bound.
Reports MFE/MAE ratio as the primary value — higher is better (favorable excursion exceeds adverse excursion).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mfe_mae_df
|
DataFrame
|
Output of |
required |
expected_warnings
|
tuple[str, ...]
|
Warning codes the caller declares; a declared code
is still recorded, the |
()
|
Returns:
| Type | Description |
|---|---|
MetricResult
|
MetricResult with value=MFE_p50/|MAE_p25| ratio. On insufficient |
MetricResult
|
data (empty input or fewer than |
MetricResult
|
short-circuit MetricResult ( |
MetricResult
|
set) so all metrics share a single return contract. |
MetricResult
|
|
MetricResult
|
|
MetricResult
|
|
Notes
Headline ratio = MFE_p50 / |MAE_p25| =
quantile(mfe, 0.50) / |quantile(mae, 0.25)|, reported with
mfe_mae_ratio_status: inf /
"unbounded_no_adverse_excursion" when the events never traded
against entry, NaN / "undefined_no_excursion" when neither
excursion exists (see :func:_excursion_ratio — the best possible
outcome must not share a score with the worst).
Z-normalised siblings mfe_z_p50 / mae_z_p25 /
mfe_mae_ratio_z are reported when mfe_z / mae_z are
present and pass the same minimum-events threshold.
factrix pairs the MFE median against the worst adverse quartile (not the median) because the asymmetric quantile pair captures risk-adjusted favourability: a strategy whose median favourable excursion exceeds the adverse excursion of its worst quartile of events is the practically useful regime.
MAE is stored as a signed non-positive excursion, so the worst
quartile is the 25th percentile — the most negative tail — and
|MAE_p25| >= |MAE_p50|. Earlier versions read
quantile(mae, 0.75), which on a non-positive series is the
mildest adverse quartile (nearest zero); pairing it with the
stated "worst quartile" intent both mislabelled the statistic and
inflated ratio by dividing by the smallest adverse magnitude
instead of the largest. The alternative convention — storing MAE
as a positive magnitude, where p75 would be correct — was not
adopted: the signed form keeps mfe/mae on the same signed
return axis as every other event primitive and lets
mfe + |mae| read as total excursion range.
compute_mfe_mae floors the excursions at the entry price
(mfe >= 0, mae <= 0, the Sweeney/Tharp definition), so the
sign of mae is guaranteed here rather than assumed. Entry is
the event bar's own close — see compute_mfe_mae for why this
primitive enters one period earlier than the return-profile ones.
The excursion and estimation windows are counted on the panel's
period grid (see compute_mfe_mae). A ragged panel records
WarningCode.RAGGED_PERIOD_GRID: the windows still span the
requested number of periods, but an asset missing periods carries
fewer observations inside them than the rest.
Examples:
Chain from :func:compute_mfe_mae output:
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics.mfe_mae import compute_mfe_mae, mfe_mae
>>> panel = compute_forward_return(
... fx.datasets.make_event_panel(n_assets=50, n_dates=400, rng=0),
... forward_periods=5,
... )
>>> per_event = compute_mfe_mae(panel, window=20)
>>> result = mfe_mae(per_event)
>>> result.name == ""
True
Use cases¶
-
Peak favourable vs adverse excursion
For each event, find the peak gain (MFE) and peak loss (MAE) over the post-event window, plus periods-to-peak. Descriptive of the shape of the post-event price path, not just its endpoint.
-
Risk-adjusted favourability
Headline ratio \(\mathrm{MFE}_{p50} / |\mathrm{MAE}_{p25}|\) pairs the median favourable excursion against the worst adverse quartile (MAE is a signed non-positive excursion, so the worst quartile is the 25th percentile) — captures whether typical upside exceeds worst-quartile downside.
-
Cross-horizon / cross-regime comparison
Z-scored variants
mfe_z/mae_z(divided by \(\hat\sigma \sqrt{W}\)) absorb the \(\sqrt{W \cdot \sigma^2}\) horizon scaling of order statistics — the apples-to-apples quantity for comparing event setups across windows or volatility regimes.
Windows are counted on the panel's period grid¶
window and estimation_window are counts of periods on the grid of the
panel the walk reads, not counts of an asset's rows. Without price_data that
is the evaluation panel's own distinct-date grid; with price_data it is the
price panel's, so the same window= spans a different amount of the sample
whenever the two grids differ. Measured on a panel whose evaluation grid keeps
every tenth period of the price grid, window=3 gives a median MFE of 0.1272
read on the evaluation grid and 0.0120 read on the price grid. Both are
correct for the grid they were counted on; scale window= to the grid you are
passing. Each event asset is laid
onto the full grid before the excursion is walked, so on a ragged panel — an
asset missing periods the other names have — the excursion spans exactly
window grid periods and the missing periods count as missing observations
inside it, instead of the walk stepping over the hole and reaching further
out. A dense panel is unaffected. bars_to_mfe / bars_to_mae are offsets in
grid periods.
A ragged panel records WarningCode.ragged_period_grid on the mfe_mae
result: the windows still span the requested number of periods, but a name
missing periods carries fewer observations inside them than the rest, so its
est_sigma (and the z-scored siblings) rest on a smaller sample. Declare it
with evaluate(..., expected_warnings=("ragged_period_grid",)) to silence the
echo — the code is still recorded — or reindex the panel onto a common grid
before calling.
Complete price paths and censored events¶
When the evaluation panel came from compute_forward_return, pass the raw
panel as evaluate(..., price_data=raw) (or as price_data= on the direct
compute_mfe_mae call). Event eligibility stays on the evaluation panel;
excursion and estimation windows walk the full price grid, and are counted in
periods of that grid.
evaluate_horizons forwards its raw panel automatically, so an existing
evaluate_horizons run that includes mfe_mae changes value: the excursion
reaches periods it could not previously see, and window= is re-based onto the
raw grid. See
Offsets and windows are counted on the grid that supplies them.
A panel that carries no event at all short-circuits with
reason="no_events"; a panel with events but no price column reports
reason="no_price_data" alongside one missing_price_column censored row per
event. The two conditions never share a reason.
compute_mfe_mae now retains one row per eligible event. Computable rows carry
path_status="computed"; rows with no usable path carry null excursions,
path_status="censored", and a censor_reason. The aggregate metadata reports
n_events_eligible, n_events_computed, n_events_censored, and reason
counts. To reproduce the pre-0.33 valid-only table, filter explicitly:
This row-retention change is a pre-1.0 MINOR-level contract change; pin 0.32.x if downstream code assumes every producer row has finite excursions.
Choosing a function¶
| Goal | Function |
|---|---|
| Per-event MFE / MAE / bars-to-peak table for downstream cuts | compute_mfe_mae |
| Aggregate distribution summary (quantiles, ratio, z-scored siblings) | mfe_mae |
Worked example — per-event excursion then summary¶
compute_mfe_mae → mfe_mae on a synthetic event panel
import factrix as fx
from factrix.metrics.mfe_mae import compute_mfe_mae, mfe_mae
panel = fx.datasets.make_event_panel(
n_assets=200, n_dates=500, event_rate=0.02,
post_event_drift_bps=40.0, rng=2024,
)
per_event = compute_mfe_mae(panel, window=20, estimation_window=60)
print(per_event.head())
# ┌────────────┬──────────┬────────┬─────────┬────────┬────────┐
# │ date ┆ asset_id ┆ mfe ┆ mae ┆ mfe_z ┆ mae_z │
# ├────────────┼──────────┼────────┼─────────┼────────┼────────┤
# │ 2024-01-04 ┆ A0001 ┆ 0.031 ┆ -0.018 ┆ 0.74 ┆ -0.43 │
# │ ... ┆ ... ┆ ... ┆ ... ┆ ... ┆ ... │
# └────────────┴──────────┴────────┴─────────┴────────┴────────┘
out = mfe_mae(per_event)
print(out.value,
out.metadata["mfe_p50"], out.metadata["mae_p25"],
out.metadata.get("mfe_mae_ratio_z"))
# 1.27 0.024 -0.019 1.31 (approximate)
See also¶
-
event_around_return
Per-offset mean curve on the same post-event window — read for drift shape; MFE/MAE read for excursion magnitude.
-
caar/bmp_z
Inferential CAAR / BMP \(z\) on the endpoint of the same event window.
-
by_slice
Per-slice excursion summaries (regime / universe / sector).
-
Metric applicability reference
Event-window / estimation-window contracts and price-data requirements.
-
Individual × Sparse landing
Adjacent event-study metrics in the same cell.