Technical Indicators from Scratch
Building Your Indicator Library
Most traders use indicators as black boxes — they plug in RSI(14) without understanding the math. Building indicators from scratch gives you three advantages: (1) you can customize parameters beyond what TradingView allows, (2) you can combine them into composite signals, and (3) you understand exactly what you're measuring.
RSI — Relative Strength Index
Python — RSI Implementation (Wilder's Smoothing)
import numpy as np
import pandas as pd
def rsi(close: pd.Series, period: int = 14) -> pd.Series:
"""Relative Strength Index using Wilder's smoothing.
This matches TradingView's RSI exactly (not SMA-based).
"""
delta = close.diff()
gain = delta.where(delta > 0, 0.0)
loss = (-delta).where(delta < 0, 0.0)
# Wilder's smoothing (exponential with alpha = 1/period)
avg_gain = gain.ewm(alpha=1/period, min_periods=period).mean()
avg_loss = loss.ewm(alpha=1/period, min_periods=period).mean()
rs = avg_gain / avg_loss
return 100 - (100 / (1 + rs))
def rsi_signal(close: pd.Series, period: int = 14) -> pd.Series:
"""Generate buy/sell signals from RSI."""
r = rsi(close, period)
signal = pd.Series(0, index=close.index)
signal[r < 30] = 1 # Oversold = buy signal
signal[r > 70] = -1 # Overbought = sell signal
return signal
MACD — Moving Average Convergence Divergence
Python — MACD with Signal Line and Histogram
def macd(
close: pd.Series,
fast: int = 12,
slow: int = 26,
signal_period: int = 9
) -> pd.DataFrame:
"""MACD line, signal line, and histogram."""
ema_fast = close.ewm(span=fast, adjust=False).mean()
ema_slow = close.ewm(span=slow, adjust=False).mean()
macd_line = ema_fast - ema_slow
signal_line = macd_line.ewm(span=signal_period, adjust=False).mean()
histogram = macd_line - signal_line
return pd.DataFrame({
"macd": macd_line,
"signal": signal_line,
"histogram": histogram,
})
def macd_crossover(close: pd.Series) -> pd.Series:
"""Detect MACD crossover signals."""
m = macd(close)
prev_hist = m["histogram"].shift(1)
curr_hist = m["histogram"]
signal = pd.Series(0, index=close.index)
signal[(prev_hist < 0) & (curr_hist > 0)] = 1 # Bullish cross
signal[(prev_hist > 0) & (curr_hist < 0)] = -1 # Bearish cross
return signal
Bollinger Bands, ATR, and OBV
Python — Bollinger Bands, ATR, OBV
def bollinger_bands(close: pd.Series, period: int = 20, std: float = 2.0):
"""Bollinger Bands: middle, upper, lower, %B, bandwidth."""
sma = close.rolling(period).mean()
rolling_std = close.rolling(period).std()
upper = sma + std * rolling_std
lower = sma - std * rolling_std
pct_b = (close - lower) / (upper - lower)
bandwidth = (upper - lower) / sma
return pd.DataFrame({
"bb_mid": sma, "bb_upper": upper,
"bb_lower": lower, "bb_pctb": pct_b,
"bb_bw": bandwidth
})
def atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14):
"""Average True Range — measures volatility."""
tr1 = high - low
tr2 = (high - close.shift(1)).abs()
tr3 = (low - close.shift(1)).abs()
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
return tr.ewm(alpha=1/period, min_periods=period).mean()
def obv(close: pd.Series, volume: pd.Series):
"""On-Balance Volume — cumulative volume pressure."""
direction = np.sign(close.diff())
return (volume * direction).cumsum()
The Indicator Trap
More indicators does not mean better signals. The #1 mistake: stacking 10 indicators that all measure the same thing (momentum). RSI, Stochastics, CCI, Williams %R — they're all momentum oscillators. Using all four gives you the illusion of confluence when you really have one signal repeated four times. Use one indicator per category: one momentum (RSI), one trend (MACD or EMA), one volatility (ATR or Bollinger), one volume (OBV).
Building a Screener Engine
The DSL-Based Screener
A screener is just a filter pipeline — you define conditions, and the engine returns symbols that match all of them. The key insight: build a Domain-Specific Language (DSL) so you can express complex filters in a readable format:
DSL — Screener Filter Examples
# Oversold bounce setup
rsi(14) < 35
close > ema(200)
volume > sma_volume(20) * 1.5
sector != "Utilities"
# Breakout momentum
close > high(52w)
rsi(14) > 55 AND rsi(14) < 75
macd_histogram > 0
atr(14) / close > 0.02
# Mean reversion play
bb_pctb(20,2) < 0.1
rsi(14) < 30
close > ema(50)
insider_buys(90d) > 0
Python — Screener Engine Core
from dataclasses import dataclass
from typing import Callable
@dataclass
class Filter:
"""A single screening condition."""
name: str
fn: Callable[[pd.DataFrame], pd.Series] # returns boolean mask
weight: float = 1.0
class ScreenerEngine:
"""DSL-based stock screener with scoring."""
def __init__(self, db):
self.db = db
self.filters: list[Filter] = []
self.universe: list[str] = []
def set_universe(self, symbols: list[str]):
self.universe = symbols
return self
def add_filter(self, name: str, fn, weight: float = 1.0):
self.filters.append(Filter(name=name, fn=fn, weight=weight))
return self
async def run(self, top_n: int = 20) -> pd.DataFrame:
"""Execute all filters and return scored results."""
# Load data for entire universe
data = await self.db.get_bars(self.universe, period="1Y")
# Compute indicators for each symbol
results = []
for sym in self.universe:
df = data[data["symbol"] == sym].copy()
if len(df) < 50:
continue
# Apply each filter, track pass/fail
score = 0
passes = 0
details = {}
for f in self.filters:
try:
mask = f.fn(df)
passed = bool(mask.iloc[-1])
if passed:
score += f.weight
passes += 1
details[f.name] = passed
except:
details[f.name] = None
if passes == len(self.filters): # all filters pass
results.append({
"symbol": sym,
"score": score,
"close": df["close"].iloc[-1],
**details
})
return (pd.DataFrame(results)
.sort_values("score", ascending=False)
.head(top_n))
# Usage
screener = ScreenerEngine(db)
screener.set_universe(sp500_symbols)
screener.add_filter("RSI oversold", lambda df: rsi(df["close"]) < 35, weight=2.0)
screener.add_filter("Above 200 EMA", lambda df: df["close"] > df["close"].ewm(span=200).mean(), weight=1.5)
screener.add_filter("Volume surge", lambda df: df["volume"] > df["volume"].rolling(20).mean() * 1.5)
results = await screener.run(top_n=10)
Pattern Detection
Detecting Breakouts, Support/Resistance, Volume Anomalies
Pattern detection takes you beyond simple indicator thresholds. You're looking for structural changes in price behavior — breakouts from ranges, tests of key levels, and volume spikes that signal institutional activity.
Python — Support/Resistance Detection
def find_support_resistance(
df: pd.DataFrame,
lookback: int = 120,
touches: int = 3,
tolerance: float = 0.02
) -> dict:
"""Find support/resistance levels from price history.
A level is valid if price touches it >= `touches` times
within `tolerance` (2% default).
"""
recent = df.tail(lookback)
highs = recent["high"].values
lows = recent["low"].values
closes = recent["close"].values
# Find local pivots (swing highs and lows)
pivot_highs = []
pivot_lows = []
for i in range(2, len(recent) - 2):
if highs[i] > highs[i-1] and highs[i] > highs[i+1]:
pivot_highs.append(highs[i])
if lows[i] < lows[i-1] and lows[i] < lows[i+1]:
pivot_lows.append(lows[i])
# Cluster pivots into levels
def cluster(values, tol):
if not values: return []
sorted_v = sorted(values)
clusters = [[sorted_v[0]]]
for v in sorted_v[1:]:
if abs(v - np.mean(clusters[-1])) / np.mean(clusters[-1]) < tol:
clusters[-1].append(v)
else:
clusters.append([v])
return [
{"level": np.mean(c), "touches": len(c), "strength": len(c) / lookback}
for c in clusters if len(c) >= touches
]
current_price = closes[-1]
resistance = [l for l in cluster(pivot_highs, tolerance) if l["level"] > current_price]
support = [l for l in cluster(pivot_lows, tolerance) if l["level"] < current_price]
return {"support": support, "resistance": resistance, "price": current_price}
Python — Breakout Detection
def detect_breakout(
df: pd.DataFrame,
consolidation_days: int = 20,
volume_multiplier: float = 2.0,
range_threshold: float = 0.08 # 8% range = tight consolidation
) -> dict | None:
"""Detect breakout from consolidation range."""
recent = df.tail(consolidation_days + 1)
consolidation = recent.iloc[:-1]
today = recent.iloc[-1]
# Check if consolidation range is tight enough
range_high = consolidation["high"].max()
range_low = consolidation["low"].min()
range_pct = (range_high - range_low) / range_low
if range_pct > range_threshold:
return None # Not consolidating
avg_vol = consolidation["volume"].mean()
# Bullish breakout
if today["close"] > range_high and today["volume"] > avg_vol * volume_multiplier:
return {
"type": "bullish_breakout",
"entry": today["close"],
"stop": range_low,
"target": today["close"] + (range_high - range_low),
"volume_ratio": today["volume"] / avg_vol,
}
return None
Composite Scoring
From Signals to Scores — Weighting and Ranking
Individual indicators are noisy. The magic happens when you combine multiple independent signals into a composite score. This is exactly what professional quant shops do — they call it "alpha combination."
Python — Composite Signal Scorer
class CompositeScorer:
"""Multi-signal scoring engine with category weighting."""
CATEGORIES = {
"momentum": 0.25, # RSI, Stochastics
"trend": 0.30, # EMA cross, MACD, ADX
"volatility": 0.15, # ATR squeeze, Bollinger %B
"volume": 0.15, # OBV trend, volume spike
"fundamental": 0.15, # Insider buys, earnings surprise
}
def score(self, df: pd.DataFrame) -> dict:
scores = {}
# Momentum (0-100)
r = rsi(df["close"]).iloc[-1]
scores["momentum"] = max(0, min(100,
(70 - abs(r - 50)) * 2.5 # Peak at RSI 50, fade at extremes
))
# Trend (0-100)
m = macd(df["close"])
ema50 = df["close"].ewm(span=50).mean().iloc[-1]
ema200 = df["close"].ewm(span=200).mean().iloc[-1]
price = df["close"].iloc[-1]
trend_score = 0
if price > ema200: trend_score += 40
if price > ema50: trend_score += 30
if m["histogram"].iloc[-1] > 0: trend_score += 30
scores["trend"] = trend_score
# Volatility (0-100) — prefer moderate volatility
bb = bollinger_bands(df["close"])
bw = bb["bb_bw"].iloc[-1]
scores["volatility"] = max(0, min(100,
100 - abs(bw - 0.08) * 500
))
# Volume (0-100)
vol_ratio = df["volume"].iloc[-1] / df["volume"].rolling(20).mean().iloc[-1]
scores["volume"] = min(100, vol_ratio * 50)
# Weighted composite
composite = sum(
scores[cat] * weight
for cat, weight in self.CATEGORIES.items()
if cat in scores
)
return {
"composite": round(composite, 1),
"components": scores,
"grade": self._grade(composite),
}
def _grade(self, score: float) -> str:
if score >= 85: return "A+"
if score >= 75: return "A"
if score >= 65: return "B+"
if score >= 55: return "B"
return "C"
Backtesting Your Signals
Walk-Forward Backtesting
A signal that hasn't been backtested is just a guess. Walk-forward validation is the gold standard — it simulates what would have happened if you'd been trading this signal in real time, with no look-ahead bias.
Why Walk-Forward, Not Simple Backtest?
A simple backtest optimizes parameters on the full history, then tests on the same data — guaranteed overfitting. Walk-forward splits your data into rolling windows: optimize on window 1, test on window 2, then slide forward. Repeat. If the signal works across multiple out-of-sample windows, it's likely real, not curve-fitted.
Python — Walk-Forward Backtester
from dataclasses import dataclass, field
@dataclass
class Trade:
symbol: str
entry_date: str
entry_price: float
exit_date: str = ""
exit_price: float = 0.0
pnl_pct: float = 0.0
hit_tp: bool = False
hit_stop: bool = False
class WalkForwardBacktester:
"""Walk-forward backtest with fixed R/R."""
def __init__(self, stop_pct: float = 0.05, tp_pct: float = 0.10, max_hold: int = 20):
self.stop_pct = stop_pct
self.tp_pct = tp_pct
self.max_hold = max_hold
def backtest(self, df: pd.DataFrame, signals: pd.Series) -> list[Trade]:
"""Run backtest on buy signals (1 = buy, 0 = no signal)."""
trades = []
i = 0
while i < len(df):
if signals.iloc[i] != 1:
i += 1
continue
entry = df.iloc[i]
stop = entry["close"] * (1 - self.stop_pct)
target = entry["close"] * (1 + self.tp_pct)
trade = Trade(
symbol=entry.get("symbol", ""),
entry_date=str(df.index[i]),
entry_price=entry["close"],
)
# Simulate forward
for j in range(i + 1, min(i + self.max_hold, len(df))):
bar = df.iloc[j]
if bar["low"] <= stop:
trade.exit_price = stop
trade.hit_stop = True
break
if bar["high"] >= target:
trade.exit_price = target
trade.hit_tp = True
break
else:
trade.exit_price = df.iloc[min(i+self.max_hold, len(df)-1)]["close"]
trade.exit_date = str(df.index[j if 'j' in dir() else i])
trade.pnl_pct = (trade.exit_price - trade.entry_price) / trade.entry_price
trades.append(trade)
i = j + 1 if trade.hit_tp or trade.hit_stop else i + self.max_hold
return trades
def summary(self, trades: list[Trade]) -> dict:
if not trades: return {}
wins = [t for t in trades if t.pnl_pct > 0]
return {
"total_trades": len(trades),
"win_rate": len(wins) / len(trades),
"avg_pnl": np.mean([t.pnl_pct for t in trades]),
"hit_tp_rate": sum(t.hit_tp for t in trades) / len(trades),
"hit_stop_rate": sum(t.hit_stop for t in trades) / len(trades),
"max_drawdown": self._max_drawdown(trades),
}
Backtest Pitfalls
Survivorship bias: Your S&P 500 list today doesn't include companies that got delisted since 2020. Look-ahead bias: Using future data to make past decisions. Transaction costs: Always include slippage (0.05-0.1%) and commissions. A signal that returns 0.5% per trade looks great until costs eat 0.15%.
Key Takeaways
Key Takeaways — Part 3
- Build indicators from scratch (RSI, MACD, Bollinger, ATR, OBV) to understand them deeply and customize freely.
- Use one indicator per category (momentum, trend, volatility, volume) — stacking similar indicators creates false confluence.
- Build a DSL-based screener engine with filter chains, weighted scoring, and ranked output.
- Pattern detection (breakouts, S/R levels, volume anomalies) provides structural signals beyond simple indicator thresholds.
- Composite scoring with category weighting turns noisy signals into reliable grades (A+ to C).
- Always validate with walk-forward backtesting. Include transaction costs and check for survivorship/look-ahead bias.
Part 4 of 6
Dashboard — Real-Time Visualization
Build Your Own Trading Terminal with AI
Complete 6-part series — From architecture to production deployment
Back to Home