Binary pass/fail filters give you 50 candidates with no ranking. A composite scoring engine assigns each candidate a 0-100 score built from technical strength, momentum, risk profile, and catalyst proximity. The score IS the edge.
After filtering, you have 50-80 candidates. Every single one passed the same criteria. But they are not equal. A stock with RSI at 14 (deeply oversold) is a much stronger mean-reversion candidate than one at RSI 29 (barely oversold). A stock breaking out on 3x volume is more convincing than one on 1.6x volume.
The scoring system captures these differences. It transforms binary pass/fail into a continuous 0-100 score that quantifies how much each stock meets your criteria.
The formula has three components:
The raw score is a weighted average of four factors. Each factor is scored 0-100 independently, then combined with configurable weights.
| Factor | Weight | What It Measures | Key Inputs |
|---|---|---|---|
| Technical | 40% | Price action quality and trend alignment | EMA alignment, RSI position, volume confirmation, support proximity |
| Momentum | 25% | Speed and strength of the move | Rate of change, relative strength vs sector/market, MACD histogram |
| Risk-Adjusted | 20% | Return quality normalized by risk | ATR-normalized returns, drawdown history, win rate |
| Catalyst | 15% | Proximity to potential price-moving events | Earnings date, analyst target distance, sector momentum |
The technical score measures how "clean" the price action is. A stock with perfect EMA alignment, RSI in the bullish sweet spot, and above-average volume scores higher than one with mixed signals.
def score_technical(stock): """Score technical setup quality 0-100.""" score = 0 # EMA Alignment (0-30 points) if stock.ema_20 > stock.ema_50 > stock.ema_200: score += 30 # Perfect bullish alignment elif stock.ema_20 > stock.ema_50: score += 20 # Short-term bullish elif stock.close > stock.ema_200: score += 10 # At least above long-term trend # RSI Position (0-25 points) rsi = stock.rsi_14 if 55 <= rsi <= 70: score += 25 # Bullish sweet spot elif 40 <= rsi < 55: score += 15 # Neutral, room to run elif rsi < 30: score += 20 # Oversold (good for mean reversion) elif rsi > 80: score += 5 # Overbought, risky entry # Volume Confirmation (0-25 points) vol_ratio = stock.volume / stock.avg_vol_20 if vol_ratio >= 2.0: score += 25 # Strong volume surge elif vol_ratio >= 1.5: score += 20 elif vol_ratio >= 1.0: score += 10 # Support Proximity (0-20 points) pct_above_ema50 = (stock.close - stock.ema_50) / stock.ema_50 if 0 < pct_above_ema50 < 0.02: score += 20 # Sitting right on support elif 0.02 <= pct_above_ema50 < 0.05: score += 15 elif pct_above_ema50 >= 0.05: score += 8 # Extended from support return min(score, 100)
Momentum measures the rate of price change, not just the direction. A stock that gained 15% in 20 days scores higher than one that gained 3%. Relative strength — how the stock performs versus its sector — is the secret weapon here.
def score_momentum(stock): """Score momentum strength 0-100.""" score = 0 # Rate of Change 20-day (0-35 points) roc = stock.roc_20 if roc > 10: score += 35 elif roc > 5: score += 25 elif roc > 2: score += 15 elif roc > 0: score += 8 # Relative Strength vs Sector (0-35 points) rs = stock.rs_sector # stock_roc_20 - sector_roc_20 if rs > 8: score += 35 elif rs > 4: score += 25 elif rs > 0: score += 15 else: score += 5 # MACD Histogram (0-30 points) if stock.macd_hist > 0 and stock.macd_hist > stock.macd_hist_prev: score += 30 # Positive and increasing elif stock.macd_hist > 0: score += 20 # Positive but decelerating elif stock.macd_hist > stock.macd_hist_prev: score += 10 # Negative but improving return min(score, 100)
Not all returns are equal. A 10% gain with 2% ATR (ATR-normalized return of 5x) is far more impressive than a 10% gain with 8% ATR (1.25x). This factor rewards stocks that deliver returns relative to their volatility.
def score_risk_adjusted(stock): """Score risk-adjusted quality 0-100.""" score = 0 # ATR-Normalized Return (0-40 points) if stock.atr_pct_14 > 0: atr_norm = stock.roc_20 / (stock.atr_pct_14 * 100) if atr_norm > 3: score += 40 elif atr_norm > 2: score += 30 elif atr_norm > 1: score += 20 elif atr_norm > 0: score += 10 # Max Drawdown 60d (0-30 points) dd = abs(stock.max_drawdown_60) if dd < 5: score += 30 elif dd < 10: score += 20 elif dd < 20: score += 10 # Win Rate 20d (0-30 points) if stock.win_rate_20 > 60: score += 30 elif stock.win_rate_20 > 55: score += 20 elif stock.win_rate_20 > 50: score += 10 return min(score, 100)
Catalysts give a trade a time horizon and a reason. A stock with earnings in 10 days and an analyst target 20% above current price has more potential energy than one with no upcoming events.
def score_catalyst(stock): """Score catalyst proximity 0-100.""" score = 0 # Earnings Proximity (0-30 points) days = stock.days_to_earnings if 5 <= days <= 15: score += 30 elif 15 < days <= 30: score += 15 elif days < 5: score += 5 # Too close (gap risk) # Analyst Target Distance (0-35 points) upside = stock.analyst_target_upside if upside > 30: score += 35 elif upside > 15: score += 25 elif upside > 5: score += 15 # Sector Momentum (0-35 points) if stock.sector_roc_20 > 5: score += 35 elif stock.sector_roc_20 > 2: score += 20 elif stock.sector_roc_20 > 0: score += 10 return min(score, 100)
After computing the raw score, we add bonus points for positive catalysts. These are "soft" signals that increase the probability of a successful trade.
| Catalyst | Boost | Cap | Why It Matters |
|---|---|---|---|
| Insider Buying | +15 per txn | +45 | Insiders buy for one reason: they think the stock is going up. Multiple buys = high conviction. |
| Analyst Upgrade | +20 per upgrade | +40 | Upgrades often precede institutional buying. Double upgrade = very bullish. |
| 13D Filing (Activist) | +25 | +25 | An activist just disclosed a >5% stake with intent to influence. Major catalyst. |
| Unusual Options Activity | +10 | +10 | Abnormal call volume suggests smart money positioning for upside. |
| Buyback Announcement | +15 | +15 | Company buying its own shares = management believes stock is undervalued. |
Every boost has a cap to prevent a single catalyst from dominating the score. An insider who buys 10 times does not make the stock 10x better — the first 3 transactions carry the most information, then marginal signal fades.
def calculate_boosts(stock): """Calculate total score boosts from positive catalysts.""" boosts = 0 # Insider buying (last 90 days) boosts += min(stock.insider_buy_count_90d * 15, 45) # Analyst upgrades (last 30 days) boosts += min(stock.analyst_upgrade_count_30d * 20, 40) # 13D activist filing if stock.has_13d_recent: boosts += 25 # Unusual options activity if stock.options_unusual_call_activity: boosts += 10 # Buyback announcement (last 60 days) if stock.has_buyback_announcement: boosts += 15 return boosts
Risk penalties catch stocks that look technically great but have hidden dangers. A stock can score 90/100 on technicals but get destroyed by a secondary offering the next day.
| Risk Flag | Penalty | Why It Is Dangerous |
|---|---|---|
| Insider Selling | -15 per txn | Cluster selling is a red flag. Multiple executives dumping simultaneously = something is wrong. |
| Analyst Downgrade | -25 per downgrade | Downgrades precede institutional selling. Bad news travels faster than good news. |
| Dilution Risk (S-3/S-1) | -30 | Company may issue new shares, diluting existing shareholders. Common in biotech and growth. |
| High CTB (>30%) | -15 | Either shorts are aggressively betting against it, or the stock is so broken shares are hard to find. |
| Earnings < 3 Days | -20 | Binary event risk. Stock could gap 10% either way. Technical analysis is irrelevant post-earnings. |
If total penalty points exceed 75, the stock is automatically rejected regardless of its technical score. This circuit breaker prevents the scanner from recommending ticking time bombs. A stock with 3 insider sells, a downgrade, and an S-3 filing scores -100 in penalties — it does not matter if RSI is at 15.
def calculate_penalties(stock): """Calculate total risk penalties. > 75 = auto-reject.""" penalties = 0 penalties += stock.insider_sell_count_90d * 15 penalties += stock.analyst_downgrade_count_30d * 25 if stock.has_s3_or_s1_filing: penalties += 30 if stock.cost_to_borrow > 30: penalties += 15 if stock.days_to_earnings < 3: penalties += 20 return penalties def should_reject(stock): return calculate_penalties(stock) > 75
The final score must be normalized to 0-100 and filtered against strategy-specific minimum thresholds.
def normalize_score(raw_score, boosts, penalties, strategy_weight): """Normalize final score to 0-100.""" weighted = raw_score * (strategy_weight / 100) adjusted = weighted + boosts - penalties return round(max(0, min(100, adjusted)), 1)
| Strategy | Min Score | Min Technical | Max Penalty | Rationale |
|---|---|---|---|---|
| Contraction | 65 | 50 | 40 | Higher bar — false contraction breakouts are expensive |
| Momentum | 70 | 60 | 30 | Needs strong conviction — momentum trades have no second chance |
| Breakout | 75 | 65 | 25 | Strictest — breakout failures are the most painful losses |
| Mean Reversion | 60 | 40 | 50 | Lower technical bar (stock is oversold by definition) but still needs catalyst support |
A mean-reversion trade starts from a technically weak position — that is the whole point, the stock is oversold. Requiring a technical score of 65 would filter out every valid candidate. But a breakout trade should have pristine technical alignment — if you are buying at the 20-day high, everything must scream "GO." Different strategies have different quality criteria.
class ScoringEngine: """Multi-factor scoring with boosts and penalties.""" def __init__(self, weights=None): self.weights = weights or { 'technical': 0.40, 'momentum': 0.25, 'risk_adjusted': 0.20, 'catalyst': 0.15, } def score_stock(self, stock, strategy_name, strategy_weight): """Score a single stock. Returns dict with all components.""" # Kill switch penalties = calculate_penalties(stock) if penalties > 75: return {'ticker': stock.ticker, 'final_score': 0, 'rejected': True, 'reason': 'Risk kill switch'} # Factor scores tech = score_technical(stock) mom = score_momentum(stock) risk = score_risk_adjusted(stock) cat = score_catalyst(stock) # Weighted raw score raw = (tech * self.weights['technical'] + mom * self.weights['momentum'] + risk * self.weights['risk_adjusted'] + cat * self.weights['catalyst']) boosts = calculate_boosts(stock) final = normalize_score(raw, boosts, penalties, strategy_weight) return { 'ticker': stock.ticker, 'strategy': strategy_name, 'final_score': final, 'raw_score': round(raw, 1), 'technical': tech, 'momentum': mom, 'risk_adjusted': risk, 'catalyst': cat, 'boosts': boosts, 'penalties': penalties, 'rejected': False, } def score_candidates(self, candidates_df, strategy_name, strategy_weight): """Score all candidates. Returns sorted DataFrame.""" results = [] for _, stock in candidates_df.iterrows(): result = self.score_stock(stock, strategy_name, strategy_weight) results.append(result) scored = pd.DataFrame(results) scored = scored[~scored['rejected']] return scored.sort_values('final_score', ascending=False)