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.
Consider how most people build a screener in Python:
# 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:
| Problem | Impact | DSL 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.
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.
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.
| Variable | Description | Computation |
|---|---|---|
close | Latest adjusted close price | Direct from data |
open | Latest open price | Direct from data |
high | Latest session high | Direct from data |
low | Latest session low | Direct from data |
volume | Latest session volume | Direct from data |
typical | Typical price: (H+L+C)/3 | (high + low + close) / 3 |
range_pct | Daily range as % of close | (high - low) / close |
| Function | Description | Parameters |
|---|---|---|
ema(N) | Exponential Moving Average | N = period (20, 50, 200) |
sma(N) | Simple Moving Average | N = period |
ema_cross_up(fast, slow) | EMA fast just crossed above slow | fast=20, slow=50 typical |
ema_cross_down(fast, slow) | EMA fast just crossed below slow | Same |
price_above_ema(N) | Close > EMA(N) | Convenience alias |
| Function | Description | Typical Use |
|---|---|---|
rsi(N) | Relative Strength Index | <30 oversold, >70 overbought |
macd_hist() | MACD histogram value | Positive = 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 |
| Function | Description | Why It Matters |
|---|---|---|
atr(N) | Average True Range | Absolute volatility for stop placement |
atr_pct(N) | ATR as % of price | Normalized volatility for comparison across stocks |
bbw(N) | Bollinger Band Width | Low = contraction (pre-breakout), High = expansion |
bb_upper(N) | Upper Bollinger Band | Resistance zone |
bb_lower(N) | Lower Bollinger Band | Support zone |
| Function | Description | Signal |
|---|---|---|
avg_vol(N) | Average volume over N days | Baseline for spike detection |
vol_ratio(N) | volume / avg_vol(N) | >1.5 = notable, >2.0 = spike |
obv_slope(N) | On-Balance Volume trend | Positive = accumulation |
vwap() | Volume-Weighted Avg Price | Above VWAP = bullish intraday |
| Function | Description | Use Case |
|---|---|---|
highest(series, N) | Highest value of series over N bars | Breakout detection |
lowest(series, N) | Lowest value of series over N bars | Support / breakdown |
pct_from_high(N) | % below N-bar high | Pullback depth |
pct_from_low(N) | % above N-bar low | Recovery strength |
near_breakout(N, pct) | Within pct% of N-bar high | Pre-breakout detection |
| Variable | Description | Source |
|---|---|---|
sector | GICS sector name | Reference data |
market_cap | Market capitalization | Quote data |
days_to_earnings | Trading days until next earnings | Calendar |
avg_spread_pct | Average bid-ask spread % | Quote data |
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:
The simplest approach uses Python's pandas.eval() or numexpr for fast, safe evaluation of mathematical expressions against DataFrames.
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)
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.
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
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.
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:
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.
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.
Identifies stocks in strong uptrends with expanding momentum. The "ride the wave" strategy — you are buying strength, not weakness.
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.
Catches stocks making new highs with volume confirmation. The highest-reward, lowest-win-rate strategy — when it works, it works big.
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.
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.
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.
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 } # }, # ... # }
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 Rate | Diagnosis | Action |
|---|---|---|
| < 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 |
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]
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.
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.
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)