diff --git a/strategies/momentum_quality.py b/strategies/momentum_quality.py index f56581e..293b4cc 100644 --- a/strategies/momentum_quality.py +++ b/strategies/momentum_quality.py @@ -46,9 +46,13 @@ class MomentumQualityStrategy(Strategy): inv_dd = rolling_max_dd(data, self.quality_window) # --- Cross-sectional ranking --- - mom_rank = momentum.rank(axis=1, pct=True, na_option="bottom") - con_rank = consistency.rank(axis=1, pct=True, na_option="bottom") - dd_rank = inv_dd.rank(axis=1, pct=True, na_option="bottom") + # na_option="keep" so NaN stocks stay NaN in the composite. With + # "bottom" + default ascending=True, NaN entries receive pct=1.0 and + # the additive composite ends up maximal for NaN rows, silently + # selecting pre-IPO / delisted names as "top". + mom_rank = momentum.rank(axis=1, pct=True, na_option="keep") + con_rank = consistency.rank(axis=1, pct=True, na_option="keep") + dd_rank = inv_dd.rank(axis=1, pct=True, na_option="keep") # Composite: momentum 50%, consistency 25%, drawdown 25% scores = 0.50 * mom_rank + 0.25 * con_rank + 0.25 * dd_rank diff --git a/strategies/multi_factor.py b/strategies/multi_factor.py index eb26cdf..637a3e9 100644 --- a/strategies/multi_factor.py +++ b/strategies/multi_factor.py @@ -49,8 +49,10 @@ class MultiFactorStrategy(Strategy): value = stock.rolling(self.value_period).min() / stock # --- Cross-sectional ranking (each row ranked across assets) --- - mom_rank = momentum.rank(axis=1, pct=True, na_option="bottom") - val_rank = value.rank(axis=1, pct=True, na_option="bottom") + # na_option="keep" so NaN stocks (pre-IPO / delisted / masked) stay + # NaN in the composite score instead of being assigned pct=1.0. + mom_rank = momentum.rank(axis=1, pct=True, na_option="keep") + val_rank = value.rank(axis=1, pct=True, na_option="keep") scores = mom_rank + val_rank # combined score, higher = better # --- Select top_n assets per row --- diff --git a/strategies/recovery_momentum.py b/strategies/recovery_momentum.py index 896f3e0..acae59f 100644 --- a/strategies/recovery_momentum.py +++ b/strategies/recovery_momentum.py @@ -36,9 +36,13 @@ class RecoveryMomentumStrategy(Strategy): # Factor 2: 12-1 month momentum momentum = data.shift(self.mom_skip).pct_change(self.mom_lookback - self.mom_skip) - # Cross-sectional percentile ranks - rec_rank = recovery.rank(axis=1, pct=True, na_option="bottom") - mom_rank = momentum.rank(axis=1, pct=True, na_option="bottom") + # Cross-sectional percentile ranks. na_option="keep" is critical: + # with "bottom" + ascending=True default, NaN stocks get pct=1.0, so + # non-member / delisted / pre-IPO names leak into the composite as + # "top" candidates. "keep" propagates NaN and the final ascending=False + # + "bottom" rank pushes them to the end where they are not selected. + rec_rank = recovery.rank(axis=1, pct=True, na_option="keep") + mom_rank = momentum.rank(axis=1, pct=True, na_option="keep") # Composite score (50/50) composite = 0.5 * rec_rank + 0.5 * mom_rank