Build a Stock Scanner Series — Part 3 of 6

Composite Scoring — Ranking from Noise to Signal

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.

Multi-Factor Scoring Opportunity Boosts Risk Penalties Score Decomposition
Build a Stock Scanner That Actually Works3/6
The Scoring FormulaScore FactorsOpportunity BoostsRisk PenaltiesNormalizationImplementationKey Takeaways
The Scoring Formula

Why Binary Pass/Fail Is Not Enough

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 Master Scoring Formula
finalScore = (rawScore x strategyWeight) + boosts - penalties

The formula has three components:

The Four Score Factors

Building the Raw Score

The raw score is a weighted average of four factors. Each factor is scored 0-100 independently, then combined with configurable weights.

FactorWeightWhat It MeasuresKey Inputs
Technical40%Price action quality and trend alignmentEMA alignment, RSI position, volume confirmation, support proximity
Momentum25%Speed and strength of the moveRate of change, relative strength vs sector/market, MACD histogram
Risk-Adjusted20%Return quality normalized by riskATR-normalized returns, drawdown history, win rate
Catalyst15%Proximity to potential price-moving eventsEarnings date, analyst target distance, sector momentum

Factor 1: Technical Score (40%)

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.

Python — Technical Score
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)

Factor 2: Momentum Score (25%)

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.

Python — Momentum Score
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)

Factor 3: Risk-Adjusted Score (20%)

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.

Python — Risk-Adjusted Score
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)

Factor 4: Catalyst Score (15%)

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.

Python — Catalyst Score
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)
Opportunity Boosts

Score Boosters — Catalysts That Matter

After computing the raw score, we add bonus points for positive catalysts. These are "soft" signals that increase the probability of a successful trade.

CatalystBoostCapWhy It Matters
Insider Buying+15 per txn+45Insiders buy for one reason: they think the stock is going up. Multiple buys = high conviction.
Analyst Upgrade+20 per upgrade+40Upgrades often precede institutional buying. Double upgrade = very bullish.
13D Filing (Activist)+25+25An activist just disclosed a >5% stake with intent to influence. Major catalyst.
Unusual Options Activity+10+10Abnormal call volume suggests smart money positioning for upside.
Buyback Announcement+15+15Company buying its own shares = management believes stock is undervalued.

The Cap Matters

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.

Python — Boost Calculator
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

Risk Detection — Red Flags That Kill Trades

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 FlagPenaltyWhy It Is Dangerous
Insider Selling-15 per txnCluster selling is a red flag. Multiple executives dumping simultaneously = something is wrong.
Analyst Downgrade-25 per downgradeDowngrades precede institutional selling. Bad news travels faster than good news.
Dilution Risk (S-3/S-1)-30Company may issue new shares, diluting existing shareholders. Common in biotech and growth.
High CTB (>30%)-15Either shorts are aggressively betting against it, or the stock is so broken shares are hard to find.
Earnings < 3 Days-20Binary event risk. Stock could gap 10% either way. Technical analysis is irrelevant post-earnings.

The Kill Switch: Risk Score > 75 = Auto-Reject

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.

Python — Penalty Calculator with Kill Switch
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
Score Normalization

Normalization and Strategy Thresholds

The final score must be normalized to 0-100 and filtered against strategy-specific minimum thresholds.

Python — Normalization
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)

Minimum Thresholds by Strategy

StrategyMin ScoreMin TechnicalMax PenaltyRationale
Contraction655040Higher bar — false contraction breakouts are expensive
Momentum706030Needs strong conviction — momentum trades have no second chance
Breakout756525Strictest — breakout failures are the most painful losses
Mean Reversion604050Lower technical bar (stock is oversold by definition) but still needs catalyst support

Why Different Thresholds?

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.

Full Implementation

The Complete Scoring Engine

Python — ScoringEngine Class
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)
Key Takeaways

Key Takeaways — Part 3

Part 4 of 6
Regime Detection — The Scanner That Adapts to the Market
Build a Stock Scanner That Actually Works3/6