Caloogy Article
中文

Factor Engineering: From Raw Data to Usable Signals

Date
Jan 1, 2026
Series
Quant
Reading
5 min
Topics
QuantFactor engineeringWinsorizeZscore

The one-sentence version

To turn an idea (e.g., "stocks with fast profit growth will rise") into a usable factor, it must pass through a pipeline: Data Acquisition (mind the timing) → Calculate Raw Values → Handle Missing Values → Winsorization → Standardization → Neutralization (removing industry and market cap effects) → Re-standardization. Every step answers the question: "In this signal, how much is the information I actually want, and how much is noise or the shadow of something else?" Skipping any step may result in backtest results dominated by a few extreme values, a specific industry, or market cap effects, making you believe you have discovered a new factor when you haven't.

Disclaimer

This article is for technical educational purposes only and does not constitute any investment advice. The code in this article uses simulated data.


Layer 1: The Intuition

1.1 Washing, Cutting, and Deboning

Ingredients cannot be thrown directly into the pot after being bought: you must wash away the sand (missing and erroneous data), cut off the bad parts (extreme values), cut them into uniform sizes (standardization), and remove unwanted flavors (neutralization).

Factor engineering is the preparation of ingredients before cooking. If the preparation is poorly done, no matter how exquisite the subsequent backtest is, it will be useless.

1.2 A Worked Example

Idea: "Stocks with low P/E ratios (cheap) will have higher future returns."

Sorting directly by P/E ratio will encounter these problems:

Problem Example
Extreme Values A company's profit is near zero, resulting in a P/E ratio as high as 5000x
Negative Values A loss-making company has a negative P/E ratio, which has a completely different meaning
Industry Differences Banks naturally have low P/E ratios, while tech companies naturally have high P/E ratios. Sorting by P/E is equivalent to "buying banks and selling tech," which is an industry bet, not a "cheapness" effect
Market Cap Correlation Small companies might be systematically cheaper, causing "value" and "size" to be mixed together
Timing A company announces last year's annual report in March. If the backtest uses this data on January 1st, it has peeked into the future

Layer 2: Every Step of the Pipeline

2.1 Data Acquisition: Point-in-Time Data

The most hidden and fatal error is look-ahead bias: using data on a specific day in a backtest that was impossible to know on that day.

Common Pitfall Correct Approach
Using the "reporting period" of a financial report (e.g., Dec 31) as the available date Use the announcement date, or even the day after the announcement date
Financial reports are later revised, but the database only keeps the revised figures Use a "point-in-time database" that preserves historical versions
Using today's constituent list for index components Use the constituent list from that specific time (otherwise, it introduces survivorship bias, see Seven Deadly Sins of Backtesting)

2.2 Winsorization

"Pulling" values that exceed a reasonable range back to the boundaries, rather than deleting them. Common boundaries:

Method Boundary
Quantile Method 1st and 99th percentiles
Standard Deviation Method Mean ± 3 standard deviations (but the standard deviation itself is inflated by extreme values)
MAD Method Median ± 3 × 1.4826 × MAD (Median Absolute Deviation), which is most robust to extreme values

The coefficient 1.4826 makes MAD consistent with the standard deviation under a normal distribution.

2.3 Missing Values

2.4 Standardization

Perform a z-score on the cross-section for each period (each trading day or month):

zi=xi−xˉσx.z_i = \frac{x_i - \bar{x}}{\sigma_x}.

This allows comparison across different periods and different factors, and facilitates the synthesis of multiple factors. One can also use rank percentiles instead, which is less sensitive to the shape of the distribution.

2.5 Neutralization

Use regression to remove unwanted exposures. Perform a cross-sectional regression for each period:

xi=∑kγk Dik+δln⁡(market_capi)+εi,x_i = \sum_k \gamma_k \, D_{ik} + \delta \ln(\text{market\_cap}_i) + \varepsilon_i,

DikD_{ik} are industry dummy variables (where stock ii belongs to industry kk, the value is 1). The residual εi\varepsilon_i is the neutralized factor: it is uncorrelated with industry and market cap; what remains is "among stocks of the same industry and same size, who is cheaper."


Layer 3: Code Implementation

3.1 Engineering details: A Complete Pipeline

In the simulated data below, the raw factor intentionally mixes in industry and market cap effects, and contains heavy-tailed distributions and missing values:

import numpy as np, pandas as pd
rng = np.random.default_rng(0)
N = 500
df = pd.DataFrame({
    "industry": rng.choice(["Tech", "Finance", "Consumer", "Energy"], N),
    "mcap": np.exp(rng.normal(23, 1.2, N)),
})
ind_bias = df["industry"].map({"Tech": 0.8, "Finance": -0.5, "Consumer": 0.1, "Energy": -0.3})
df["raw"] = ind_bias + 0.4 * np.log(df["mcap"]) + rng.standard_t(3, N)  # Raw factor: mixed with industry and market cap
df.loc[rng.choice(N, 15, replace=False), "raw"] = np.nan

def winsorize_mad(x, k=3):
    med = x.median(); mad = (x - med).abs().median() * 1.4826
    return x.clip(med - k * mad, med + k * mad)

def zscore(x):
    return (x - x.mean()) / x.std()

def neutralize(y, df):
    X = pd.get_dummies(df["industry"], dtype=float)
    X["lnmcap"] = np.log(df["mcap"])
    m = y.notna()
    beta, *_ = np.linalg.lstsq(X[m].values, y[m].values, rcond=None)
    out = y.copy(); out[m] = y[m] - X[m].values @ beta
    return out

x = df["raw"]
x = x.fillna(x.groupby(df["industry"]).transform("median"))   # Fill with industry median
x = winsorize_mad(x)
x = zscore(x)
x = neutralize(x, df)
x = zscore(x)
df["factor"] = x
print("Correlation between raw factor and ln market cap:", round(df["raw"].corr(np.log(df["mcap"])), 3))
print("Correlation between processed factor and ln market cap:", round(df["factor"].corr(np.log(df["mcap"])), 3))
print("Processed mean by industry:", df.groupby("industry")["factor"].mean().round(3).to_dict())
print("Raw kurtosis:", round(df["raw"].kurt(), 2), "Processed kurtosis:", round(df["factor"].kurt(), 2))

Output:

Correlation between original factor and ln market cap: 0.266
Correlation between processed factor and ln market cap: -0.0
Processed industry means: {'Consumer': -0.0, 'Tech': -0.0, 'Energy': 0.0, 'Finance': 0.0}
Original kurtosis: 4.07 Processed kurtosis: 1.28

(-0.0 is the display of a floating-point number rounded, representing a value extremely close to zero.)

3.2 Interpretation

Metric Before Processing After Processing Meaning
Correlation with Market Cap 0.27 0 Market cap effect has been stripped
Industry Means Large differences All are 0 Industry effect has been stripped
Kurtosis (larger = heavier tails) 4.07 1.28 Extreme values have been controlled

3.3 On Real Panel Data

Real data is a panel of "Date × Stock." Every step must be performed grouped by date; you must never calculate the mean and standard deviation by mixing all dates together (as that would use future information):

def process_one_date(g):
    x = g["raw"]
    x = x.fillna(x.groupby(g["industry"]).transform("median"))
    x = zscore(winsorize_mad(x))
    return zscore(neutralize(x, g))

panel["factor"] = panel.groupby("date", group_keys=False).apply(process_one_date)

3.4 Common Error Checklist

Error Consequence
Calculating mean, standard deviation, or quantiles on the entire sample Look-ahead bias
Using the financial report reporting period instead of the announcement date Look-ahead bias, inflated backtest returns
Not performing neutralization Your "new factor" might just be the shadow of industry or market cap
Over-neutralization (removing all known factors) You might also remove the actual signal
Deleting extreme values instead of winsorizing Changes the sample, and extreme values are often concentrated in certain stocks
Neutralizing before winsorizing Extreme values will distort the neutralization regression

Summary

Step Purpose
Point-in-time Data Acquisition Avoid look-ahead bias
Missing Value Handling Use industry median, do not use future data
Winsorization (MAD) Prevent a few extreme values from dominating
Standardization Comparability across time and across factors
Neutralization Strip away unwanted exposures like industry and market cap
Grouping by Date Use only the cross-sectional information available at that time for every step