Skip to content

Helpers for shaping a raw panel before evaluate. The canonical entry point, compute_forward_return, attaches a forward_return column to a raw (date, asset_id, price) panel — the output (date, asset_id, factor, forward_return) panel is the canonical input to evaluate.

The surrounding helpers cover the rest of the documented preprocessing pipeline and are independently usable on a canonical panel: return cleaning (winsorize_forward_return, compute_abnormal_return), factor normalization (mad_winsorize, cross_sectional_zscore), and orthogonalization against base factors (orthogonalize_factor).

Column adaptation

Use adapt when the input is already a long panel but carries vendor- or project-specific column names such as trade_date, ticker, or close_adj. It renames those columns to factrix's canonical date, asset_id, price, and optional OHLCV names before compute_forward_return. It does not reshape data, construct factors, or compute returns.

adapt preserves Polars eager/lazy inputs, converts pandas input to Polars, and leaves unrelated columns such as factors, industries, market caps, or regime labels unchanged. Optional fill_forward is a raw-OHLCV convenience: it maps non-finite numeric values to null and forward-fills per asset before forward returns are computed.

factrix.adapt.adapt

adapt(data: AdaptInput, *, date: str = 'date', asset_id: str = 'asset_id', price: str = 'close', open: str | None = None, high: str | None = None, low: str | None = None, volume: str | None = None, fill_forward: bool = False) -> DataFrame | LazyFrame

Rename user columns to factrix canonical names.

Type-preserving for polars inputs: a pl.LazyFrame stays lazy (rename / cast / fill happen inside the lazy chain, no implicit .collect()), a pl.DataFrame stays eager. pd.DataFrame is converted to pl.DataFrame (pandas has no lazy equivalent). Only renames columns that differ from the canonical name; all other columns pass through unchanged.

Parameters:

Name Type Description Default
data AdaptInput

Input frame — pl.DataFrame, pl.LazyFrame, or pd.DataFrame.

required
date str

User's date column name.

'date'
asset_id str

User's asset identifier column name.

'asset_id'
price str

User's price column name.

'close'
open str | None

User's open column name. If set, renamed to open. Required by factors.technical.generate_overnight_return.

None
high str | None

User's high column name. Renamed to high. Required by generate_52w_high_ratio / generate_intraday_range.

None
low str | None

User's low column name. Renamed to low. Required by generate_intraday_range.

None
volume str | None

User's volume column name. Renamed to volume. Required by generate_amihud / generate_volume_price_trend.

None
fill_forward bool

If True, map every non-finite value (NaN and ±inf) to null, then forward-fill all numeric columns per asset. Useful for raw OHLCV data that may contain sporadic missing or non-finite values. Mapping ±inf is deliberate: inf is not null, so it would otherwise survive the tail drop in compute_forward_return and leak into return math.

False

Returns:

Type Description
DataFrame | LazyFrame

Same polars type as input (pl.DataFramepl.DataFrame,

DataFrame | LazyFrame

pl.LazyFramepl.LazyFrame) with canonical column

DataFrame | LazyFrame

names. pd.DataFrame input returns pl.DataFrame.

Raises:

Type Description
TypeError

If data is none of pl.DataFrame, pl.LazyFrame, pd.DataFrame.

ValueError

If any specified source column does not exist.

Forward return

compute_forward_return accepts forward_periods as a positive int row horizon. 0, negative values, floats, strings, and bool values raise UserInputError. The function shifts by row count within each asset_id, computes the per-period normalized forward_return, then drops rows whose computed return is not finite (null, NaN, +inf, or -inf). If the horizon is too long for the panel, or price data leaves no finite forward returns after filtering, the function raises UserInputError instead of returning an empty panel.

winsorize_forward_return clips forward_return by per-date quantiles. Its bounds must satisfy 0 <= lower <= upper <= 1; invalid ordering, out-of-range values, non-numeric values, and bool bounds raise UserInputError.

factrix.preprocess.compute_forward_return

compute_forward_return(data: DataFrame, forward_periods: int = 5, *, overwrite: bool = False) -> DataFrame

Step 1: Compute per-period forward return per asset.

forward_return = (price[t+1+forward_periods] / price[t+1] - 1) / forward_periods

Entry at t+1 (next bar after density), exit at t+1+forward_periods.

WHY t+1 entry: The density at t is computed using data up to and including price[t]. Using price[t] as both density input and entry price assumes you can trade at the same price used to generate the density — unrealistic in practice. Entry at t+1 enforces a strict causal boundary: density → wait → trade → measure.

This also keeps the return window cleanly separated from the estimation window in event studies (BMP test), eliminating the need for ad-hoc shift corrections.

Dividing by forward_periods normalizes returns to a per-period basis, making different forward_periods directly comparable on a scale basis (see Notes for the scope boundary).

Parameters:

Name Type Description Default
data DataFrame

Must contain date, asset_id, price. Must already be sorted with regular spacing per asset on the time axis; this function shifts by row count and does not inspect date.

required
forward_periods int

Holding horizon in rows of the time axis, not calendar time (default 5). On a daily panel this is 5 trading days; on a weekly panel, 5 weeks; on 1-min bars, 5 minutes. Frequency is the caller's responsibility.

5
overwrite bool

Allow recomputation when data already carries a forward_return column. False (default) raises rather than silently overwrite — the function is not idempotent: the previous call already dropped the last forward_periods + 1 rows per asset, so recomputing on the result drops a further tail. To change the horizon, recompute from the original (pre-forward-return) panel; overwrite=True recomputes in place anyway, accepting the additional truncation.

False

Raises:

Type Description
UserInputError

forward_periods is not a positive int; data already has a forward_return column and overwrite is False; or the row horizon / price data leaves no finite forward returns after filtering.

Returns:

Type Description
DataFrame

Input DataFrame with forward_return column appended and the

DataFrame

overlap horizon forward_periods stamped on as a reserved column —

DataFrame

the single source of truth factrix.evaluate reads (it strips the

DataFrame

column before dispatch, so it never reaches a metric or to_frame).

DataFrame

Rows where forward return is not finite (tail nulls, NaN, +inf, -inf)

DataFrame

are dropped.

Notes

The / forward_periods per-period normalization is a scale choice with three caveats the caller should know:

  1. Arithmetic, not summed-log-return. This is the arithmetic per-period mean of a simple return, not the academic-standard direct long-horizon regression of summed log returns on the predictor (the latter is linear-additive across horizons by construction).
  2. Compounding bias. Compounding at the arithmetic mean is an upward-biased estimator of cumulative wealth; the bias grows with forward_periods and per-bar return variance. Negligible for rank-based information coefficient (IC); not negligible for signed-return mean and t-tests at large forward_periods.
  3. Scale, not inference. / forward_periods aligns the scale across horizons — it does not address the inference problem. Overlap is handled by heteroskedasticity-and-autocorrelation-consistent (HAC) (see :class:factrix.inference.NeweyWest); across-horizon selection is handled by a declared multiple-testing family in :func:factrix.multi_factor.bhy (BHY controls FDR, not FWER). The three concerns (scale, overlap, cross-horizon selection) are addressed at separate layers; overlap and across-horizon dependence share a common source in the persistent regressor, but each requires its own tool.
References
  • Fama & French (1988). "Dividend Yields and Expected Stock Returns." Journal of Financial Economics, 22(1), 3–25. Direct summed-log-return long-horizon regression — the academic-standard alternative to factrix's ÷N.
  • Jacquier, Kane & Marcus (2003). "Geometric or Arithmetic Mean: A Reconsideration." Financial Analysts Journal, 59(6), 46–53. Compounding bias of the arithmetic mean and the unbiased horizon-weighted blend.
  • Boudoukh, Richardson & Whitelaw (2008). "The Myth of Long-Horizon Predictability." Review of Financial Studies, 21(4), 1577–1605. Documents that across-horizon regression statistics share information through the persistent regressor — separate from any per-period scaling choice, and the reason inference across horizons is not addressed by normalization.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> raw = fx.datasets.make_cs_panel(n_assets=20, n_dates=120)
>>> panel = compute_forward_return(raw, forward_periods=5)
>>> "forward_return" in panel.columns
True
>>> panel["forward_return"].null_count() == 0
True

The output panel is the canonical input to fx.evaluate:

>>> from factrix.metrics import ic
>>> results = fx.evaluate(
...     panel, metrics={"ic": ic()}, factor_cols=["factor"], forward_periods=5
... )
>>> isinstance(results, dict) and "factor" in results
True

factrix.preprocess.winsorize_forward_return

winsorize_forward_return(data: DataFrame, lower: float = 0.01, upper: float = 0.99) -> DataFrame

Step 2: Per-date percentile clip on forward returns.

Parameters:

Name Type Description Default
lower float

Lower quantile bound (default 0.01 = 1st percentile). Must satisfy 0 <= lower <= upper <= 1.

0.01
upper float

Upper quantile bound (default 0.99 = 99th percentile). Must satisfy 0 <= lower <= upper <= 1. Set to (0.0, 1.0) to disable.

0.99

Raises:

Type Description
UserInputError

lower / upper are not numeric quantile bounds satisfying 0 <= lower <= upper <= 1.

Returns:

Type Description
DataFrame

DataFrame with forward_return clipped in-place.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import (
...     compute_forward_return,
...     winsorize_forward_return,
... )
>>> raw = fx.datasets.make_cs_panel(n_assets=20, n_dates=120)
>>> panel = compute_forward_return(raw, forward_periods=5)
>>> clipped = winsorize_forward_return(panel, lower=0.01, upper=0.99)
>>> clipped.height == panel.height
True
>>> clipped["forward_return"].max() <= panel["forward_return"].max()
True

factrix.preprocess.compute_abnormal_return

compute_abnormal_return(data: DataFrame) -> DataFrame

Step 3: Cross-sectional abnormal return.

abnormal_return = forward_return - mean(forward_return) per date

Returns:

Type Description
DataFrame

DataFrame with abnormal_return column appended.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import (
...     compute_abnormal_return,
...     compute_forward_return,
... )
>>> raw = fx.datasets.make_cs_panel(n_assets=20, n_dates=120)
>>> panel = compute_forward_return(raw, forward_periods=5)
>>> adjusted = compute_abnormal_return(panel)
>>> "abnormal_return" in adjusted.columns
True

Factor normalization

factrix.preprocess.mad_winsorize

mad_winsorize(data: DataFrame, factor_col: str = 'factor', n_mad: float = 3.0) -> DataFrame

Step 4: Per-date MAD-based winsorization on factor values.

Clips factor values to [median ± n_mad × 1.4826 × MAD] within each cross-section.

Parameters:

Name Type Description Default
n_mad float

Number of MAD units for clipping (default 3.0). Set to 0 to disable.

3.0

Returns:

Type Description
DataFrame

DataFrame with factor_col clipped in-place.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import mad_winsorize
>>> raw = fx.datasets.make_cs_panel(n_assets=20, n_dates=120)
>>> clipped = mad_winsorize(raw, n_mad=3.0)
>>> clipped.columns == raw.columns
True
>>> clipped.height == raw.height
True

factrix.preprocess.cross_sectional_zscore

cross_sectional_zscore(data: DataFrame, factor_col: str = 'factor') -> DataFrame

Step 5: MAD-robust z-score within each cross-section (date).

z = (x - median(x)) / (1.4826 × MAD(x))

Returns:

Type Description
DataFrame

DataFrame with factor_zscore column appended.

Examples:

>>> import factrix as fx
>>> from factrix.preprocess import cross_sectional_zscore
>>> raw = fx.datasets.make_cs_panel(n_assets=20, n_dates=120)
>>> standardized = cross_sectional_zscore(raw)
>>> "factor_zscore" in standardized.columns
True

Orthogonalization

factrix.preprocess.orthogonalize_factor

orthogonalize_factor(factor_df: DataFrame, base_factors: DataFrame, factor_col: str = 'factor', base_cols: list[str] | None = None) -> OrthogonalizeResult

Orthogonalize factor against base factors via per-date ordinary least squares (OLS).

Parameters:

Name Type Description Default
factor_df DataFrame

Panel with date, asset_id, {factor_col}. factor_col should already be z-scored (Step 5 output).

required
base_factors DataFrame

Panel with date, asset_id and base factor columns. Industry dummies should be pre-encoded as 0/1 columns.

required
factor_col str

Column name of the factor to orthogonalize.

'factor'
base_cols list[str] | None

List of column names in base_factors to regress on. If None, uses all columns except date and asset_id.

None

Returns:

Type Description
OrthogonalizeResult

OrthogonalizeResult with: data (factor_df with factor_col

OrthogonalizeResult

replaced by the residual and factor_pre_ortho preserving the

OrthogonalizeResult

original value), mean_betas (average beta per base factor

OrthogonalizeResult

across dates), and mean_r_squared (average R² across dates).

Examples:

>>> import factrix as fx
>>> import polars as pl
>>> from factrix.preprocess import (
...     cross_sectional_zscore,
...     orthogonalize_factor,
... )
>>> raw = fx.datasets.make_cs_panel(n_assets=20, n_dates=120)
>>> factor_df = cross_sectional_zscore(raw).select(
...     "date", "asset_id", pl.col("factor_zscore").alias("factor")
... )
>>> base = raw.with_columns(
...     pl.col("price").rank().over("date").alias("size")
... ).select("date", "asset_id", "size")
>>> result = orthogonalize_factor(factor_df, base, base_cols=["size"])
>>> "factor_pre_ortho" in result.data.columns
True
>>> isinstance(result.mean_r_squared, float)
True

factrix.preprocess.OrthogonalizeResult dataclass

OrthogonalizeResult(data: DataFrame, mean_betas: dict[str, float] = dict(), mean_r_squared: float = 0.0, n_dates: int = 0, coverage: float = 0.0, n_base: int = 0)

Result of factor orthogonalization with attribution info.