Build a Stock Scanner Series — Part 2 of 6

Building a Filter Language — Your Scanner's Brain

Hardcoded filters break every time the market changes. A DSL (Domain-Specific Language) lets you write, swap, and combine filter expressions on the fly. We build one from scratch with 4 battle-tested strategies.

DSL Architecture 4 Strategies Technical Indicators Pass Rate Testing
Build a Stock Scanner That Actually Works2/6
Why a DSL?Filter VocabularyExpression Evaluator4 StrategiesTesting FiltersKey Takeaways
Why a DSL?

Why Hardcoded Filters Suck

Consider how most people build a screener in Python:

Python — The Hardcoded Approach
# This works... until it doesn't
def screen_oversold(stock):
    return (
        stock.rsi_14 < 30
        and stock.volume > stock.avg_volume_20 * 1.5
        and stock.close > stock.ema_200
    )

# New strategy? Write another function.
# Tweak thresholds? Edit the code and redeploy.
# A/B test two filter sets? Copy-paste and pray.

This approach has three fatal problems:

ProblemImpactDSL Solution
Rigid Every threshold change requires code modification and redeployment Filters are strings — change them via config file, database, or API
Non-composable Combining filters requires writing new functions for every combination Expressions combine naturally: filter_a AND filter_b
Not regime-swappable Cannot change strategy weights at runtime based on market conditions Store filter sets per regime, activate the right one each morning

A DSL (Domain-Specific Language) solves all three. Instead of writing Python functions, you write expressions that get evaluated against each stock's data. The expressions are just strings — you can store them in config files, swap them at runtime, and combine them programmatically.

DSL Expression Example
rsi(14) < 30 AND volume > avg_vol(20) * 1.5 AND close > ema(200)

This expression is readable, testable, and — critically — swappable. You can have a JSON file with four strategy definitions, each containing a filter expression. Every morning, the regime detector picks which strategies to weight heavily, and the scanner loads the corresponding filter expressions. No code changes required.

Filter Vocabulary

Designing Your Filter Vocabulary

Before building the evaluator, you need to define what your DSL can express. Think of this as the "dictionary" of your filter language. Every variable and function you support must be computable from your data.

Price Series Variables

VariableDescriptionComputation
closeLatest adjusted close priceDirect from data
openLatest open priceDirect from data
highLatest session highDirect from data
lowLatest session lowDirect from data
volumeLatest session volumeDirect from data
typicalTypical price: (H+L+C)/3(high + low + close) / 3
range_pctDaily range as % of close(high - low) / close

Moving Averages & Crossovers

FunctionDescriptionParameters
ema(N)Exponential Moving AverageN = period (20, 50, 200)
sma(N)Simple Moving AverageN = period
ema_cross_up(fast, slow)EMA fast just crossed above slowfast=20, slow=50 typical
ema_cross_down(fast, slow)EMA fast just crossed below slowSame
price_above_ema(N)Close > EMA(N)Convenience alias

Momentum & Oscillators

FunctionDescriptionTypical Use
rsi(N)Relative Strength Index<30 oversold, >70 overbought
macd_hist()MACD histogram valuePositive = bullish momentum
adx(N)Average Directional Index>25 trending, <20 ranging
roc(N)Rate of Change (N bars)Momentum magnitude
stoch_k(N)Stochastic %K<20 oversold, >80 overbought

Volatility

FunctionDescriptionWhy It Matters
atr(N)Average True RangeAbsolute volatility for stop placement
atr_pct(N)ATR as % of priceNormalized volatility for comparison across stocks
bbw(N)Bollinger Band WidthLow = contraction (pre-breakout), High = expansion
bb_upper(N)Upper Bollinger BandResistance zone
bb_lower(N)Lower Bollinger BandSupport zone

Volume Analysis

FunctionDescriptionSignal
avg_vol(N)Average volume over N daysBaseline for spike detection
vol_ratio(N)volume / avg_vol(N)>1.5 = notable, >2.0 = spike
obv_slope(N)On-Balance Volume trendPositive = accumulation
vwap()Volume-Weighted Avg PriceAbove VWAP = bullish intraday

Extremes & Patterns

FunctionDescriptionUse Case
highest(series, N)Highest value of series over N barsBreakout detection
lowest(series, N)Lowest value of series over N barsSupport / breakdown
pct_from_high(N)% below N-bar highPullback depth
pct_from_low(N)% above N-bar lowRecovery strength
near_breakout(N, pct)Within pct% of N-bar highPre-breakout detection

Context Variables

VariableDescriptionSource
sectorGICS sector nameReference data
market_capMarket capitalizationQuote data
days_to_earningsTrading days until next earningsCalendar
avg_spread_pctAverage bid-ask spread %Quote data
Building the Expression Evaluator

From String to Signal

The evaluator is the engine that takes a filter expression (a string) and evaluates it to True or False for each stock. There are two approaches:

Approach 1: Safe Eval with Pandas

The simplest approach uses Python's pandas.eval() or numexpr for fast, safe evaluation of mathematical expressions against DataFrames.

Python — Pandas Eval Approach
import pandas as pd
import numpy as np

class IndicatorEngine:
    """Pre-compute all indicators for the universe."""

    def compute(self, df):
        """df: DataFrame with OHLCV columns, one row per stock (latest bar).
        Returns DataFrame with all indicator columns added."""

        # Moving Averages (pre-computed from bar history)
        # These would be computed from full history, stored as latest value
        # df['ema_20'], df['ema_50'], df['ema_200'] already present

        # RSI
        # df['rsi_14'] already computed from bar history

        # Bollinger Band Width
        df['bbw_20'] = (df['bb_upper_20'] - df['bb_lower_20']) / df['sma_20']

        # ATR as percentage of price
        df['atr_pct_14'] = df['atr_14'] / df['close']

        # Volume ratio
        df['vol_ratio_20'] = df['volume'] / df['avg_vol_20']

        # Breakout proximity
        df['pct_from_high_20'] = (df['highest_20'] - df['close']) / df['highest_20']

        return df


class FilterEvaluator:
    """Evaluate DSL expressions against stock data."""

    def evaluate(self, df, expression):
        """Evaluate a filter expression.

        expression: e.g. 'rsi_14 < 30 and vol_ratio_20 > 1.5'
        Returns: boolean Series (True for stocks that pass)
        """
        # pandas.eval handles AND/OR/comparison safely
        try:
            mask = df.eval(expression)
            return mask
        except Exception as e:
            print(f"Filter error: {e}")
            return pd.Series(False, index=df.index)

Approach 2: Custom AST Parser

For more complex expressions — especially those with custom functions like near_breakout(20, 0.02) — you need a proper expression parser. Python's ast module provides safe parsing.

Python — AST-Based Evaluator
import ast
import operator

class SafeExprEvaluator:
    """Safely evaluate expressions with custom functions."""

    OPERATORS = {
        ast.Lt: operator.lt, ast.Gt: operator.gt,
        ast.LtE: operator.le, ast.GtE: operator.ge,
        ast.Eq: operator.eq, ast.NotEq: operator.ne,
        ast.Add: operator.add, ast.Sub: operator.sub,
        ast.Mult: operator.mul, ast.Div: operator.truediv,
        ast.And: lambda a, b: a & b,
        ast.Or: lambda a, b: a | b,
    }

    def __init__(self, stock_data):
        # stock_data: dict of variable_name -> value
        self.data = stock_data
        self.functions = {
            'ema': self._ema,
            'sma': self._sma,
            'rsi': self._rsi,
            'atr_pct': self._atr_pct,
            'avg_vol': self._avg_vol,
            'highest': self._highest,
            'near_breakout': self._near_breakout,
        }

    def _ema(self, period):
        return self.data.get(f'ema_{period}', 0)

    def _rsi(self, period):
        return self.data.get(f'rsi_{period}', 50)

    def _near_breakout(self, lookback, threshold):
        high_n = self.data.get(f'highest_{lookback}', 0)
        close = self.data['close']
        if high_n == 0: return False
        return (high_n - close) / high_n <= threshold

    def evaluate(self, expression):
        """Parse and evaluate a filter expression safely."""
        tree = ast.parse(expression, mode='eval')
        return self._eval_node(tree.body)

    def _eval_node(self, node):
        # Recursively evaluate AST nodes
        # ... (handles Name, Call, Compare, BoolOp, etc.)
        pass  # Full implementation ~80 lines

Which Approach to Choose?

Pandas eval is great for simple expressions using pre-computed columns. It is fast (vectorized) and handles 90% of use cases. AST parsing is needed when you want custom functions like near_breakout(20, 0.02) that cannot be expressed as simple column operations. In practice, most production scanners use a hybrid: pre-compute indicators into columns, then use pandas eval for the filter logic.

Four Classic Strategies

Strategy-Specific Filters

Your scanner should run multiple strategies simultaneously, each with its own filter expression. Here are four battle-tested strategies that cover the main market regimes:

Pre-Breakout

Strategy 1: Volatility Contraction

Finds stocks where volatility has squeezed to unusually low levels, suggesting an imminent directional move. Think of it as a coiled spring — the tighter the squeeze, the bigger the eventual move.

Filter Expression
bbw_20 < sma(bbw_20, 20) AND rsi_14 > 45 AND atr_pct_14 < 0.03 AND close > ema_200

Logic: Bollinger Band Width below its own 20-day average (squeeze), RSI neutral-to-bullish (not dumping), ATR under 3% of price (low volatility), and above the 200-day EMA (uptrend context). This filter finds the "quiet before the storm" — stocks that are consolidating in an uptrend.

Best regime: Neutral to Risk-On. In Risk-Off, contraction often precedes breakdown, not breakout.

Trend Following

Strategy 2: Momentum Expansion

Identifies stocks in strong uptrends with expanding momentum. The "ride the wave" strategy — you are buying strength, not weakness.

Filter Expression
ema_20 > ema_50 AND ema_50 > ema_200 AND rsi_14 > 55 AND rsi_14 < 80 AND vol_ratio_20 > 1.2

Logic: Perfect EMA alignment (20 > 50 > 200 = strong uptrend), RSI in the bullish sweet spot (55-80, not overbought), and volume confirmation above average. This is the textbook trend-following setup.

Best regime: Risk-On. This strategy gets destroyed in Risk-Off environments — you are buying momentum into a falling market.

Breakout

Strategy 3: Breakout

Catches stocks making new highs with volume confirmation. The highest-reward, lowest-win-rate strategy — when it works, it works big.

Filter Expression
close >= highest_20 AND vol_ratio_20 > 1.5 AND rsi_14 > 55 AND adx_14 > 20

Logic: Price at or above the 20-day high (breakout), volume 50% above average (real demand, not a head-fake), RSI confirming bullish momentum, and ADX indicating a trending (not ranging) market. The ADX filter is critical — breakouts in ranging markets have a terrible hit rate.

Best regime: Risk-On to early Recovery. Breakouts in Risk-Off are almost always bull traps.

Mean Reversion

Strategy 4: Mean Reversion (Oversold Bounce)

Finds quality stocks that have sold off too far, too fast. You are buying fear and selling the recovery. The bread-and-butter strategy for risk-off environments.

Filter Expression
rsi_14 < 35 AND vol_ratio_20 > 2.0 AND close > ema_200 AND pct_from_high_20 > 0.08

Logic: RSI oversold below 35, heavy volume (capitulation selling), but still above the 200-EMA (the long-term trend is intact), and pulled back at least 8% from the 20-day high. The EMA(200) filter is the key — it ensures you are buying a pullback in an uptrend, not catching a falling knife in a downtrend.

Best regime: Any regime, but especially Risk-Off and early Recovery. This is the "buy the dip when the trend is still up" strategy.

Putting It Together: The Strategy Registry

Python — Strategy Registry
import json

class StrategyRegistry:
    """Load and manage filter strategies from config."""

    def __init__(self, config_path="strategies.json"):
        with open(config_path) as f:
            self.strategies = json.load(f)

    def get_filter(self, strategy_name):
        """Return the filter expression for a strategy."""
        return self.strategies[strategy_name]["filter"]

    def get_active(self, regime):
        """Return strategies active for the current regime."""
        return {
            name: strat
            for name, strat in self.strategies.items()
            if regime in strat["regimes"]
        }


# strategies.json
# {
#   "contraction": {
#     "filter": "bbw_20 < sma_bbw_20 and rsi_14 > 45 and atr_pct_14 < 0.03",
#     "regimes": ["risk_on", "neutral", "recovery"],
#     "weight": { "risk_on": 20, "neutral": 30, "risk_off": 5 }
#   },
#   "momentum": {
#     "filter": "ema_20 > ema_50 and ema_50 > ema_200 and rsi_14 > 55",
#     "regimes": ["risk_on", "recovery"],
#     "weight": { "risk_on": 45, "neutral": 20, "risk_off": 5 }
#   },
#   ...
# }
Testing Your Filters

The Pass Rate Problem

How do you know if your filter is good? There is a simple diagnostic: the pass rate. Run your filter against the entire universe and count how many stocks pass.

Pass RateDiagnosisAction
< 1% (0-5 stocks)Too tight. You are missing good setups. Possibly an impossible combination of criteria.Relax thresholds or remove one criterion
1-5% (5-25 stocks)Ideal for most strategies. Enough candidates to score and rank, few enough to be meaningful.Perfect zone
5-15% (25-75 stocks)Borderline. Your scoring engine will need to do heavy lifting to differentiate.Consider adding one more filter criterion
> 15% (75+ stocks)Too loose. Your filter is not filtering. The signal-to-noise ratio is poor.Add criteria or tighten thresholds significantly
Python — Pass Rate Diagnostics
def diagnose_filter(evaluator, universe_df, expression, strategy_name):
    """Run a filter and print diagnostics."""
    mask = evaluator.evaluate(universe_df, expression)
    passed = mask.sum()
    total = len(universe_df)
    rate = passed / total * 100

    print(f"\n{'='*50}")
    print(f"Strategy: {strategy_name}")
    print(f"Expression: {expression}")
    print(f"Universe: {total} stocks")
    print(f"Passed: {passed} ({rate:.1f}%)")

    if rate < 1:
        print("[WARN] Too tight - consider relaxing thresholds")
    elif rate > 15:
        print("[WARN] Too loose - add criteria or tighten")
    else:
        print("[OK] Pass rate in healthy range")

    # Show sector distribution of passed stocks
    if passed > 0:
        sectors = universe_df[mask]['sector'].value_counts()
        print(f"\nSector distribution:")
        for sector, count in sectors.head(5).items():
            print(f"  {sector}: {count}")

    return universe_df[mask]

Filter Overlap Analysis

Another important diagnostic is how much your filters overlap. If two strategies pass the same stocks, they are not providing independent signals — you are effectively running one strategy, not two.

Watch for Over-Optimization

It is tempting to keep tweaking thresholds until your filter produces exactly 10 stocks on today's data. This is curve-fitting. Your filter should produce consistent, reasonable pass rates across different market conditions. Test it on 6 months of historical data, not just today.

The Complete Filter Pipeline

Python — Full Pipeline
class ScannerPipeline:
    """Complete filter pipeline: load, compute, filter, report."""

    def __init__(self):
        self.indicators = IndicatorEngine()
        self.evaluator = FilterEvaluator()
        self.strategies = StrategyRegistry()

    def run(self, universe_df, regime):
        """Run all active strategies for the current regime."""

        # Step 1: Compute all indicators
        df = self.indicators.compute(universe_df)

        # Step 2: Get active strategies for this regime
        active = self.strategies.get_active(regime)
        print(f"Regime: {regime} -> {len(active)} active strategies")

        # Step 3: Run each filter
        all_candidates = {}
        for name, strat in active.items():
            passed = self.evaluator.evaluate(df, strat["filter"])
            candidates = df[passed].copy()
            candidates["strategy"] = name
            candidates["strategy_weight"] = strat["weight"][regime]
            all_candidates[name] = candidates
            print(f"  {name}: {len(candidates)} candidates")

        # Step 4: Merge and deduplicate
        merged = pd.concat(all_candidates.values())
        # If a stock appears in multiple strategies, keep the one
        # with the highest strategy weight (best regime fit)
        merged = merged.sort_values("strategy_weight", ascending=False)
        merged = merged.drop_duplicates(subset=["ticker"], keep="first")

        print(f"\nTotal unique candidates: {len(merged)}")
        return merged


# Usage
pipeline = ScannerPipeline()
candidates = pipeline.run(universe_df, regime="risk_on")
# -> Next step: scoring (Part 3)
Key Takeaways

Key Takeaways — Part 2

Part 3 of 6
Composite Scoring — Ranking from Noise to Signal
Build a Stock Scanner That Actually Works2/6