Quant Memo

Build It Yourself

← All guides

Build a Cross-Sectional Factor Screen

Combine value and quality into a composite equity signal, z-score each factor across the cross-section, blend, form a long-short book, and evaluate it the way a quant actually does: with the information coefficient and the fundamental law of active management.

A factor screen is the quant's version of a stock pitch: instead of arguing one name is cheap and well-run, you measure cheapness and quality across the whole cross-section, blend the measurements into a single score, and hold a portfolio tilted toward the high-scorers. The two factors we'll combine are the oldest in the book, value (cheap stocks beat expensive ones; Fama–French) and quality (profitable, stable firms beat junk; Asness–Frazzini–Pedersen). They pair beautifully because they're nearly uncorrelated: value buys the unloved, quality insists the unloved aren't broken.

The real lesson here isn't the two factors, it's the evaluation lens. Retail screens get judged by backtested returns, which are noisy and easy to overfit. Professionals judge a signal by its information coefficient and reason about capacity with the fundamental law of active management. This guide builds the composite on the backtest skeleton and then evaluates it the way a desk would.

A fundamental panel

We need cross-sectional fundamentals per month. Simulate a 200-stock universe over 120 months with two raw factors on deliberately different scales, value as an earnings yield (~6%), quality as a return-on-equity (~12%), plus forward returns that depend weakly on both (swap for real data from your fundamentals provider):

import numpy as np
import pandas as pd

rng = np.random.default_rng(3)
n_months, n_assets = 120, 200
dates = pd.date_range("2012-01-31", periods=n_months, freq="ME")
tickers = [f"S{i:03d}" for i in range(n_assets)]

# --- real data: value = earnings_yield_panel; quality = roe_panel ---
value   = pd.DataFrame(rng.normal(0.06, 0.03, (n_months, n_assets)), index=dates, columns=tickers)
quality = pd.DataFrame(rng.normal(0.12, 0.08, (n_months, n_assets)), index=dates, columns=tickers)

# forward monthly returns: a small true payoff to the factors + market + noise
zv_t = value.sub(value.mean(axis=1), axis=0).div(value.std(axis=1), axis=0)
zq_t = quality.sub(quality.mean(axis=1), axis=0).div(quality.std(axis=1), axis=0)
true_sig = 0.6*zv_t + 0.4*zq_t
mkt   = pd.Series(rng.normal(0.005, 0.04, n_months), index=dates)
noise = pd.DataFrame(rng.normal(0, 0.09, (n_months, n_assets)), index=dates, columns=tickers)
fwd_ret = 0.004*true_sig + noise.add(mkt, axis=0)

The 0.004*true_sig term is tiny on purpose: real factor signals explain a sliver of next-month return and drown in noise. That's not a flaw to engineer away, it's the actual signal-to-noise ratio of equity investing, and the reason breadth matters so much later.

Standardize, then combine

You can't add a 6%-scale earnings yield to a 12%-scale ROE directly, quality would dominate purely because its numbers are bigger. Cross-sectional z-scoring puts every factor on a common, unit-variance scale each month:

zi,t  =  xi,tμtσtz_{i,t} \;=\; \frac{x_{i,t} - \mu_t}{\sigma_t}

where μt,σt\mu_t, \sigma_t are the cross-sectional mean and standard deviation on date tt. Then the composite is just a weighted blend of z-scores:

def zscore(df):
    return df.sub(df.mean(axis=1), axis=0).div(df.std(axis=1), axis=0)

zv, zq = zscore(value), zscore(quality)
composite = 0.5*zv + 0.5*zq                          # equal-weight blend

Two construction choices are already baked in and both are defensible-but-arbitrary: the 50/50 weighting (equal-weighting factors is a robust default, optimizing the blend on past returns overfits) and the decision to not yet neutralize. On real data you'd also winsorize z-scores at ±3 so one absurd outlier doesn't hijack the composite, and you'd often sector-neutralize, z-score within each sector, so "value" doesn't just mean "overweight banks and energy." Neutralization is the difference between a factor bet and an accidental sector bet.

The information coefficient

Before building any portfolio, ask the only question that matters: does the signal line up with future returns? The information coefficient is the cross-sectional correlation between the signal today and the return realized next period, computed on rank to be robust to outliers, one number per month:

ICt  =  corr ⁣(rank(st),rank(rt+1))\mathrm{IC}_t \;=\; \operatorname{corr}\!\bigl(\operatorname{rank}(s_t),\, \operatorname{rank}(r_{t+1})\bigr)

def rank_ic(sig, ret):
    ics = {}
    for dt in sig.index:
        s, y = sig.loc[dt], ret.loc[dt]
        m = s.notna() & y.notna()
        ics[dt] = s[m].rank().corr(y[m].rank())
    return pd.Series(ics)

ic = rank_ic(composite, fwd_ret)
print(round(ic.mean(), 4), round(ic.std(), 4))                 # 0.0227  0.0761
print(round(ic.mean()/ic.std()*np.sqrt(len(ic)), 2))           # t-stat 3.26

Our composite scores a mean IC of 0.023. That sounds microscopic, and it is, but it's realistic: live equity factor ICs typically run 0.02–0.05. What makes it tradeable is that it's small and reliably positive, a t-statistic of 3.3 over 120 months. A signal with a mean IC of 0.10 in a backtest is far more likely to be a lookahead bug than a discovery.

From signal to portfolio

Build a dollar-neutral long-short book by weighting each name by its demeaned composite, normalized to gross 1, long the high-scorers, short the low:

w = composite.sub(composite.mean(axis=1), axis=0)
w = w.div(w.abs().sum(axis=1), axis=0)               # sums to 0, gross = 1
port = (w * fwd_ret).sum(axis=1)                     # w[t] earns return over t -> t+1

ir = port.mean()/port.std()*np.sqrt(12)
print(round(port.mean()*12, 4), round(port.std()*np.sqrt(12), 4), round(ir, 3))
# ann return 0.034 | ann vol 0.030 | IR 1.13

The book delivers an annualized information ratio of 1.13, return per unit of active risk. Note the mechanics you already know: w sums to zero (market-neutral), and w[t] is applied to the forward return, so there's no lookahead. For a long-only version, just hold the top quintile: in our run the top 20% by composite returns 16.2% annualized against a 14.2% universe, a 2-point tilt from a whisper of signal.

The fundamental law: why breadth is everything

Here is the identity that ties it together. Grinold's fundamental law of active management says the information ratio you can achieve is your skill per bet times the square root of the number of bets:

IR  =  ICBR\mathrm{IR} \;=\; \mathrm{IC}\cdot\sqrt{\mathrm{BR}}

where breadth BR is the number of independent bets per year. With 200 names rebalanced monthly, BR=200×12=2400\mathrm{BR} = 200 \times 12 = 2400:

breadth = n_assets * 12
print(round(ic.mean()*np.sqrt(breadth), 3))          # 1.11  ≈  portfolio IR 1.13

The law predicts 1.11; the portfolio delivered 1.13. They match because our synthetic bets really are independent, and that's the whole point. A microscopic IC of 0.023 becomes a respectable IR above 1.0 only by being applied across thousands of independent bets. This is why factor investing is a breadth business, not a stock-picking one: you don't need to be right often, you need to be right slightly-more-than-half the time, thousands of times. It's also the law's warning, if your 200 stocks all really express one macro bet, your effective breadth is nearer 1 than 2400, and that IR is a mirage.

QuantityValue
Mean IC (rank)0.023
IC t-stat (120 mo)3.3
Breadth (200 × 12)2400
IC · √breadth1.11
Realized portfolio IR1.13

Where this breaks, and what to test next

  • Independent breadth is a fiction. The law assumes uncorrelated bets; real stocks share sector and macro factors, so true breadth is a fraction of the name count. Overstating it is the single biggest way factor IRs disappear out of sample.
  • Neutralize, or measure the wrong thing. Without sector and beta neutralization, your "value + quality" signal may just be a bet on a few industries. Re-run z-scoring within sectors and watch the IC change.
  • Point-in-time fundamentals. Backtesting on today's reported financials is lookahead, earnings are restated and reported with a lag. Use point-in-time data and lag fundamentals by at least a quarter, or the whole thing is fantasy.
  • Costs and turnover. Monthly rebalancing is far cheaper than the daily reversal book, but a long-short factor book still turns over; run the turnover-cost sweep before believing the IR.
  • Crowding and decay. Value and quality are the most-harvested factors on earth; published anomalies decay after publication. A live IC below your backtest is the base case, not the exception.

The payoff is a genuinely professional workflow: standardize factors, blend without overfitting, judge the signal by its IC before you ever look at a return, and reason about capacity through the fundamental law. That toolkit generalizes to any factor you can measure. Extend it with the earnings-quality signal in Good EPS, Bad EPS or the macro tilts in Sector Rotation, and layer the vol-targeting overlay on top to stabilize the book's risk.

The theory behind it

Related strategies

Further reading

  • Grinold & Kahn, Active Portfolio Management (2000)
  • Fama & French, Common Risk Factors in the Returns on Stocks and Bonds (1993)
  • Asness, Frazzini & Pedersen, Quality Minus Junk (2019)
  • Grinold, The Fundamental Law of Active Management (1989)

Code is for education, not investment advice. Test everything on paper before risking capital.