From multiple factors to a tradable portfolio, in four blocks:
Before combining factors, the usual sequence is: diagnose factor correlation and multicollinearity first, then choose a combination method. Feeding highly correlated factors into one model can look fine in backtests but often disappoints live. In practice, pairwise correlations above 0.9 are common and largely duplicate information.
Three blocks:
Simple averaging is not enough—if the combination method is wrong, even strong single factors can make a mediocre portfolio. A common sequence: correlation diagnostics → equal-weight benchmark → IC/ICIR weighting → advanced methods.
Factor correlation analysis clarifies whether factors complement each other or largely overlap.
In multi-factor modeling, putting highly correlated factors into the same model can look strong in backtests but weak live. Correlations above 0.9 often mean duplicated information.
This section covers three angles: cross-sectional correlation, time-series correlation, and multicollinearity diagnostics.
Cross-sectional correlation asks whether different factors’ values in the stock universe move together at the same date. For example, on a given day, are valuation and growth positively or negatively related across stocks?
Two common measures:
Example: three factors—valuation (PE), momentum (MOM), and volatility (VOL). Cross-section for one day:
import pandas as pd
import numpy as np
from scipy.stats import spearmanr
# Simulated data
np.random.seed(42)
n_stocks = 500
data = pd.DataFrame({
'PE': np.random.randn(n_stocks),
'MOM': np.random.randn(n_stocks) * 0.8 + 0.2,
'VOL': np.random.randn(n_stocks) * 0.5 - 0.1
})
# Spearman correlation matrix
corr_matrix = data.corr(method='spearman')
print(corr_matrix)
PE MOM VOL PE 1.000000 -0.090156 -0.077518 MOM -0.090156 1.000000 0.073234 VOL -0.077518 0.073234 1.000000
Example output:
| PE | MOM | VOL | |
|---|---|---|---|
| PE | 1.00 | 0.03 | -0.12 |
| MOM | 0.03 | 1.00 | 0.45 |
| VOL | -0.12 | 0.45 | 1.00 |
MOM–VOL correlation of 0.45 is moderately high. Momentum and volatility often link—high-vol names frequently show stronger momentum—so watch this carefully.
As a rule of thumb, pairs with cross-sectional correlation above 0.6 should be merged or one dropped; above 0.8 they are effectively duplicate factors.
Cross-sectional correlation looks across stocks at one time; time-series correlation looks at whether factor returns rise and fall together over time.
How to compute: first get each factor’s daily return series, then correlate those return series.
# Assume we have factor return data
factor_returns = pd.DataFrame({
'PE_ret': np.random.randn(252) * 0.02,
'MOM_ret': np.random.randn(252) * 0.015,
'VOL_ret': np.random.randn(252) * 0.01
})
# Time-series correlation
time_corr = factor_returns.corr
print(time_corr)
PE_ret MOM_ret VOL_ret PE_ret 1.000000 0.079345 0.076862 MOM_ret 0.079345 1.000000 -0.056697 VOL_ret 0.076862 -0.056697 1.000000
High time-series correlation means the two factors behave similarly most of the time. Consequence: when styles rotate, a multi-factor book can fail all at once.
A past value-plus-low-vol model had cross-sectional correlation of only 0.2 (looked diversified) but time-series correlation of 0.7—both struggled in market selloffs, and that year’s portfolio drawdown exceeded 15%.
Check both dimensions. Low cross-sectional correlation does not imply low time-series correlation. Combinations that are low on both diversify better.
Multicollinearity means factors are highly linearly related. Regression coefficients become unstable and factor contributions hard to interpret.
Three common diagnostics:
Code sketch:
from statsmodels.stats.outliers_influence import variance_inflation_factor
from sklearn.preprocessing import StandardScaler
# Standardize
scaler = StandardScaler
X_scaled = scaler.fit_transform(data)
# Compute VIF
vif_data = pd.DataFrame
vif_data['Factor'] = data.columns
vif_data['VIF'] = [variance_inflation_factor(X_scaled, i) for i in range(X_scaled.shape[1])]
print(vif_data)
--------------------------------------------------------------------------- ModuleNotFoundError Traceback (most recent call last) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 43 try: ---> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ModuleNotFoundError: No module named 'sklearn.__check_build._check_build' During handling of the above exception, another exception occurred: ImportError Traceback (most recent call last) <ipython-input-3-dcf3aef96062> in <cell line: 0>() 1 from statsmodels.stats.outliers_influence import variance_inflation_factor ----> 2 from sklearn.preprocessing import StandardScaler 3 4 # 标准化数据 5 scaler = StandardScaler() d:\pythonprojects\venv\Lib\site-packages\sklearn\__init__.py in <module> 79 # it and importing it first would fail if the OpenMP dll cannot be found. 80 from . import _distributor_init # noqa: F401 ---> 81 from . import __check_build # noqa: F401 82 from .base import clone 83 from .utils._show_versions import show_versions d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ---> 46 raise_build_error(e) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in raise_build_error(e) 29 else: 30 dir_content.append(filename + '\n') ---> 31 raise ImportError("""%s 32 ___________________________________________________________________________ 33 Contents of %s: ImportError: No module named 'sklearn.__check_build._check_build' ___________________________________________________________________________ Contents of d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build: setup.py _check_build.cp38-win_amd64.pyd__init__.py __pycache__ ___________________________________________________________________________ It seems that scikit-learn has not been built correctly. If you have installed scikit-learn from source, please do not forget to build the package before using it: run `python setup.py install` or `make` in the source directory. If you have used an installer, please check that it is suited for your Python version, your operating system and your platform.
Output:
| Factor | VIF |
|---|---|
| PE | 1.15 |
| MOM | 1.42 |
| VOL | 1.38 |
VIFs for these three factors are low, so multicollinearity is not a major issue. If any factor’s VIF exceeds 10, dig further.
When building industry factors, putting all 11 industry dummies into the model can push VIF much higher. Industry dummies are collinear by construction—they sum to the intercept. Drop one industry as the base category.
When high correlation appears, common remedies are:
In screening, cluster highly correlated factors first, then pick a representative from each cluster—retain information without redundancy.
Correlation analysis is a system check: cross-section captures static links, time series captures dynamics, and multicollinearity reveals deeper structure. Together they support a more robust multi-factor stack.
More factors are not always better—independence matters more. If two factors tend to fail in the same periods, stacking them amplifies errors instead of diversifying risk.
Multi-factor combination merges single factors into a composite score. With many factors, using each alone is impractical—you must combine. This section covers four basic methods. They are simple but still widely used; ICIR weighting is a strong cost-effective starting point.
Equal weighting assigns the same weight 1/N to every factor. It is the most naive approach and a common baseline.
Clear advantages:
Serious drawbacks:
Core formula. Note: standardize all factors first; otherwise scale differences let one factor dominate.
$$ \text{Composite score} = (\text{Factor}_1 + \text{Factor}_2 + \ldots + \text{Factor}_N) / N $$A team once equal-weighted 10 factors with a strong backtest; six months live, one factor failed and returns collapsed. That is the equal-weight limit—no automatic drop of dead factors.
Use equal weight as a benchmark. When testing new factors, combine with equal weight first; if that still fails, the set usually will not work.
Market-cap weighting allocates by stock market cap—higher weight on large caps, lower on small caps—similar to index construction.
Market-cap weighting allocates by market cap: large caps get more weight, small caps less—again like an index.
Why it matters for combination: some factors work better in large caps, others in small caps. Cap weighting naturally tilts large; if a factor already has a size preference, this method amplifies it.
Procedure:
Formulas:
$$ \text{Composite score}_i = \sum_j (w_j \cdot \text{factor}_{ij}) $$$$ w_j = \text{MarketCap}_j / \sum(\text{MarketCap}) $$A liquidity-factor book built with cap weighting once showed almost all return coming from large caps—effectively picking size, not stocks. Switching to IC weighting normalized results.
Cap weighting easily injects size bias. If you do not want a size tilt, avoid it. When large caps happen to rally in the backtest window, the equity curve can look inflated and hard to replicate live.
IC weighting uses the correlation between a factor and future returns as the weight. Higher IC means stronger predictive power and a larger weight.
IC weighting sets weights from factor–future-return correlation. Higher IC → stronger prediction → larger weight.
Steps:
Formula. IC can be positive or negative—normalize by absolute IC so signs do not cancel into chaos.
$$ \text{Composite score} = \sum(IC_i \cdot factor_i) / \sum(|IC_i|) $$A common choice is the trailing 12-month rolling mean IC. Twelve months balances noise sensitivity and responsiveness—too short is noisy; too long lags.
Example: ICs of 0.05, 0.03, and −0.02 yield weights:
| Factor | IC | Weight |
|---|---|---|
| Factor A | 0.05 | 50% |
| Factor B | 0.03 | 30% |
| Factor C | −0.02 | 20% |
Factor C keeps 20% despite negative IC. Negative IC is still information—it may work when inverted—so dropping it outright wastes signal.
IC weighting needs reasonably stable IC. If IC swings wildly, weights swing too. Smooth IC first (e.g., exponential moving average).
ICIR weighting adds IC stability on top of IC weighting. IR (information ratio) is mean IC divided by its standard deviation—capturing both predictive power and stability.
Relative to IC weighting: a high but volatile IC should not get a large weight. ICIR addresses that.
Formulas:
Higher IR means the factor is both predictive and stable—worth more weight.
One factor had mean IC 0.06 but IR only 0.3 (IC std 0.2); another had mean IC 0.04 and IR 0.8. Live, the latter usually wins—stable factors compound.
ICIR weighting is a recommended entry-level combination method: balances return and stability, and is easy to compute.
Comparison of the four methods:
| Method | Idea | Pros | Cons | Best for |
|---|---|---|---|---|
| Equal weight | Treat all alike | Simple, no overfit | Ignores quality | Benchmarks, early screening |
| Cap weight | Prefer large caps | Index-like | Size bias | Large-cap style books |
| IC weight | Weight by predictive power | Separates good/bad factors | Ignores stability | Large quality gaps |
| ICIR weight | Return + stability | Often best overall | Needs longer history | Robust excess returns |
Python sketches of the four methods:
import pandas as pd
import numpy as np
def equal_weight(factors):
"""Equal weighting"""
return factors.mean(axis=1)
def market_cap_weight(factors, market_cap):
"""Market-cap weighting"""
weights = market_cap / market_cap.sum
return (factors * weights).sum(axis=1)
def ic_weight(factors, future_returns, window=12):
"""IC weighting"""
ic = factors.rolling(window).corr(future_returns)
weights = ic.mean / ic.mean.abs.sum
return (factors * weights).sum(axis=1)
def icir_weight(factors, future_returns, window=12):
"""ICIR weighting"""
ic = factors.rolling(window).corr(future_returns)
ir = ic.mean / ic.std
weights = ir / ir.sum
return (factors * weights).sum(axis=1)
# Example usage
# score = icir_weight(factor_df, return_df)
These methods are only a starting point. Advanced use includes dynamic weights, industry neutralization, and risk constraints. Master the four basics, then extend.
With few factors, equal weight and IC weighting can work; with dozens of factors, multicollinearity, noise stacking, and overfitting become acute. This section covers three advanced methods. Goal: extract clean, useful signal from entangled factors.
PCA reduces dimension. Factors are often highly related—momentum and reversal, turnover and volatility may describe the same market behavior.
PCA extracts new variables (principal components)—linear combinations of the originals that are mutually orthogonal. The first PC explains the most variance, then the second, and so on.
Core idea: keep most of the information in the original factor matrix with a few PCs.
With n stocks and k factors, form matrix X (n×k). PCA steps:
In practice: some factors have large variance but weak return correlation; selecting PCs by variance alone can disappoint. Prefer IC screening first—keep factors with |IC| above a threshold—then run PCA.
import numpy as np
import pandas as pd
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
# Assume factor_data is a DataFrame: rows = stocks, columns = factors
scaler = StandardScaler
factor_scaled = scaler.fit_transform(factor_data)
pca = PCA(n_components=0.85) # keep 85% of variance
pca.fit(factor_scaled)
# Variance explained by each PC
print("Explained variance ratio:", pca.explained_variance_ratio_)
# Combined factor: weight by explained variance
weights = pca.explained_variance_ratio_ / pca.explained_variance_ratio_.sum
pca_factor = np.dot(pca.transform(factor_scaled), weights)
--------------------------------------------------------------------------- ModuleNotFoundError Traceback (most recent call last) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 43 try: ---> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ModuleNotFoundError: No module named 'sklearn.__check_build._check_build' During handling of the above exception, another exception occurred: ImportError Traceback (most recent call last) <ipython-input-5-bc8b2aeec203> in <cell line: 0>() 1 import numpy as np 2 import pandas as pd ----> 3 from sklearn.decomposition import PCA 4 from sklearn.preprocessing import StandardScaler 5 d:\pythonprojects\venv\Lib\site-packages\sklearn\__init__.py in <module> 79 # it and importing it first would fail if the OpenMP dll cannot be found. 80 from . import _distributor_init # noqa: F401 ---> 81 from . import __check_build # noqa: F401 82 from .base import clone 83 from .utils._show_versions import show_versions d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ---> 46 raise_build_error(e) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in raise_build_error(e) 29 else: 30 dir_content.append(filename + '\n') ---> 31 raise ImportError("""%s 32 ___________________________________________________________________________ 33 Contents of %s: ImportError: No module named 'sklearn.__check_build._check_build' ___________________________________________________________________________ Contents of d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build: setup.py _check_build.cp38-win_amd64.pyd__init__.py __pycache__ ___________________________________________________________________________ It seems that scikit-learn has not been built correctly. If you have installed scikit-learn from source, please do not forget to build the package before using it: run `python setup.py install` or `make` in the source directory. If you have used an installer, please check that it is suited for your Python version, your operating system and your platform.
PCA-combined factors often lift backtest Sharpe by about 0.2–0.5 versus equal weight. But PCA is sensitive to extremes—without MAD winsorization, PC1 can be dominated by a few outlier stocks.
PCA’s limit: PCs are mathematically clean but hard to interpret. PC1 might be 20% momentum + 15% value + 10% quality + … with no clear economic label.
Factor rotation addresses that by rotating PC axes so each component loads strongly on only a few original factors—giving each component a clearer label.
| Rotation | Features | Best for |
|---|---|---|
| Orthogonal (Varimax) | Keeps PCs orthogonal; simplifies loadings | Factors theoretically uncorrelated |
| Oblique (Promax) | Allows factor correlation; closer to reality | Genuine correlation among factors |
In practice Varimax is more common. Orthogonal factors can go straight into regressions without collinearity worries. Oblique fits may be tighter, but residual correlation complicates later modeling.
from sklearn.decomposition import FactorAnalyzer
fa = FactorAnalyzer(n_factors=5, rotation='varimax')
fa.fit(factor_scaled)
# Factor loading matrix
loadings = fa.loadings_
print("Factor loadings:\n", pd.DataFrame(loadings,
index=factor_data.columns,
columns=['F1','F2','F3','F4','F5']))
# Rotated factor scores
factor_scores = fa.transform(factor_scaled)
--------------------------------------------------------------------------- ModuleNotFoundError Traceback (most recent call last) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 43 try: ---> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ModuleNotFoundError: No module named 'sklearn.__check_build._check_build' During handling of the above exception, another exception occurred: ImportError Traceback (most recent call last) <ipython-input-6-f435f56d9712> in <cell line: 0>() ----> 1 from sklearn.decomposition import FactorAnalyzer 2 3 fa = FactorAnalyzer(n_factors=5, rotation='varimax') 4 fa.fit(factor_scaled) 5 d:\pythonprojects\venv\Lib\site-packages\sklearn\__init__.py in <module> 79 # it and importing it first would fail if the OpenMP dll cannot be found. 80 from . import _distributor_init # noqa: F401 ---> 81 from . import __check_build # noqa: F401 82 from .base import clone 83 from .utils._show_versions import show_versions d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ---> 46 raise_build_error(e) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in raise_build_error(e) 29 else: 30 dir_content.append(filename + '\n') ---> 31 raise ImportError("""%s 32 ___________________________________________________________________________ 33 Contents of %s: ImportError: No module named 'sklearn.__check_build._check_build' ___________________________________________________________________________ Contents of d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build: setup.py _check_build.cp38-win_amd64.pyd__init__.py __pycache__ ___________________________________________________________________________ It seems that scikit-learn has not been built correctly. If you have installed scikit-learn from source, please do not forget to build the package before using it: run `python setup.py install` or `make` in the source directory. If you have used an installer, please check that it is suited for your Python version, your operating system and your platform.
If you regress on rotated factor scores, inspect the loading matrix—factors with low loadings (<0.3) on all components are noise and should be dropped early, or they pollute multiple PCs.
The previous methods are linear. In markets, factor–return links are often nonlinear. Small-cap behavior in bulls vs bears is a classic nonlinear interaction.
ML can capture complex relationships. Three common approaches:
With many factors and severe multicollinearity, OLS estimates are unstable. Ridge adds L2 regularization to shrink coefficients—trading a little bias for much less variance.
from sklearn.linear_model import Ridge
from sklearn.model_selection import cross_val_score
ridge = Ridge(alpha=1.0)
# Next-period returns as labels
scores = cross_val_score(ridge, factor_scaled, returns,
cv=5, scoring='neg_mean_squared_error')
ridge.fit(factor_scaled, returns)
# Combined factor = factor values × coefficients
ridge_factor = np.dot(factor_scaled, ridge.coef_)
--------------------------------------------------------------------------- ModuleNotFoundError Traceback (most recent call last) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 43 try: ---> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ModuleNotFoundError: No module named 'sklearn.__check_build._check_build' During handling of the above exception, another exception occurred: ImportError Traceback (most recent call last) <ipython-input-7-7fd80de13d4d> in <cell line: 0>() ----> 1 from sklearn.linear_model import Ridge 2 from sklearn.model_selection import cross_val_score 3 4 ridge = Ridge(alpha=1.0) 5 # 用未来一期收益作为标签 d:\pythonprojects\venv\Lib\site-packages\sklearn\__init__.py in <module> 79 # it and importing it first would fail if the OpenMP dll cannot be found. 80 from . import _distributor_init # noqa: F401 ---> 81 from . import __check_build # noqa: F401 82 from .base import clone 83 from .utils._show_versions import show_versions d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ---> 46 raise_build_error(e) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in raise_build_error(e) 29 else: 30 dir_content.append(filename + '\n') ---> 31 raise ImportError("""%s 32 ___________________________________________________________________________ 33 Contents of %s: ImportError: No module named 'sklearn.__check_build._check_build' ___________________________________________________________________________ Contents of d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build: setup.py _check_build.cp38-win_amd64.pyd__init__.py __pycache__ ___________________________________________________________________________ It seems that scikit-learn has not been built correctly. If you have installed scikit-learn from source, please do not forget to build the package before using it: run `python setup.py install` or `make` in the source directory. If you have used an installer, please check that it is suited for your Python version, your operating system and your platform.
Tuning tip: choose alpha by grid search, often from 0.01 to 100. Larger alpha pulls coefficients toward 0 (more conservative). Pick the alpha with the lowest cross-validation error.
Ridge never zeros coefficients, so it cannot select features. Elastic Net mixes L1 and L2—shrinks coefficients and drops weak factors.
from sklearn.linear_model import ElasticNet
enet = ElasticNet(alpha=0.01, l1_ratio=0.5)
enet.fit(factor_scaled, returns)
# Which factors were selected?
selected = np.where(enet.coef_ != 0)[0]
print("Selected factor indices:", selected)
--------------------------------------------------------------------------- ModuleNotFoundError Traceback (most recent call last) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 43 try: ---> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ModuleNotFoundError: No module named 'sklearn.__check_build._check_build' During handling of the above exception, another exception occurred: ImportError Traceback (most recent call last) <ipython-input-8-b523dfc79842> in <cell line: 0>() ----> 1 from sklearn.linear_model import ElasticNet 2 3 enet = ElasticNet(alpha=0.01, l1_ratio=0.5) 4 enet.fit(factor_scaled, returns) 5 d:\pythonprojects\venv\Lib\site-packages\sklearn\__init__.py in <module> 79 # it and importing it first would fail if the OpenMP dll cannot be found. 80 from . import _distributor_init # noqa: F401 ---> 81 from . import __check_build # noqa: F401 82 from .base import clone 83 from .utils._show_versions import show_versions d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ---> 46 raise_build_error(e) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in raise_build_error(e) 29 else: 30 dir_content.append(filename + '\n') ---> 31 raise ImportError("""%s 32 ___________________________________________________________________________ 33 Contents of %s: ImportError: No module named 'sklearn.__check_build._check_build' ___________________________________________________________________________ Contents of d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build: setup.py _check_build.cp38-win_amd64.pyd__init__.py __pycache__ ___________________________________________________________________________ It seems that scikit-learn has not been built correctly. If you have installed scikit-learn from source, please do not forget to build the package before using it: run `python setup.py install` or `make` in the source directory. If you have used an installer, please check that it is suited for your Python version, your operating system and your platform.
On ~500 A-share factors, Elastic Net once dropped 300+ factors and lifted the remaining book’s Sharpe from 0.8 to 1.3—sparsity helping.
When factor–return links are nonlinear, trees are a natural choice. They capture interactions—e.g., momentum works when market cap < RMB 5bn and turnover > 5%.
import lightgbm as lgb
model = lgb.LGBMRegressor(
n_estimators=100,
max_depth=3,
learning_rate=0.1,
num_leaves=7
)
model.fit(factor_scaled, returns)
# Feature importance
importance = pd.DataFrame({
'factor': factor_data.columns,
'importance': model.feature_importances_
}).sort_values('importance', ascending=False)
# Combined factor
ml_factor = model.predict(factor_scaled)
--------------------------------------------------------------------------- ModuleNotFoundError Traceback (most recent call last) <ipython-input-9-d6eae67aac86> in <cell line: 0>() ----> 1 import lightgbm as lgb 2 3 model = lgb.LGBMRegressor( 4 n_estimators=100, 5 max_depth=3, ModuleNotFoundError: No module named 'lightgbm'
Strong warning: trees overfit easily. Too many trees or deep trees can yield high in-sample IC and negative out-of-sample IC. Guidelines: depth ≤ 4, leaves ≤ 10, small learning rate (0.01–0.05). Prefer underfit to overfit.
| Method | Pros | Cons | Best for |
|---|---|---|---|
| PCA | Strong dimension reduction, fast | Poor interpretability, linear | Many factors, severe collinearity |
| Factor rotation | Interpretable; supports attribution | Need to choose factor count | Need economic labels |
| Machine learning | Nonlinear; auto feature selection | Overfit risk; heavy tuning | Complex factor–return links |
A common pipeline: PCA down to ~10 PCs, Elastic Net for a second screen, then a shallow XGBoost for nonlinear boost. That stack has been relatively robust live.
No universal winner. PCA for fast reduction, rotation for interpretation, ML for complex links. Combine flexibly from the data.
Multi-factor combination toolkit — Raw factor matrix (n×k) → standardize + winsorize + neutralize → PCA (reduce · decorrelate · max variance) → rotation (Varimax/Promax: interpretability · sparse loadings · attribution) → ML combination (nonlinear · regularization · feature selection) → variance-weighted / IC-weighted PCA / orthogonal or oblique rotation / Ridge–Elastic Net / XGBoost–LightGBM → combined factor (n×1).
Recommended workflow for a multi-factor composite score:
| Scenario | Recommended method | Why |
|---|---|---|
| Few factors (<10), low correlation | Equal / ICIR weight | Simple, robust, low overfit risk |
| Large quality gaps | IC / ICIR weight | Weight by predictive power and stability |
| Many factors, severe collinearity | PCA + Elastic Net | Reduce & decorrelate; auto selection |
| Need attribution / interpretation | Factor rotation (Varimax) | Interpretable loadings |
| Nonlinear factor–return links | Shallow XGBoost/LightGBM | Capture interactions; control overfit |
| Highly correlated pairs (>0.8) | Drop / combine / orthogonalize first | Avoid duplicated info and unstable coeffs |
The next three sections move to the portfolio layer: risk model → optimization / trading → performance attribution.
In any multi-factor system, risk modeling is unavoidable.
The Barra model is the classic structured risk model: split equity return risk into factor-driven systematic risk and stock-specific idiosyncratic risk.
Early on, risk models are often treated as helpers—until a book over-exposes to one industry and market moves produce drawdowns far beyond expectations. Then risk modeling becomes central.
Why “structured”? Because the complex covariance matrix is factored into a clear, interpretable structure.
Estimating pairwise covariances for thousands of stocks yields an N×N matrix that quickly becomes unmanageable; with short samples the estimate is extremely noisy.
A structured risk model instead:
Formula:
Where:
Parameters to estimate fall from O(N²) to O(K² + N). K is typically tens while N may be thousands, so K² is far smaller than N²—that is the structured gain.
Structured models are not magic: they assume co-movement is mainly factor-driven. If that fails, so does the model. In A-shares, extreme episodes can suddenly change factor correlations and create large forecast errors.
The factor covariance F is the heart of the risk model. Its accuracy drives risk forecast quality.
Estimating F usually has two steps: a historical sample estimate, then adjustments such as:
Use the past T periods of factor returns to form the sample covariance:
Here $f_{t}$ is the factor-return vector at $t$, and $\mu_f$ is the mean vector.
Sample covariance has limits—short T means noise; many factors invite extremes that distort estimates.
Ledoit–Wolf shrinkage is common: shrink the sample covariance toward a more stable target (e.g., the identity):
δ is the shrinkage intensity, chosen from the data. Noisy samples raise δ (more target); clean samples lower δ (more sample).
Practical tip: backtests often show shrinkage cuts the gap between predicted and realized risk by ~15%. Gains are clearer with more than 20 factors. Prefer EWMA or shrinkage over a raw rolling window.
Idiosyncratic risk is the volatility the factor model cannot explain. Each stock has its own; stocks are assumed uncorrelated (so D is diagonal).
Estimation is straightforward:
Rough code sketch:
import numpy as np
def estimate_idiosyncratic_risk(returns, factor_returns, factor_loadings):
# returns: T×N stock returns
# factor_returns: T×K factor returns
# factor_loadings: N×K factor loadings
T, N = returns.shape
K = factor_returns.shape[1]
# Factor-implied stock returns
predicted_returns = factor_returns @ factor_loadings.T # T×N
# Idiosyncratic = actual − predicted
idiosyncratic_returns = returns - predicted_returns
# Residual variances
idio_var = np.var(idiosyncratic_returns, axis=0, ddof=1)
# Simple shrinkage
target_var = np.median(idio_var)
shrinkage = 0.2 # use a finer estimate in production
idio_var_shrunk = shrinkage * target_var + (1 - shrinkage) * idio_var
return np.diag(idio_var_shrunk)
Note: idiosyncratic estimates struggle when N is large and history is short. With 3,000 stocks and only 60 months, a raw idiosyncratic matrix is nearly unusable. Structured shrinkage toward industry means improves things.
Risk attribution answers: where does portfolio risk come from?
With portfolio weights w (N×1), portfolio variance is:
Substitute the structured model:
Here:
You can further split total risk by factor and by stock:
| Risk source | Formula | Interpretation |
|---|---|---|
| Factor risk | RC_factor = (w^T B) F (B^T w) | Systematic risk from factor exposures |
| Idiosyncratic risk | RC_idio = w^T D w | Risk from stock-specific volatility |
| Single-factor contribution | RC_k = (w^T B)_k (F B^T w)_k | Marginal contribution of factor k |
| Single-stock contribution | RC_i = w_i² D_ii | Idiosyncratic contribution of stock i |
Often plot a risk-attribution pie: factor vs idiosyncratic share. Very high idiosyncratic share means under-diversification or a weak factor model.
Case: an industry-neutral multi-factor book showed near-zero industry risk (as intended) but size-factor risk ~40% of total—large size exposure that needed rebalancing.
Structured risk models turn a hard problem into an operable frame. With factor decomposition you estimate a K×K factor covariance and N idiosyncratic risks to recover a full N×N covariance.
The value is not only forecasting risk but understanding it. Each attribution paints a risk portrait—which factors drive volatility, which names drag. That clarifies how to adjust.
Emphasize: even good models need market context. A-share factor structure drifts; re-estimate regularly—at least quarterly.
Caution: in the 2015 China crash, optimizing with a risk model estimated six months earlier badly understated market-factor vol and produced larger-than-expected drawdowns. Habit: roll-update—re-estimate factor covariance on the latest 12 months each time.
After mining, testing, and combining signals, the last step is turning signals into holdings—portfolio optimization.
Factor selection names candidates; optimization sets weights. Done poorly, earlier work is wasted. High factor IC with live results far off often points to the optimizer.
Mean–variance optimization dates to Markowitz (1952): minimize risk for a return target, or maximize expected return for a risk budget.
Mathematically, solve:
Here w is the weight vector, Σ the covariance, and μ expected returns.
The theory is clean, but practice is brutal: tiny changes in μ can swing weights wildly—practitioners call it an error maximizer.
⚠️ Caution: feeding historical means as μ often yields extreme weights—e.g., two-stock concentration. Winsorized expected returns help. Sensitivity to μ far exceeds sensitivity to Σ.
Because mean–variance is hypersensitive to return forecasts, one response is to drop return forecasts and control risk only—risk budgeting.
The classic case is risk parity: equalize each name’s contribution to total portfolio risk.
Mathematically:
where
$$ RC_i = w_i (\Sigma w)_i / \sqrt{w'\Sigma w} $$Benefit: no return forecast—only a covariance matrix, which is far more stable than return forecasts.
In practice, the core sleeve of a factor book often uses risk budgeting—e.g., 50% risk-budget weights and 50% for active signals—controlling risk while leaving room for alpha.
💡 Practical tip: risk budgeting needs a solid covariance. Prefer EWMA covariance with a ~60-trading-day half-life—capture recent vol without overreacting.
Mean–variance is too sensitive; risk budgeting ignores returns—is there a middle path? Yes: Black–Litterman.
Core idea: start from equilibrium returns (e.g., from CAPM), treat bullish/bearish views on names as prior information, and fuse them Bayesian-style into posterior expected returns.
Formula:
Where:
The model blends judgment with data. It is widely used in industry rotation: quant signals form views on several industries, BL merges them into equilibrium returns, then optimize.
💡 Rule of thumb: set Ω roughly from the reciprocal of signal IC—higher IC ⇒ smaller Ω ⇒ stronger view. Do not make Ω too small or the model overfits the views.
Theory is clean; live trading has limits. Common constraints:
| Constraint | Example | How |
|---|---|---|
| Weight bounds | Single name ≤ 5% | Box constraints |
| Industry neutrality | Industry active ≤ 2% | Linear equality/inequality |
| Turnover limit | One-way turnover ≤ 20% | L1 penalty or transaction-cost term |
| Lot sizes | Multiples of 100 shares | Mixed-integer (harder) |
More constraints pull the solution farther from the theoretical optimum. With too many, the book may look like equal weight—better to just equal-weight.
Suggestion: add only truly needed constraints. High-frequency strategies need turnover limits; low-frequency value may care more about industry neutrality.
Simplified sketches of the three models’ core logic:
import numpy as np
from scipy.optimize import minimize
# Five stocks: covariance and expected returns
Sigma = np.array([[0.1, 0.02, 0.01, 0.03, 0.02],
[0.02, 0.12, 0.03, 0.02, 0.01],
[0.01, 0.03, 0.08, 0.01, 0.02],
[0.03, 0.02, 0.01, 0.15, 0.03],
[0.02, 0.01, 0.02, 0.03, 0.09]])
mu = np.array([0.12, 0.08, 0.15, 0.06, 0.10])
# 1. Mean–variance (minimum-variance portfolio)
def min_variance(weights):
return weights.T @ Sigma @ weights
cons = ({'type': 'eq', 'fun': lambda w: np.sum(w) - 1})
bounds = [(0, 0.3)] * 5 # single-name cap 30%
w0 = np.ones(5) / 5
res = minimize(min_variance, w0, constraints=cons, bounds=bounds)
print("Mean–variance weights:", res.x)
# 2. Risk budgeting (equal risk contribution)
def risk_parity_obj(weights):
port_var = weights.T @ Sigma @ weights
rc = weights * (Sigma @ weights) / np.sqrt(port_var)
target = port_var / 5 / np.sqrt(port_var)
return np.sum((rc - target)**2)
res_rp = minimize(risk_parity_obj, w0, constraints=cons, bounds=bounds)
print("Risk-budget weights:", res_rp.x)
# 3. Black–Litterman (simplified)
tau = 0.05
Pi = np.array([0.08, 0.08, 0.08, 0.08, 0.08]) # equilibrium returns
P = np.array([[1, 0, 0, 0, 0]]) # bullish on first stock
Q = np.array([0.15]) # expected return 15%
Omega = np.array([[0.01]]) # view confidence
mu_bl = np.linalg.inv(np.linalg.inv(tau*Sigma) + P.T @ np.linalg.inv(Omega) @ P) @ \
(np.linalg.inv(tau*Sigma) @ Pi + P.T @ np.linalg.inv(Omega) @ Q)
print("BL posterior returns:", mu_bl)
# Re-optimize with BL returns
def bl_obj(weights):
return -weights.T @ mu_bl + 0.5 * weights.T @ Sigma @ weights
res_bl = minimize(bl_obj, w0, constraints=cons, bounds=bounds)
print("BL optimized weights:", res_bl.x)
均值-方差权重: [0.21916242 0.1506428 0.28730179 0.10774354 0.23514945] 风险预算权重: [0.20521881 0.18819935 0.22798738 0.16546155 0.21313291] BL后验收益: [0.10333333 0.08466667 0.08233333 0.087 0.08466667] BL优化权重: [0.3 0.14728426 0.23719211 0.10253062 0.21299301]
That closes this section. Optimization is the bridge from research to live books. Start with risk budgeting, then add views and constraints—robust, with room to improve.
The main thread covered mean–variance, risk budgeting, Black–Litterman, and constrained optimization. Another family is parametric portfolio policies: parameterize weights directly as functions of firm characteristics and estimate those parameters from a sample objective (e.g., utility), rather than estimating means/covariances and then solving a two-stage program.
Two contrasts:
Use cases: characteristic-driven equity allocation and mid/low-frequency strategies that need to contain error amplification. Treat them as complements to classic optimizers, not replacements.
In quant investing, a frequently ignored risk is:
Not that the strategy loses money, but that after losses you cannot explain why—and after gains you cannot tell luck from skill.
This section’s core topic is performance attribution.
Attribution provides a clear accounting of returns and risk. A beautiful equity curve with no story for where excess returns came from usually deserves low trust.
Suppose a multi-factor book beats its benchmark. Did excess return come from factor stock selection, industry allocation, or a lucky style rotation?
Without attribution you decide with incomplete information—fast, but blind to risks ahead.
Run a full attribution at least once a quarter—not only for reports, but to locate the sources of excess return.
Core identity: Excess return = allocation + selection + interaction + residual. Attribution’s job is to unpack each piece.
Brinson is the industry classic. It splits excess return into three parts:
Formulas:
Here $W_{p,i}$ / $W_{b,i}$ are portfolio / benchmark weights in industry $i$, and $R_{p,i}$ / $R_{b,i}$ are portfolio / benchmark returns in that industry.
A typical case: high backtest excess, Brinson shows negative allocation and positive selection—industry bets were wrong, but stock picking was strong enough for net positive excess.
Treat such strategies carefully: durable selection skill still has value, but persistent industry drag is a long-run risk.
Practical tip: Brinson is sensitive to industry taxonomy. Prefer SW Level-1 or GICS. Too coarse loses resolution; too fine inflates interaction and hurts interpretability.
Brinson splits by industry. Barra splits by risk factors.
Barra decomposes stock returns as:
Here $\beta_{ik}$ is stock $i$’s exposure to factor $k$, $f_k$ is factor $k$’s return, and $\varepsilon_i$ is residual.
Portfolio excess return then decomposes as:
In other words: where is the book more exposed than the benchmark, and how much return did those factors contribute?
One strategy with ~15% annualized excess showed almost all of it from the size factor—persistent small-cap tilt in a strong small-cap year.
Ask: if small-caps reverse, how does the strategy cope?
Barra’s value is separating skill from luck in return sources.
In multi-factor models, the fear is factors canceling each other—or one failing suddenly.
Factor contribution analysis is a periodic health check on the stack.
Procedure:
A common display:
| Factor | Avg exposure | Factor return | Return contrib. | Risk contrib. | Information ratio |
|---|---|---|---|---|---|
| Value | 0.35 | 2.1% | 0.74% | 0.52% | 1.42 |
| Momentum | 0.28 | 1.5% | 0.42% | 0.38% | 1.11 |
| Quality | 0.22 | 1.8% | 0.40% | 0.29% | 1.38 |
| Low vol | 0.15 | 0.9% | 0.14% | 0.21% | 0.67 |
Here value contributes most with a solid IR; low vol contributes little while taking meaningful risk—consider cutting its weight or dropping it.
Note: contribution analysis must respect factor correlations. Highly related factors interfere in attribution. Orthogonalize first, or results can mislead.
Excess-return decomposition peels total excess down until each piece’s source is clear.
A common framework:
Total excess return
├── Allocation (industry / style)
│ ├── Industry allocation
│ └── Style allocation
├── Selection
│ ├── Within-industry selection
│ └── Within-style selection
├── Factor returns
│ ├── Factor-exposure returns
│ └── Factor-timing returns
└── Residual (luck component)
The framework shows return sources from different angles. High factor returns with low selection look more like enhanced indexing than active stock picking.
Case: a strategy with high excess return but a low Sharpe.
Decomposition showed excess mostly from industry allocation—heavy in a sector that rallied hard—while selection was negative. Such books often lose excess quickly when industries rotate.
Decomposition reveals the structure under headline performance.
A standard workflow:
Attribution flow (for organizing the analysis):
Factor performance attribution flow — Portfolio & benchmark returns → choose method (Brinson / Barra / factor contribution) → allocation, selection, factor-exposure returns, residual, factor return/risk contributions → attribution report & improvement actions.
Common practical caveats:
Think of attribution as a regular health check. Run it on a schedule to know whether the strategy is healthy; without it, reliability is hard to judge.
Hopefully this helps you account for returns and risk more clearly.