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 — |
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 |
None
|
high
|
str | None
|
User's high column name. Renamed to |
None
|
low
|
str | None
|
User's low column name. Renamed to |
None
|
volume
|
str | None
|
User's volume column name. Renamed to |
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 |
False
|
Returns:
| Type | Description |
|---|---|
DataFrame | LazyFrame
|
Same polars type as input ( |
DataFrame | LazyFrame
|
|
DataFrame | LazyFrame
|
names. |
Raises:
| Type | Description |
|---|---|
TypeError
|
If data is none of |
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 |
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 |
False
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Input DataFrame with |
DataFrame
|
overlap horizon |
DataFrame
|
the single source of truth |
DataFrame
|
column before dispatch, so it never reaches a metric or |
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:
- 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).
- Compounding bias. Compounding at the arithmetic mean
is an upward-biased estimator of cumulative wealth; the
bias grows with
forward_periodsand per-bar return variance. Negligible for rank-based information coefficient (IC); not negligible for signed-return mean and t-tests at largeforward_periods. - Scale, not inference.
/ forward_periodsaligns 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:
factrix.preprocess.winsorize_forward_return ¶
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.01
|
upper
|
float
|
Upper quantile bound (default 0.99 = 99th percentile).
Must satisfy |
0.99
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
DataFrame with |
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 ¶
Step 3: Cross-sectional abnormal return.
abnormal_return = forward_return - mean(forward_return) per date
Returns:
| Type | Description |
|---|---|
DataFrame
|
DataFrame with |
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 ¶
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 |
Examples:
factrix.preprocess.cross_sectional_zscore ¶
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 |
Examples:
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 |
required |
base_factors
|
DataFrame
|
Panel with |
required |
factor_col
|
str
|
Column name of the factor to orthogonalize. |
'factor'
|
base_cols
|
list[str] | None
|
List of column names in |
None
|
Returns:
| Type | Description |
|---|---|
OrthogonalizeResult
|
OrthogonalizeResult with: |
OrthogonalizeResult
|
replaced by the residual and |
OrthogonalizeResult
|
original value), |
OrthogonalizeResult
|
across dates), and |
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.