Skip to content

factrix.metrics.event_quality

Per-event quality descriptive statistics for event signals.

All metrics operate on the signed_car (return x sign(factor)) of individual events. They describe the quality and shape of per-event outcomes — distinct from significance testing (caar.py) and path analysis (mfe_mae.py).

Metrics

event_hit_rate — fraction of correct-direction events (binomial test) event_ic — density strength → return correlation (Spearman) signal_density — average time gap between events profit_factor — sum(gains) / sum(losses) event_skewness — skewness of signed_car distribution

Notes

Pipeline. Per-event scalar (hit / information coefficient (IC) / skew / density) computed on signed_car, then cross-event aggregation; binomial inference for hit rate, nonparametric for IC / skewness, descriptive elsewhere.

factrix.metrics.event_quality.event_hit_rate

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

Fraction of events with return in expected direction.

The static event floor (sample_threshold=SampleThreshold(min_events=MIN_EVENTS_HARD)) gates the hit-rate binomial test on the count of non-zero (event) observations.

Parameters:

Name Type Description Default
data DataFrame

Panel with event density and forward return.

required

Returns:

Type Description
MetricResult

MetricResult with value=hit rate, stat=z from binomial test.

Notes

hits = sum_i 1{signed_car_i > 0}, rate = hits / N. Two-sided binomial test against H0: p = 0.5: exact below _BINOMIAL_EXACT_CUTOFF, normal-approximation z above (z = (hits - N/2) / (sqrt(N) / 2)).

factrix publishes stat consistent with the test branch (raw hit count for the exact path, z for the normal path) so an exact-binomial p is never paired with a Gaussian z label.

return_col must be sign-symmetric around zero — signed_car = return_col * sign(factor_col), so an always-positive magnitude target (realised volatility, turnover) collapses sign(signed_car) to sign(factor_col) and silently turns the hit rate into a count of positive-factor events, with no error raised. Use :func:~factrix.metrics.ic.ic or :func:~factrix.metrics.monotonicity.monotonicity for those targets instead.

Examples:

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

factrix.metrics.event_quality.event_ic

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

Spearman correlation between factor value and realised forward return.

The static event floor (sample_threshold=SampleThreshold(min_events=MIN_EVENTS_HARD)) gates the rank correlation on the count of non-zero (event) observations.

Spearman correlation between |factor| and signed_car (return × sign(factor)), computed only on event rows.

Unlike standard information coefficient (IC) (full cross-section per date), this measures whether density magnitude predicts return magnitude among triggered events. Direction is already accounted for via sign().

Only meaningful when density values have magnitude variance (not all ±1). The metric returns a not-applicable result when variance is absent.

Parameters:

Name Type Description Default
data DataFrame

Panel with event density and forward return.

required

Returns:

Type Description
MetricResult

MetricResult with value=Spearman rho, stat=z from Fisher transform.

Notes

rho = Spearman(|factor|, signed_car) over event rows; Fisher z-transform z = atanh(rho) * sqrt(N - 3) against H0: rho = 0. Direction is already absorbed into signed_car so this isolates the magnitude-of-density → magnitude-of-return link.

factrix short-circuits "not_applicable_discrete_signal" when |factor| lacks variance (e.g. {0, ±1} events): event-IC is undefined without magnitude variation, distinct from "too few events".

Examples:

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

factrix.metrics.event_quality.profit_factor

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

Profit factor = sum(gains) / sum(|losses|) across events.

The static event floor (sample_threshold=SampleThreshold(min_events=MIN_EVENTS_HARD)) gates the ratio on the count of non-zero (event) observations.

Per-event aggregate — no strategy assumptions. A profit factor > 1 means gross gains exceed gross losses across all events.

Parameters:

Name Type Description Default
data DataFrame

Panel with event density and forward return.

required

Returns:

Type Description
MetricResult

MetricResult with value=profit_factor.

Notes

PF = sum(signed_car_i * 1{signed_car_i > 0}) / |sum(signed_car_i * 1{signed_car_i < 0})|. Descriptive only; no formal H0 (the ratio's sampling distribution lacks a clean closed-form null without distributional assumptions). PF > 1 means gross gains exceed gross losses across all events; the metric ignores per-event variance.

When gains are positive and losses are zero, factrix returns inf: the gross gain/loss ratio is unbounded, not zero. When both gains and losses are zero, the ratio is undefined and the metric returns NaN with metadata["profit_factor_status"].

Examples:

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

factrix.metrics.event_quality.event_skewness

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

Skewness of signed event return distribution.

The static event floor (sample_threshold=SampleThreshold(min_events=MIN_EVENTS_HARD)) gates the descriptive skewness on the count of non-zero (event) observations.

Positive skew = occasional large gains, frequent small losses (desirable for event strategies). Uses scipy's Fisher skewness (bias-corrected).

Also tests H₀: skewness = 0 via D'Agostino's skew test.

Parameters:

Name Type Description Default
data DataFrame

Panel with event density and forward return.

required

Returns:

Type Description
MetricResult

MetricResult with value=skewness, stat=z from D'Agostino test.

Notes

skew = m_3 / m_2^(3/2) (Fisher, bias-corrected via scipy.stats.skew(bias=False)); D'Agostino skew test gives z with H0: skew = 0 when n_events >= 20. Below 20 events, the test is not produced (stat=None) but the descriptive skewness is still returned.

factrix gates the inference branch at n_events >= 20 because the D'Agostino-Pearson normal approximation degrades sharply on small samples; reporting an unreliable z would invite false-positive significance.

Examples:

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

factrix.metrics.event_quality.signal_density

signal_density(data: DataFrame, *, factor_col: str = 'factor') -> MetricResult

Average bars per event (inverse frequency).

No event-axis sample_threshold is declared (sample_threshold=SampleThreshold()) because the in-body floor is a degeneracy guard (>= 2 events overall, then >= 2 per asset) needed to define the per-asset bars-per-event ratio, not the statistical MIN_EVENTS_HARD floor the other event metrics gate on; like other math-degeneracy guards it stays in the body.

Answers: "how frequently does this density fire?"

Computed per-asset as total_bars / n_events (inverse event frequency), then averaged across assets. This is not the mean of actual inter-event gaps: bars-per-event depends only on counts, so clustered events and evenly-spaced events yield the same value. See clustering_hhi for event-date concentration.

Low density (large gaps) means the density is selective; high density (small gaps) means the density fires often — capacity is higher but independence may be weaker.

Parameters:

Name Type Description Default
data DataFrame

Panel with date, asset_id, factor.

required

Returns:

Type Description
MetricResult

MetricResult with value = mean bars-per-event across assets.

Notes

Per asset i: bars_per_event_i = total_bars_i / n_events_i; the headline is the cross-asset mean of this ratio. This is an inverse-frequency measure, not the mean of inter-event gaps: clustered and evenly-spaced events at the same total count map to the same value.

factrix exposes clustering_hhi for event-date concentration; pair the two when independence assumptions matter.

Examples:

>>> import factrix as fx
>>> from factrix.metrics.event_quality import signal_density
>>> panel = fx.datasets.make_event_panel(n_assets=50, n_dates=400, seed=0)
>>> result = signal_density(panel)
>>> result.name == ""
True

Event-study contracts

These metrics use the sign-only form \(\text{signed\_car} = \text{forward\_return} \times \text{sign}(\text{factor})\) — distinct from caar's magnitude-weighted \(\text{forward\_return} \times \text{factor}\). See the abnormal-return table for the full per-metric contract and the confounded-event note for how the binomial / Spearman nulls behave under within-asset event clustering.

Use cases

  • Directional accuracy


    Fraction of events whose signed_car is positive, with a two-sided binomial test against \(H_0: p = 0.5\). Exact branch below the normal-approximation cutoff; \(z\) branch above. Headline statistic for "is the sign right more often than chance".

  • Magnitude → magnitude


    Among triggered events, does the signal's |factor| co-move with the realised signed_car? Spearman rank correlation with Fisher-\(z\) inference. Auto-skips on \(\{0, \pm 1\}\) inputs where |factor| has no variance.

  • Gain / loss ratio and shape


    profit_factor reports \(\sum\text{gains} / |\sum\text{losses}|\) as a descriptive gross ratio. If gains are positive and losses are zero, the ratio is unbounded (value = inf, profit_factor_status = "unbounded_no_losses"); if both are zero, the ratio is undefined (value = NaN). event_skewness reports the Fisher- corrected skewness of the signed_car distribution with a D'Agostino test when n_events >= 20. Useful for screening fat-right-tail vs symmetric event payoffs.

  • Firing frequency


    signal_density reports mean bars-per-event per asset (inverse frequency). Pair with clustering_hhi when independence assumptions matter — bars-per-event ignores temporal clustering.

Choosing a function

Goal Function
Directional-accuracy binomial test event_hit_rate
Magnitude-of-signal → magnitude-of-return rank correlation event_ic
Gross gain / loss ratio (descriptive only) profit_factor
Tail asymmetry of signed_car with D'Agostino skew test event_skewness
Inverse firing frequency (bars per event) signal_density

Worked example — directional accuracy + tail shape

event_hit_rate + event_skewness on a synthetic event panel

import factrix as fx
from factrix.metrics.event_quality import (
    event_hit_rate, event_skewness, profit_factor,
)
from factrix.preprocess import compute_forward_return

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

hit = event_hit_rate(panel)
print(hit.value, hit.stat, hit.p_value)
# 0.564  3.81  1.4e-04   (approximate)

sk = event_skewness(panel)
print(sk.value, sk.stat)
# 0.42  4.10   (approximate; stat is D'Agostino z when n_events >= 20)

pf = profit_factor(panel)
print(pf.value, pf.metadata["n_wins"], pf.metadata["n_losses"])
# 1.34  1131  864

See also