@@ -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+
521632def 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