Six common style factors: Value, Size, Profitability, Momentum, Low Volatility, and Quality.
The first three form the core of the Fama–French three-factor model and show significant risk premia in long-run evidence across markets. The latter three more often reflect pricing frictions and investor behavior; they remain useful in empirical work and allocation practice. Factor tests and portfolio construction often start from these families.
The value factor means buying cheap stocks. Two common measures are:
Finance textbooks more often present:
$P/E$ is the reciprocal of earnings yield $E/P$; $P/B$ is the reciprocal of book-to-market. In practice, prefer $E/P$ and $B/M$: they are closer to normality and easier to use in cross-sectional regressions.
Stocks with high $B/M$ or high $E/P$ tend to have higher expected returns—one of the core findings of the Fama–French three-factor model.
Using raw $B/M$ as a factor works poorly because industry levels differ sharply: banks tend to have high $B/M$, tech stocks low. Without industry neutralization, the portfolio collapses into banks.
A practical approach:
Implementation is straightforward:
import pandas as pd
import numpy as np
def calc_value_factor(df):
"""
df: DataFrame with 'bm' and 'industry' columns
Returns industry-neutralized value factor
"""
df['value_raw'] = df['bm']
# Industry neutralization
df['value_factor'] = df.groupby('industry')['value_raw'].transform(
lambda x: (x - x.mean) / x.std
)
return df
For A-share data, prefer a trailing 12-month rolling $B/M$ over the latest report only. Filing lags make “latest” data prone to look-ahead bias.
The size factor is the small-cap effect: small-cap stocks have historically outperformed large-caps across many markets.
In recent years the size premium has weakened. In A-shares, for example, large-caps broadly beat small-caps over 2017–2020, and small-cap-heavy strategies took large drawdowns. Possible reasons:
In that setting, prefer:
Code example:
def calc_size_factor(df):
"""
df: DataFrame with 'market_cap'
Returns negative log market cap as the size factor
"""
df['size_factor'] = -np.log(df['market_cap'])
# Optional: size buckets
df['size_quantile'] = pd.qcut(df['market_cap'], 10, labels=False)
return df
In live trading, small-cap strategies face binding liquidity constraints. Backtests can look strong, but low turnover and high impact often erode realized returns. Apply liquidity screens and drop names that are too small.
The profitability factor means buying profitable firms. The intuition is simple: companies that earn persistently should appreciate over time.
Common profitability metrics include:
| Metric | Formula | Notes |
|---|---|---|
| ROE | Net income / shareholders' equity | Most common; amplified by leverage |
| ROA | Net income / total assets | Strips leverage; more robust |
| Gross margin | (Revenue − COGS) / revenue | Pricing power; large industry gaps |
| F-Score | Nine financial signals combined | Piotroski composite score |
Prefer ROE with two adjustments:
Implementation:
def calc_profitability_factor(df):
"""
df: DataFrame with 'roe' and 'industry'
Returns industry-neutralized profitability factor
"""
# Exclude financials
df = df[df['industry'] != '金融']
# 3-year rolling average ROE
df['roe_avg'] = df.groupby('stock_id')['roe'].rolling(3).mean.values
# Industry neutralization
df['profit_factor'] = df.groupby('industry')['roe_avg'].transform(
lambda x: (x - x.mean) / x.std
)
return df
Profitability and value are often negatively correlated: high-profit firms tend to trade rich (low $B/M$), while low-profit firms can look cheap (high $B/M$). If you use both, orthogonalize—otherwise they cancel.
Momentum (also called a trend factor) is straightforward: recent winners tend to keep winning for a while; recent losers tend to keep losing—winners stay winners, losers stay losers.
Momentum often shines in bull markets but loses stability in sharp style rotations. In sudden risk events or market reversals, prior winners can be sold aggressively, producing large drawdowns. Momentum is sensitive to reversals.
Core construction: buy the top 30% by return over the past N months (typically 12, skipping the most recent month) and sell the bottom 30%.
One detail often missed: skip the most recent month. A-shares show strong short-term reversal—last month’s winners often underperform next month. Without the skip, factor IC can flip negative.
The formula is simple:
# Momentum: past 12-month return, skipping the most recent month
def momentum_factor(price_df, lookback=12, skip=1):
# price_df: daily prices; columns = tickers, rows = dates
# Past ~12 months (~252 trading days) cumulative return
ret_12m = price_df.pct_change(252)
# Skip most recent ~1 month (~21 trading days)
ret_1m = price_df.pct_change(21)
# Momentum = 12m return − 1m return
momentum = ret_12m - ret_1m
return momentum
How does momentum fare in A-shares? Over 2010–2023, monthly IC averaged roughly 0.03–0.05 and IR about 0.5—not spectacular, but fairly steady, aside from larger drawdowns around the 2016 and 2021 style rotations.
Momentum works best with other factors—e.g., momentum + low vol, or momentum + quality. Standalone momentum can draw down hard in style switches. Add a trend-strength filter: enable the signal only when the past three months’ moving averages are in a bullish stack.
The low-vol factor (low-volatility anomaly) is counterintuitive: lower-volatility stocks often earn higher subsequent returns—the opposite of a simple high-risk/high-return story.
One explanation: institutions under ranking pressure chase high-beta, high-vol names to beat peers in the short run. That lottery-like demand bids up high-vol valuations and depresses future returns, while neglected low-vol stocks offer a margin of safety.
There are many constructions; a common one is the inverse of historical volatility:
# Low-vol factor: inverse of std of past 60 daily returns
def low_vol_factor(price_df, window=60):
# Daily returns
daily_ret = price_df.pct_change
# Rolling std (annualized)
vol = daily_ret.rolling(window).std * np.sqrt(252)
# Low-vol factor = 1 / volatility
low_vol = 1 / vol
return low_vol
A pitfall: raw inverse volatility is highly dispersed. Rank-standardize volatility first, then invert—smoother exposures and better stock selection.
Low vol has performed solidly in A-shares: monthly IC around 0.04–0.06, with low correlation to the broad market—often a hedge in selloffs. Many defensive strategies treat it as a core sleeve.
Low vol is not a panacea. Late in bull markets it lags as capital chases high-vol names. In H1 2019 low vol did well; in H2, as tech surged, it trailed CSI 300 by more than ten points.
Quality is the “good company” factor: strong profitability, sound finances, and durable economics. The logic is simple—good firms should outperform poor ones over the long run.
If markets were fully efficient, quality would already be priced in. In practice, markets err: good firms can be sold on short-term news; weak firms can be bid up on themes. Quality harvests the correction.
Quality is usually a composite. A common three-pillar setup:
| Dimension | Metric | Formula |
|---|---|---|
| Profitability | ROE | Net income / book equity |
| Financial health | Leverage | Total liabilities / total assets (invert) |
| Earnings stability | Gross-margin volatility | Std of gross margin over past 5 years (invert) |
Composition is simple: z-score (or rank-standardize) each pillar, then equal-weight:
# Quality: composite of ROE, low leverage, and earnings stability
def quality_factor(financial_df):
# Assume columns: ROE, 资产负债率 (leverage), 毛利率波动率 (GM vol)
# Z-score standardize
roe_z = (financial_df['ROE'] - financial_df['ROE'].mean) / financial_df['ROE'].std
leverage_z = (financial_df['资产负债率'] - financial_df['资产负债率'].mean) / financial_df['资产负债率'].std
# Lower leverage is better → flip sign
leverage_z = -leverage_z
stability_z = (financial_df['毛利率波动率'] - financial_df['毛利率波动率'].mean) / financial_df['毛利率波动率'].std
# Lower volatility is better → flip sign
stability_z = -stability_z
# Equal-weight composite
quality = (roe_z + leverage_z + stability_z) / 3
return quality
Quality in A-shares is steady: monthly IC around 0.02–0.04—modest but consistently positive—and low correlation with other factors, so it diversifies well.
Adding revenue growth to quality often hurt: high-growth names tend to be expensive. Swapping growth for gross-margin stability improved results. Focus quality on quality, not growth.
How should the three factors be combined?
Many beginners equal-weight then backtest—and underperform the best single factor. Correlation means naive weights amplify overlapping information.
A better workflow:
The three factors are only a starting point. Refine them with domain judgment—e.g., splitting value into book and sentiment components can help.
Putting the three side by side:
| Factor | Logic | Mean IC | IR | Max drawdown |
|---|---|---|---|---|
| Momentum | Winners persist | 0.03–0.05 | 0.5 | Larger (style switches) |
| Low vol | Low-risk anomaly | 0.04–0.06 | 0.6 | Smaller (except late bulls) |
| Quality | Quality premium | 0.02–0.04 | 0.4 | Smaller |
Correlations: momentum and low vol are negatively related—momentum chases strength; low vol seeks safety. Quality is weakly related to both, so the trio diversifies well.
Example mix: 40% momentum + 30% low vol + 30% quality—capture momentum in bulls, lean on low vol and quality in bears. Weights can adapt to regime—e.g., trim momentum and raise low vol when sentiment runs hot.
None of the three is a holy grail—momentum fails, low vol lags, quality hits value traps. Combined with size, value, and dividend factors discussed earlier, they form a more robust multi-factor system.
← Previous chapter · Back to contents · Next chapter →