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. It never fills observations. compute_forward_return
maps non-finite numeric values to null at its ingestion gate and drops rows
whose required entry or exit price is unavailable. A missing price must not be
forward-filled: doing so invents a tradable level and changes the effective
holding period. Repair genuine source errors upstream under an explicit column
and staleness policy.
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) -> DataFrame | LazyFrame
Rename user columns to factrix canonical names.
Type-preserving for polars inputs: a pl.LazyFrame stays lazy
(rename / cast 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. Renamed to |
'date'
|
asset_id
|
str
|
User's asset identifier column name. Renamed to
|
'asset_id'
|
price
|
str
|
User's price column name. Renamed to |
'close'
|
open
|
str | None
|
User's opening-price column name. Renamed to |
None
|
high
|
str | None
|
User's period-high price column name. Renamed to |
None
|
low
|
str | None
|
User's period-low price column name. Renamed to |
None
|
volume
|
str | None
|
User's traded-volume column name. Renamed to |
None
|
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, one source is assigned to multiple canonical names, or a rename would overwrite an existing canonical column. |
The panel contract¶
Every public entry point — evaluate, evaluate_horizons, inspect_data,
compute_forward_return — puts its input through one structural gate before
anything else happens. Three things it enforces, each of which was previously
left to individual producers and therefore missed by whichever path forgot it:
datemust beDateorDatetime. Only column names were validated, so aStringdate flowed throughsort/shift/over/group_byas text and was ordered lexicographically. With ISO-8601 strings that accidentally works, which is what makes it dangerous — it passes every test and every demo. WithMM/DD/YYYYthe panel is silently reordered and every forward return is paired with the wrong neighbour. Parse first, e.g.pl.col("date").str.to_datetime("%m/%d/%Y").(date, asset_id)must be unique. Entry and exit prices are looked up by(asset_id, period_index). A duplicated key makes that lookup ambiguous and can fan out the joins, so factrix rejects it instead of guessing which price represents the asset-period.- NaN and ±Inf become
null. Applied to inputs, not to computed outputs. This makes the whole class structurally unrepresentable downstream rather than patching instances of it: polars ranks NaN as larger than every real value,pl.corr(method="spearman")silently ranks it, and a+Infdenominator makesfinite / infevaluate to0.0— a finite fabricated −100% return that an output-sideis_finite()filter cannot catch.
The first two raise UserInputError; the third is a silent,
deliberate normalization. _finite_expr remains in place inside the producers
as defence in depth.
Forward return¶
compute_forward_return accepts forward_periods as a positive int.
0, negative values, floats, strings, and bool values raise
UserInputError.
The horizon is measured on the panel's own period grid — the distinct
sorted date values present in the panel, indexed 0, 1, 2, … Each asset's
forward return pairs its row at period index i + 1 with its row at
i + 1 + forward_periods. This used to be a positional shift within each
asset, which equals a time horizon only on a complete per-asset panel: with
one asset missing 20 periods mid-sample, a "5-period" return silently spanned
25 real periods, contaminating both the return and the overlap stamped in
_overlap_periods that every downstream HAC inference reads. Suspensions,
halts, delist-relist and staggered entry are ordinary in regional equity data,
and sparse event panels are ragged by construction.
A ragged grid raises ragged_period_grid. The pairing is correct for every
asset either way; the point of the warning is that an asset with a gap has no
observation to pair at the exit period and so contributes fewer rows than a
complete one. Reindex onto a common grid if the horizons must be comparable
across names.
Rows whose computed return is not finite are dropped. 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.
The returned rows are the forward-return sample, not a complete price
history. Event offsets and MFE/MAE windows may need the dropped tail even when
their event row remains in the sample. Preserve the raw panel and pass it as
evaluate(panel, price_data=raw, ...); see
Full price data for event paths.
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.
Evaluating on a coarser grid¶
A factor is often observed on a finer grid than it is traded on: the price
grid supplies the return horizon, while the evaluation happens on a
caller-chosen rebalance grid. Two separate quantities then live on the panel,
and compute_forward_return stamps both:
| Stamp | Name on EvaluationResult |
Meaning |
|---|---|---|
_forward_periods |
forward_periods |
The return horizon — the forward_periods the return was built with, in periods of the price grid. It names the hypothesis: the (factor, forward_periods, *params) identity that compare / bhy use, and the axis expand_over may name. Never changes with the evaluation grid. |
_overlap_periods |
overlap_periods |
The overlap of adjacent observations on the evaluation grid — what inference consumes: the HAC bandwidth and effective degrees of freedom, the non-overlapping stride, and the stride-scaled sample floors. Bookkeeping only; it does not join the identity, because the same horizon evaluated on two grids is one hypothesis estimated twice. |
On the full grid the two coincide. That is why sub-sampling a panel by
hand after compute_forward_return goes wrong: the overlap_periods stamp
still says "horizon", so a 60-period return evaluated every 60 periods is
treated as 60-fold overlapping and the stride-scaled floor (50 × 60
periods for ic()) rejects a healthy 24-period panel with
insufficient_ic_periods. The short-circuit message says so and points here.
Pass the evaluation grid as dates= instead. The return is still computed on
the full grid at forward_periods, only rows on those dates are kept, and
the overlap is derived on the full period index:
The row at period i covers (i + 1, i + 1 + h], so two kept rows overlap
exactly when they are fewer than h periods apart. Stride 60 at h = 60
gives 1; stride 20 gives 3; the full grid gives h.
import factrix as fx
from factrix.metrics import ic, quantile_spread
from factrix.preprocess import compute_forward_return
raw = fx.datasets.make_cs_panel(n_assets=30, n_dates=1500) # fine grid
grid = raw["date"].unique().sort()
rebalance_dates = grid.gather_every(60) # every 60th period
panel = compute_forward_return(raw, forward_periods=60, dates=rebalance_dates)
result = fx.evaluate(
panel, metrics={"ic": ic(), "spread": quantile_spread()}, factor_cols=["factor"]
)["factor"]
result.forward_periods # 60 — the hypothesis
result.overlap_periods # 1 — derived: adjacent evaluations share no future period
result.metrics["ic"].n_obs # 24, the sampled periods (no stride applied)
result.metrics["spread"].n_obs # 24
Three details of the derivation are deliberate:
- Every date must be on the grid; nothing is snapped. Snapping backward
would read a factor observed after the entry period (look-ahead); snapping
forward would silently move the entry period. A value off the grid raises
UserInputError. ADatevalue names aDatetimeperiod unambiguously and aDatetimein another time unit is the same instant; any other dtype mismatch is rejected rather than parsed. - The maximum, not a typical count. The evaluation grid may be spaced
unevenly on the period grid. Under-stating the overlap by one leaves
dependence the non-overlapping stride does not remove and the stride
t-test over-rejects (measured 8.3% at a nominal 5% on the test grid in
tests/stats/test_uneven_grid_overlap_size.py, 21.7% with no stride); over-stating it by one only thins the strided sample (size intact, some power lost). The Newey-West path is insensitive either way (5.5%). - Derived inside
compute_forward_return, on the full index. Theis_finitefilter removes any period on which every asset's return is non-finite; an index rebuilt from the returned panel's own dates would compress those gaps and under-count the overlap — the dangerous direction.
The unit of forward_return does not change with the grid: it is the return
per period of the horizon (the / forward_periods normalisation), not
per evaluation period. Every p-value on the affected paths is
scale-invariant, and a per-evaluation-period rate is not defined on an
unevenly spaced grid.
evaluate_horizons(..., dates=) forwards the same grid to every horizon of
a sweep; each result carries its own derived overlap_periods while the
(factor, forward_periods) identity is unaffected.
factrix.preprocess.compute_forward_return ¶
compute_forward_return(data: DataFrame, forward_periods: int = 5, *, dates: Series | Iterable[object] | None = None, 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 periods of the panel's own
grid — the distinct sorted |
5
|
dates
|
Series | Iterable[object] | None
|
Optional evaluation grid — the dates to keep, as a polars
|
None
|
overwrite
|
bool
|
Allow recomputation when |
False
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
Input DataFrame with |
DataFrame
|
reserved stamp columns — |
DataFrame
|
the hypothesis) and |
DataFrame
|
observations on the evaluation grid, the quantity inference consumes; |
DataFrame
|
equal to the horizon unless |
DataFrame
|
single source of truth |
DataFrame
|
before dispatch, so they never reach a metric or |
DataFrame
|
where forward return is not finite (tail nulls, NaN, +inf, -inf) are |
DataFrame
|
dropped; with |
DataFrame
|
too. |
Notes
The horizon is measured on the panel's period grid. The distinct
sorted date values in the panel are indexed 0, 1, 2, ..., and each
asset's forward return pairs its row at period index i + 1 with its
row at i + 1 + forward_periods. This used to be a positional
shift within each asset, which equals a time horizon only on a
complete per-asset panel: with asset A missing 20 periods mid-sample, a
"5-period" return silently spanned 25 real periods, contaminating both
the return and the overlap horizon stamped in _forward_periods that
every downstream HAC inference reads. Suspensions, halts,
delist-relist and staggered entry are ordinary in regional equity data,
and sparse event panels are ragged by construction.
A ragged grid (an asset missing periods that others have) raises
WarningCode.RAGGED_PERIOD_GRID: the pairing is now correct for
every asset, but an asset with a gap simply has no observation at
i + 1 + h, so it contributes fewer rows than a complete one.
Reindex onto a common grid if the horizons must be comparable across
names.
Evaluating on a coarser grid. A factor is often observed on a
finer grid than it is traded on — the price grid supplies the return
horizon, while the evaluation happens on a caller-chosen rebalance
grid. Two separate quantities then matter. forward_periods is the
economic horizon and names the hypothesis; it is the same on any
grid. overlap_periods is how many adjacent evaluation
observations share future periods, which is what the HAC bandwidth
and effective degrees of freedom, the non-overlapping stride and the
stride-scaled sample floors consume. On the full grid the two
coincide, so a panel sub-sampled by hand after this function keeps
a stale overlap_periods = forward_periods stamp: a 60-period
return evaluated every 60 periods is treated as 60-fold overlapping
and the stride-scaled floor rejects it outright. Pass the evaluation
grid as dates instead and the overlap is derived here, on the
full grid, as::
overlap_periods = 1 + max_i #{ j in dates : 0 < idx(j) - idx(i) < h }
with idx the period index on the panel's full distinct-date
grid. The row at i covers (i + 1, i + 1 + h], so two kept
rows overlap exactly when they are fewer than h periods apart.
Stride 60 at h = 60 gives 1; stride 20 gives 3; the full grid
gives h. The maximum, not a typical count, is used because the
evaluation grid may be spaced unevenly on the period grid:
under-stating the overlap by one leaves dependence the stride does
not remove and the stride t-test over-rejects (12-13% at a nominal
5% in simulation), while over-stating it by one only thins the
strided sample. The derivation must run here rather than on the
returned panel: the is_finite filter below removes any period on
which every asset's return is non-finite, so an index rebuilt from
the output's own dates would compress the gaps and under-count.
The unit of forward_return does not change with the grid: it is
the return per period of the horizon (the / forward_periods
below), not per evaluation period. Every p-value on the affected
paths is scale-invariant, and a per-evaluation-period rate is not
defined on an unevenly spaced grid.
Non-finite prices are blanked before the division, not after. The
filter used to be applied to the quotient: with a +Inf
denominator, finite / inf is 0.0, so the result was a perfectly
finite fabricated -100% return that sailed straight through
is_finite(). (An inf numerator gives inf and was correctly
dropped — the leak was asymmetric, and so easy to miss.)
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:
>>> 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
A 60-period return evaluated every 60 periods: the horizon stays 60 (the hypothesis), while the derived overlap is 1, so inference treats the sampled observations as non-overlapping:
>>> raw = fx.datasets.make_cs_panel(n_assets=30, n_dates=1500)
>>> grid = raw["date"].unique().sort()
>>> sampled = compute_forward_return(
... raw, forward_periods=60, dates=grid.gather_every(60)
... )
>>> sampled["date"].n_unique()
24
>>> result = fx.evaluate(sampled, metrics={"ic": ic()}, factor_cols=["factor"])
>>> (result["factor"].forward_periods, result["factor"].overlap_periods)
(60, 1)
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 |
Notes
Quantile interpolation. The per-date bounds are computed with
interpolation="linear" — the numpy / pandas / alphalens default —
rather than polars' own default of "nearest". With "nearest"
the bound snaps to an actually observed value, so on a small
cross-section the winsorisation can be a complete no-op: for
[0, 1, ..., 8, 100] at upper=0.95 the nearest-rank quantile is
100 itself and the outlier survives untouched, while the linear
quantile is 58.6 and clips it. Linear interpolation makes the clip
level a continuous function of the requested percentile, which is what
a per-date winsoriser needs in order to behave consistently as the
cross-section size changes.
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¶
Both normalizers scale by the median absolute deviation (MAD). Three places where factrix departs from the most common implementation, and why:
Finite-sample MAD constant. The standard scale is 1.4826 × MAD, where
1.4826 = 1/Φ⁻¹(0.75) is the asymptotic consistency constant — this is what
R's mad() and scipy.stats.median_abs_deviation ship. factrix multiplies in
the Croux-Rousseeuw (1992) finite-sample factor b_n as well (tabulated for
n ≤ 9, n/(n−0.8) above), keyed on the per-date count of finite values,
because this library targets cross-sections of 5–20 names where the asymptotic
constant is materially biased. Measured on a standard-normal factor
(true σ = 1), E[1.4826 × MAD] is 0.82 at n=5, 0.91 at n=10 and 0.96 at n=20,
against 0.99–1.01 at every size with b_n. The bias matters twice over: the
output of a function called "z-score" was not unit-scale, and because the bias
is n-dependent, thin dates in an unbalanced panel (delistings, staggered
entry, sparse event triggers) were handed systematically larger z-scores, so
anything pooling or weighting by z over-weighted the noisiest cross-sections.
Fewer than three finite values on a date leaves no estimable robust scale at
all: the date comes back null rather than as a fabricated 0.0.
Centring. The textbook z-score subtracts the mean. cross_sectional_zscore
defaults to center="median" to stay robust to the outliers the MAD scale is
chosen for — with the consequence that the output is not mean-zero on a
skewed factor, so w ∝ z carries a net long or short leg (measured: +0.32
net exposure on a 2-of-20 sparse trigger column). Pass center="mean" when the
weights must be dollar-neutral by construction; the scale stays the robust MAD
either way.
Regime switches are announced. When the per-date MAD collapses to zero
(more than 50% ties at the median) the scale falls back to the non-robust
per-date standard deviation. That is a data-driven change of estimator, so it
raises a UserWarning carrying
WarningCode.ZERO_MAD_STD_FALLBACK rather
than mixing robust and non-robust dates into one column silently. On a sparse
{0, R} trigger column mad_winsorize skips the clip entirely
(SPARSE_WINSORIZE_SKIPPED): that column's standard deviation is produced by
the triggers, so a 3-std band shrinks with the trigger rate and destroyed 58%
of a unit event's magnitude at one trigger in fifty names.
Non-finite input. NaN and ±Inf are blanked to null on output by both functions, not clipped into the band. A non-finite tick is a data error, not an extreme value; winsorizing it produced a plausible finite number that survived downstream finite-observation filters and put that asset at the top of the date's ranking.
cross_sectional_zscore names its output column after its input:
factor → factor_zscore, momentum → momentum_zscore, so several factors
can be standardized in one panel without colliding.
factrix.preprocess.mad_winsorize ¶
mad_winsorize(data: DataFrame, factor_col: str = 'factor', n_mad: float = 3.0, *, center: Center = 'median') -> DataFrame
Step 4: Per-date MAD-based winsorization on factor values.
Clips factor values to [centre +- n_mad * b_n * 1.4826 * MAD] within
each cross-section, where b_n is the Croux-Rousseeuw (1992)
finite-sample correction for the per-date finite count.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
factor_col
|
str
|
Column to winsorize in place. |
'factor'
|
n_mad
|
float
|
Number of MAD units for clipping (default 3.0). Must be a
finite number |
3.0
|
center
|
Center
|
Where the clip band is centred — |
'median'
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
DataFrame with |
DataFrame
|
come back null. |
Notes
Small-sample MAD scaling. 1.4826 is the asymptotic
consistency constant; at finite n it under-estimates sigma by
18% at n=5 and 9% at n=10, which makes n_mad=3.0 an effective
2.46 sigma clip on a five-name cross-section — over-winsorizing
exactly the tail a factor's signal lives in. The Croux-Rousseeuw
(1992) factors (tabulated for n <= 9, n/(n-0.8) above)
restore n_mad to its nominal sigma meaning at every
cross-section size, so an unbalanced panel is clipped consistently
across thin and wide dates.
Zero-MAD dates. More than 50% ties on a date drives the MAD to
exactly 0. Two sub-regimes are handled differently, and both raise a
UserWarning:
- A sparse
{0, R}trigger column (median 0, MAD 0) is left unwinsorized (WarningCode.SPARSE_WINSORIZE_SKIPPED). Its standard deviation is produced by the triggers, so3 * stdcollapses as the trigger rate falls — at one trigger in fifty names a unit event was clipped to 0.42, destroying 58% of the magnitude the metric downstream is trying to measure. - Any other zero-MAD date (a bucketed or binary factor) falls back
to the per-date sample standard deviation (
ddof=1), which keeps the factor finite and rank-preserving (WarningCode.ZERO_MAD_STD_FALLBACK).
A cross-section with no dispersion at all has scale = 0 and is
left untouched by the clip. A date with fewer than
MIN_SCALE_ASSETS_HARD finite values has no estimable robust
scale and is left untouched too
(WarningCode.INSUFFICIENT_SCALE_ASSETS).
Non-finite input. NaN / +-Inf values are excluded from the
per-date statistics and blanked to null in the output, matching
:func:cross_sectional_zscore. Clipping them into the band (the
previous behaviour) turned a hard data error into a plausible
finite extreme that survived every downstream finite-observation
filter and put the asset at the top of that
date's ranking.
References
- Croux, C. & Rousseeuw, P. J. (1992). "Time-Efficient Algorithms for Two Highly Robust Estimators of Scale." Finite-sample correction factors for the MAD.
Examples:
factrix.preprocess.cross_sectional_zscore ¶
cross_sectional_zscore(data: DataFrame, factor_col: str = 'factor', *, center: Center = 'median') -> DataFrame
Step 5: MAD-robust z-score within each cross-section (date).
z = (x - centre(x)) / (b_n * 1.4826 * MAD(x))
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
factor_col
|
str
|
Column to standardize. |
'factor'
|
center
|
Center
|
Where the score is centred.
|
'median'
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
DataFrame with a |
DataFrame
|
standardizing several factors in one panel no longer collides on a |
DataFrame
|
single hard-coded name). |
Notes
Small-sample MAD scaling. The scale carries the Croux-Rousseeuw
(1992) finite-sample factor b_n on top of the asymptotic
1.4826. Without it the output of a function called
"z-score" is not unit-scale at small n — its expected
cross-sectional standard deviation is 1.67 at n=5 and 1.18 at
n=10 — and, worse, the inflation is n-dependent, so on an
unbalanced panel thin dates produce systematically larger z and
every pooled or z-weighted statistic over-weights the noisiest
cross-sections. b_n removes the bias in the scale estimator
(E[b_n · 1.4826 · MAD] = σ to within 1% at every n >= 3),
which brings the expected sd(z) to 1.38 at n=5 and 1.08 at
n=10; the residual excess is Jensen curvature in E[σ̂ / scale]
and no fixed constant removes it. The constant targets the
normal-consistent scale: on heavier tails it overstates the
distribution's own MAD-implied scale (about +12% at n=3 on a
t(5), under +1% by n=50). See :func:mad_winsorize.
Zero-MAD fallback. With more than 50% ties on a date the MAD is
exactly 0, so the naive ratio is +-inf for every non-median
value (and 0/0 = NaN at the median). We fall back to the
per-date sample standard deviation (ddof=1) as the scale, which
keeps the z finite and rank-preserving — and raise a
UserWarning carrying WarningCode.ZERO_MAD_STD_FALLBACK,
because robust and non-robust dates otherwise end up mixed into one
column and one downstream statistic with no way to tell them apart.
Thin cross-sections. A date with fewer than
MIN_SCALE_ASSETS_HARD finite values yields null, not
0.0. At n=1 the old chain fell through to 0.0 — an
"average" fabricated from a single observation — and at n=2 it
returned +-0.6745 whatever the two values were, a constant
carrying no information but indistinguishable downstream from a
real score. WarningCode.INSUFFICIENT_SCALE_ASSETS is raised.
A fully constant cross-section of at least that size does have a
genuine "no spread" reading and still maps to 0.0.
Missing values stay missing. Per the library convention, producers never impute and consumers retain finite observations. A null, NaN or infinite input yields a null z-score.
Examples:
Orthogonalization¶
orthogonalize_factor runs a per-date cross-sectional OLS and returns the
residual. Two guards that the textbook formulation leaves to the analyst:
A degrees-of-freedom floor, not a solvability floor. The regression is only
fitted on dates leaving at least min_residual_df residual degrees of freedom
(n_assets − n_base − 1, default 10 — the N ≥ K + 10 form of the
Fama-MacBeth convention). Raw R² is mechanically ≈ K/(N − 1) even when the
true R² is zero, so on a six-name book regressed on four exposures the old
one-df floor reported mean_r_squared = 0.79 (adjusted: −0.03) after
stripping 83% of the factor's variance — the factor reads as redundant when it
is orthogonal by construction. Dates below the floor keep their original values,
are counted in n_dates_insufficient_df, and raise
insufficient_regression_df. mean_adj_r_squared is reported alongside the raw
figure. Pass min_residual_df=1 to restore the old behaviour.
Rank deficiency is detected, not assumed away. An intercept is always
prepended, so a full industry dummy set is exactly singular. np.linalg.lstsq
does not raise on that — it returns the minimum-norm solution — so the residual
is still exact (the projection onto the column space is unique) but mean_betas
was an arbitrary point in the solution space. Rank is read from the singular
values lstsq already computes, at matrix_rank's tolerance so near-collinear
designs are caught too; deficient dates are excluded from mean_betas and raise
rank_deficient_design. Drop one dummy category as the reference level.
Residual scale. The residual's dispersion is sqrt(1 − R²) times the
input's, and R² varies by date, so the output scale varies by date. Rank-based
metrics are unaffected; any magnitude-based use is otherwise quietly on a
time-varying scale. Pass restandardize=True to rescale each date's residual
back to the input's per-date dispersion. Both dispersions use exactly the
finite rows fitted by that date's regression; a row excluded for a non-finite
factor or base value, or because it has no matching base row, affects neither
scale estimate.
factrix.preprocess.orthogonalize_factor ¶
orthogonalize_factor(factor_df: DataFrame, base_factors: DataFrame, factor_col: str = 'factor', base_cols: list[str] | None = None, *, min_residual_df: int = MIN_ORTHOGONALIZE_RESIDUAL_ASSETS, restandardize: bool = False) -> 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
|
min_residual_df
|
int
|
Minimum residual degrees of freedom
( |
MIN_ORTHOGONALIZE_RESIDUAL_ASSETS
|
restandardize
|
bool
|
Rescale the residual to the per-date dispersion of the
input factor on the same finite rows used to fit that date's
regression. Rows excluded because the factor or any base value is
non-finite, or because no matching base row exists, contribute to
neither scale estimate. |
False
|
Returns:
| Type | Description |
|---|---|
OrthogonalizeResult
|
OrthogonalizeResult with: |
OrthogonalizeResult
|
replaced by the residual and |
OrthogonalizeResult
|
original value), |
OrthogonalizeResult
|
across full-rank dates), |
OrthogonalizeResult
|
|
OrthogonalizeResult
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Notes
Minimum residual degrees of freedom. A cross-sectional OLS needs
residual df to mean anything. The old floor was len(base_cols) + 2
rows — a single residual df — and raw R² is mechanically ≈
K/(N - 1) even when the true R² is zero, so a regional book of six
names regressed on size / value / momentum plus an industry dummy
passed the guard, fitted noise, and came back with
mean_r_squared = 0.79 (adjusted: -0.03) after removing 83% of
the factor's variance — the user concludes the factor is redundant
when it is orthogonal by construction. Dates below min_residual_df
are now skipped, counted in n_dates_insufficient_df, and flagged
with WarningCode.INSUFFICIENT_REGRESSION_DF.
mean_adj_r_squared is reported alongside the raw figure for the
same reason.
Rank deficiency. np.linalg.lstsq does not raise on a
rank-deficient design — it returns the minimum-norm solution — so the
except LinAlgError branch never fired for the dummy trap. The
residual stays correct either way (the projection onto the column
space is unique), but mean_betas was an arbitrary point in the
solution space, reported without qualification: on a 4-dummy panel
the full set gave {ind0: 0.008, ind1: -0.007, ...} and dropping one
category gave {ind1: -0.015, ...}, with identical residuals.
Rank is now read from the singular values lstsq already computes
(the matrix_rank tolerance, so near-collinear designs are caught
too, not only exactly singular ones); deficient dates are counted in
n_dates_rank_deficient, excluded from mean_betas, and flagged
with WarningCode.RANK_DEFICIENT_DESIGN.
Residual scale. See restandardize above; the default leaves
the residual on its own per-date scale rather than the input's.
Non-finite rows. A single null / NaN / ±Inf in factor_col or
in any base column used to make np.linalg.lstsq return all-NaN
betas, which turned every asset on that date into NaN while the
date still counted as orthogonalised. The regression now runs on the
finite rows only. Rows that were not finite come back null, not
with their original value: the residual is undefined for them, and
keeping the raw value would mix two scales (raw factor and residual)
inside one column — a silent, unrecoverable contamination. Their
count is reported as n_rows_non_finite and logged.
Skipped dates. A date that leaves fewer than min_residual_df
residual degrees of freedom (or one where lstsq raises) cannot
support the regression; the original values are kept for that date and
it is counted in n_dates_skipped. Such dates are excluded from
coverage, mean_betas and both R² figures.
Duplicate keys. base_factors must be unique on
(date, asset_id). A duplicated key silently fans the inner join
out (panel height doubles, coverage exceeds 1.0), so it is
rejected up front rather than de-duplicated with a guessed rule.
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, mean_adj_r_squared: float = 0.0, n_dates: int = 0, coverage: float = 0.0, n_base: int = 0, n_rows_non_finite: int = 0, n_dates_skipped: int = 0, n_dates_insufficient_df: int = 0, n_dates_rank_deficient: int = 0, restandardized: bool = False, warning_codes: tuple[str, ...] = ())
Result of factor orthogonalization with attribution info.
Attributes:
| Name | Type | Description |
|---|---|---|
data |
DataFrame
|
|
mean_betas |
dict[str, float]
|
Average beta per base factor across regressed dates whose
design matrix had full column rank. Empty when every regressed
date was rank deficient — the betas are not identified there, so
reporting an average of arbitrary minimum-norm solutions would be
worse than reporting nothing (see |
mean_r_squared |
float
|
Average raw R² across regressed dates. |
mean_adj_r_squared |
float
|
Average degrees-of-freedom-adjusted R²,
|
n_dates |
int
|
Number of per-date cross-sections seen after the join. |
coverage |
float
|
Fraction of input rows that carry an actual residual — rows dropped by the join, rows on a skipped date, and rows with non-finite inputs are all excluded. |
n_base |
int
|
Number of base factor columns regressed on. |
n_rows_non_finite |
int
|
Rows whose factor or base values were null / NaN /
±Inf on an otherwise regressed date. These rows are excluded from
the regression and their residual is null in |
n_dates_skipped |
int
|
Dates left un-orthogonalised, for any reason. Original factor values are kept for those dates. |
n_dates_insufficient_df |
int
|
Subset of |
n_dates_rank_deficient |
int
|
Dates that were regressed but whose design
matrix was rank deficient. The residual is still exact (the
projection is unique) but the betas are not identified, so those
dates are excluded from |
restandardized |
bool
|
Whether the residual was rescaled to the per-date dispersion of the input factor. |
warning_codes |
tuple[str, ...]
|
:class: |