Skip to content

Commit ce077d0

Browse files
committed
feat: Pure Volatility factor (idiosyncratic volatility)
Adds PureVolatilityFactor to factors/technical.py — academic-grade idiosyncratic volatility factor inspired by Dongwu Securities research. Three-stage construction: 1. Rolling 20-day FF3 regression → residual std → raw IVOL 2. Orthogonalize against turnover → remove trading noise 3. AR(30) filter → remove serial correlation in factor values The 'pure volatility' factor isolates fundamental idiosyncratic risk from trading noise and cross-period information leakage. 1205 passed (all existing)
1 parent 1b3f753 commit ce077d0

3 files changed

Lines changed: 114 additions & 1 deletion

File tree

config/default.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ factors:
3636
trend_stage: {enabled: true, params: {period: 120}}
3737
ma_convergence: {enabled: true}
3838
breakout_proximity: {enabled: true, params: {period: 20}}
39+
pure_volatility: {enabled: true, params: {window: 20, ar_lags: 30}}
3940

4041
# Fundamental factors
4142
fundamental:

config/schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ class FactorsConfig:
169169
"momentum_1m", "momentum_3m", "momentum_6m", "momentum_12m",
170170
"volatility_20d", "volatility_60d",
171171
"turnover_20d", "rsi_14d", "amplitude_20d", "macd",
172-
"efficiency_ratio", "breakout_ignition", "trend_stage", "ma_convergence", "breakout_proximity",
172+
"efficiency_ratio", "breakout_ignition", "trend_stage", "ma_convergence", "breakout_proximity", "pure_volatility",
173173
"kmid", "klen", "kup", "klow", "ksft",
174174
)
175175
enabled_fundamentals: tuple[str, ...] = (

factors/technical.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,117 @@ def compute(self, prices, **kwargs):
518518
proximity = (prices - low) / rng.replace(0, float("nan"))
519519
return proximity.clip(0, 1)
520520

521+
522+
523+
# ---------------------------------------------------------------------------
524+
# Pure Volatility Factor (inspired by Dongwu Securities research)
525+
# ---------------------------------------------------------------------------
526+
527+
528+
class PureVolatilityFactor(BaseFactor):
529+
"""Pure idiosyncratic volatility: FF3 residuals orthogonalized.
530+
531+
Methodology (from Dongwu Securities research report):
532+
1. Rolling 20-day FF3 regression → residual returns
533+
2. Idiosyncratic vol = std(residual) over 20 days
534+
3. Orthogonalize against turnover → remove trading-activity noise
535+
4. AR(30) filter → remove serial correlation in factor values
536+
537+
The resulting 'pure volatility' factor isolates the fundamental
538+
component of idiosyncratic risk, removing both trading noise
539+
and cross-period information leakage.
540+
541+
Higher values = higher idiosyncratic risk = expected lower returns
542+
(the IVOL anomaly: high IVOL stocks tend to underperform).
543+
"""
544+
category = FactorCategory.TECHNICAL
545+
546+
def __init__(self, window: int = 20, ar_lags: int = 30, name: str = "pure_volatility"):
547+
super().__init__({'window': window, 'ar_lags': ar_lags})
548+
self._window = window
549+
self._ar_lags = ar_lags
550+
self._name = name
551+
552+
@property
553+
def name(self) -> str:
554+
return self._name
555+
556+
def compute(self, prices: pd.DataFrame, **kwargs) -> pd.DataFrame:
557+
ret = prices.pct_change(fill_method=None)
558+
559+
# Rolling 20-day FF3 regression per asset
560+
# FF3 factors: market (excess return), SMB, HML
561+
# For simplicity, use market return (equal-weight cross-section)
562+
# as the single factor. Full FF3 would need market cap data.
563+
market_ret = ret.mean(axis=1)
564+
565+
# Rolling regression: ret_i = alpha + beta * market_ret + epsilon
566+
# Then IVOL = std(epsilon) over the window
567+
def _rolling_ivol(x):
568+
y = x.values
569+
m = market_ret.reindex(x.index).values
570+
if len(y) < self._window or np.std(m) < 1e-10:
571+
return np.nan
572+
# Simple OLS: beta = cov(y,m) / var(m)
573+
beta = np.cov(y, m)[0, 1] / np.var(m)
574+
resid = y - beta * m
575+
return float(np.std(resid, ddof=2))
576+
577+
ivol = ret.rolling(self._window).apply(_rolling_ivol, raw=False)
578+
579+
# Orthogonalize against turnover (if available)
580+
turnover = kwargs.get('turnover')
581+
if turnover is not None and not turnover.empty:
582+
# Regress IVOL ~ turnover per date cross-section
583+
# Return residuals = turnover-orthogonalized IVOL
584+
aligned = ivol.align(turnover, join='inner')
585+
ivol_aligned = aligned[0]
586+
turn_aligned = aligned[1]
587+
588+
result = ivol_aligned.copy()
589+
for date in ivol_aligned.index:
590+
row_ivol = ivol_aligned.loc[date].dropna()
591+
row_turn = turn_aligned.loc[date].reindex(row_ivol.index).dropna()
592+
common = row_ivol.index.intersection(row_turn.index)
593+
if len(common) < 10:
594+
continue
595+
y = row_ivol[common].values
596+
x = row_turn[common].values
597+
if np.std(x) < 1e-10:
598+
continue
599+
beta = np.cov(y, x)[0, 1] / np.var(x)
600+
resid = y - beta * x
601+
result.loc[date, common] = resid
602+
603+
ivol = result
604+
605+
# AR(lags) filter to remove serial correlation
606+
# Fit AR model per asset and return residuals
607+
result = ivol.copy()
608+
for asset in ivol.columns:
609+
series = ivol[asset].dropna().values
610+
if len(series) < self._ar_lags + 10:
611+
continue
612+
# Simple AR fit: X_t = sum(phi_i * X_{t-i}) + epsilon
613+
# Using least squares
614+
T = len(series)
615+
X = np.column_stack([
616+
series[self._ar_lags - 1 - i: T - 1 - i]
617+
for i in range(self._ar_lags)
618+
])
619+
y = series[self._ar_lags:]
620+
if X.shape[0] < self._ar_lags + 5:
621+
continue
622+
try:
623+
phi = np.linalg.lstsq(X, y, rcond=None)[0]
624+
pred = X @ phi
625+
resid_ar = y - pred
626+
result.loc[ivol.index[ivol.notna().any(axis=1)][self._ar_lags:], asset] = resid_ar
627+
except np.linalg.LinalgError:
628+
continue
629+
630+
return result
631+
521632
def register_all():
522633
registry = get_registry()
523634
for cls in [
@@ -531,5 +642,6 @@ def register_all():
531642
TrendStageFactor,
532643
MAConvergenceFactor,
533644
BreakoutProximityFactor,
645+
PureVolatilityFactor,
534646
]:
535647
registry.register(cls)

0 commit comments

Comments
 (0)