@@ -71,6 +71,32 @@ def trend_factor(prices: pd.DataFrame, short_window: int = 50,
7171 return zs
7272
7373
74+ def trend_filter (prices : pd .DataFrame , long_window : int = 200 ,
75+ penalty : float = 0.5 ) -> pd .DataFrame :
76+ """Trend filter: penalize stocks trading below their 200-day SMA.
77+
78+ Returns a DataFrame of multipliers (1.0 for uptrend, `penalty` for downtrend).
79+ Used as a post-composite filter rather than a scored factor.
80+ """
81+ sma_long = prices .rolling (long_window ).mean ()
82+ above = prices >= sma_long
83+ return above .astype (float ).replace (0.0 , penalty )
84+
85+
86+ def blowoff_filter (prices : pd .DataFrame , window : int = 20 ,
87+ zscore_limit : float = 3.0 , penalty : float = 0.5 ) -> pd .DataFrame :
88+ """Penalize stocks with extreme short-term gains (blowoff top protection).
89+
90+ If a stock's Bollinger z-score exceeds `zscore_limit`, its composite score
91+ is multiplied by `penalty`. Prevents chasing parabolic moves.
92+ """
93+ rolling_mean = prices .rolling (window ).mean ()
94+ rolling_std = prices .rolling (window ).std ()
95+ zscore = (prices - rolling_mean ) / rolling_std
96+ overextended = zscore > zscore_limit
97+ return (~ overextended ).astype (float ).replace (0.0 , penalty )
98+
99+
74100def volatility_factor (returns : pd .DataFrame , window : int = 63 ) -> pd .DataFrame :
75101 """Realized volatility factor (low-vol anomaly: prefer lower vol).
76102
@@ -152,6 +178,13 @@ def generate(self, prices: pd.DataFrame, returns: pd.DataFrame,
152178 fundamentals : pd .DataFrame = None ) -> pd .DataFrame :
153179 """Produce a composite alpha score for each symbol on each date.
154180
181+ Pipeline:
182+ 1. Compute scored factors (momentum, volatility, value, quality, etc.)
183+ 2. Build weighted composite from scored factors
184+ 3. Apply post-composite filters:
185+ - Trend filter: penalize stocks below 200d SMA
186+ - Blowoff filter: penalize stocks with extreme short-term gains
187+
155188 Returns DataFrame (dates x symbols) of composite z-scores.
156189 """
157190 symbols = [c for c in prices .columns if c != self .benchmark ]
@@ -182,17 +215,26 @@ def generate(self, prices: pd.DataFrame, returns: pd.DataFrame,
182215 df = df .reindex (columns = px .columns )
183216 factors [name ] = df
184217
185- # Weighted composite
218+ # Weighted composite (only factors with weight > 0 contribute)
186219 composite = pd .DataFrame (0.0 , index = px .index , columns = px .columns )
187220 total_weight = 0.0
188221 for name , weight in self .weights .items ():
189- if name in factors :
222+ if weight > 0 and name in factors :
190223 f = factors [name ].reindex (index = px .index , columns = px .columns )
191224 composite += weight * f .fillna (0 )
192225 total_weight += weight
193226
194227 if total_weight > 0 :
195228 composite /= total_weight
196229
230+ # --- Post-composite filters ---
231+ # Trend filter: penalize stocks below 200d SMA (score *= 0.5)
232+ tf = trend_filter (px , long_window = self .sma_long )
233+ composite = composite * tf .reindex (index = composite .index , columns = composite .columns ).fillna (1.0 )
234+
235+ # Blowoff filter: penalize stocks with extreme overbought z-score > threshold
236+ bf = blowoff_filter (px , window = self .mr_window , zscore_limit = self .mr_threshold )
237+ composite = composite * bf .reindex (index = composite .index , columns = composite .columns ).fillna (1.0 )
238+
197239 logger .info ("Generated composite signal: shape=%s" , composite .shape )
198240 return composite
0 commit comments