Moving averages are the backbone of trend-following algos. SMA (Simple Moving Average) treats all periods equally. EMA (Exponential Moving Average) weights recent prices more — it reacts faster to price changes and is preferred by most intraday traders.
import pandas as pd import numpy as np def sma(series: pd.Series, period: int) -> pd.Series: """Simple Moving Average.""" return series.rolling(period).mean() def ema(series: pd.Series, period: int) -> pd.Series: """Exponential Moving Average — uses pandas ewm for correct calculation.""" return series.ewm(span=period, adjust=False).mean() # Example: NIFTY 15-min closes (simplified) closes = pd.Series([21450,21510,21490,21540,21480,21520, 21560,21580,21530,21610,21590,21640]) sma9 = sma(closes, 9) ema9 = ema(closes, 9) ema21 = ema(closes, 21) df = pd.DataFrame({"close": closes, "sma9": sma9, "ema9": ema9}) # EMA crossover signal: EMA9 crosses above EMA21 = bullish df["ema9"] = ema9 df["ema21"] = ema21 df["signal"] = df.apply( lambda r: "BUY" if r["ema9"] > r["ema21"] else "SELL", axis=1 ) print(df[["close", "sma9", "ema9", "ema21", "signal"]].tail(5).round(1))
RSI — Relative Strength Index
RSI measures momentum — how fast prices are moving. Values above 70 indicate overbought (potential reversal down); below 30 indicates oversold (potential reversal up). It's built from average gains vs average losses over a rolling window.
def rsi(series: pd.Series, period: int = 14) -> pd.Series: """RSI (Relative Strength Index) — Wilder's smoothing method.""" delta = series.diff() gain = delta.where(delta > 0, 0.0) # gains only loss = -delta.where(delta < 0, 0.0) # losses only (positive) # Wilder's smoothed average (equivalent to EMA with alpha=1/period) avg_gain = gain.ewm(alpha=1/period, adjust=False).mean() avg_loss = loss.ewm(alpha=1/period, adjust=False).mean() rs = avg_gain / avg_loss rsi = 100 - (100 / (1 + rs)) return rsi.round(2) # Apply to NIFTY data df["rsi14"] = rsi(df["close"], 14) # RSI-based condition flags df["rsi_overbought"] = df["rsi14"] > 70 df["rsi_oversold"] = df["rsi14"] < 30 df["rsi_neutral"] = df["rsi14"].between(40, 60) # consolidating # Typical scalping filter: only take long entries when RSI < 65 df["ok_to_buy"] = (df["signal"] == "BUY") & (df["rsi14"] < 65) print(df[["close", "rsi14", "rsi_overbought", "ok_to_buy"]].tail(8))
VWAP — Volume Weighted Average Price
VWAP is the most important intraday indicator for Indian markets. It represents the average price weighted by volume — institutions buy below VWAP and sell above it. Every NSE algo trader tracks VWAP.
def vwap(df: pd.DataFrame) -> pd.Series: """ VWAP — resets each trading day. Requires a DataFrame with open/high/low/close/volume and a DatetimeIndex. """ # Typical price: (H + L + C) / 3 typical_price = (df["high"] + df["low"] + df["close"]) / 3 # TP × Volume cumulated each day df["_tp_vol"] = typical_price * df["volume"] df["_cum_tpv"] = df.groupby(df.index.date)["_tp_vol"].cumsum() df["_cum_vol"] = df.groupby(df.index.date)["volume"].cumsum() vwap_series = df["_cum_tpv"] / df["_cum_vol"] # Cleanup temporary columns df.drop(columns=["_tp_vol", "_cum_tpv", "_cum_vol"], inplace=True) return vwap_series.round(2) # Apply VWAP and generate signals df["vwap"] = vwap(df) # Classic VWAP signal logic df["above_vwap"] = df["close"] > df["vwap"] df["vwap_signal"] = df.apply(lambda r: "BUY" if r["close"] > r["vwap"] and r["rsi14"] < 65 else "SELL" if r["close"] < r["vwap"] and r["rsi14"] > 35 else "WAIT", axis=1 ) print(df[["close", "vwap", "above_vwap", "vwap_signal"]].tail(6))
MACD — Moving Average Convergence Divergence
def macd(series: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9 ) -> pd.DataFrame: """ MACD = EMA(fast) − EMA(slow) Signal Line = EMA(MACD, signal_period) Histogram = MACD − Signal Line """ ema_fast = series.ewm(span=fast, adjust=False).mean() ema_slow = series.ewm(span=slow, adjust=False).mean() macd_line = ema_fast - ema_slow signal_line = macd_line.ewm(span=signal, adjust=False).mean() histogram = macd_line - signal_line return pd.DataFrame({ "macd": macd_line.round(2), "signal": signal_line.round(2), "histogram": histogram.round(2) }) # Apply MACD macd_df = macd(df["close"], fast=12, slow=26, signal=9) df = df.join(macd_df, rsuffix="_macd") # MACD crossover signal: MACD line crosses above signal line = BUY df["macd_cross_up"] = ( (df["macd"] > df["signal"]) & (df["macd"].shift(1) <= df["signal"].shift(1)) ) df["macd_cross_dn"] = ( (df["macd"] < df["signal"]) & (df["macd"].shift(1) >= df["signal"].shift(1)) ) buys = df[df["macd_cross_up"]] sells = df[df["macd_cross_dn"]] print(f"MACD BUY signals : {len(buys)}") print(f"MACD SELL signals: {len(sells)}")
Supertrend
Supertrend is the most popular trend-following indicator on NSE. It uses ATR to draw dynamic support/resistance bands and generates clean BUY/SELL flips. Widely used for NIFTY and BANKNIFTY intraday trend trading.
def supertrend(df: pd.DataFrame, period: int = 10, multiplier: float = 3.0) -> pd.DataFrame: """ Supertrend indicator. Returns DataFrame with columns: supertrend, trend (1=up, -1=down), signal. """ hl2 = (df["high"] + df["low"]) / 2 # ATR tr = pd.concat([ df["high"] - df["low"], (df["high"] - df["close"].shift()).abs(), (df["low"] - df["close"].shift()).abs() ], axis=1).max(axis=1) atr = tr.rolling(period).mean() # Basic bands upper_band = hl2 + (multiplier * atr) lower_band = hl2 - (multiplier * atr) # Final bands with trend persistence final_upper = upper_band.copy() final_lower = lower_band.copy() supertrend = pd.Series(index=df.index, dtype="float64") trend = pd.Series(index=df.index, dtype="int64") for i in range(1, len(df)): # Upper band: only move down, never up if upper_band.iloc[i] < final_upper.iloc[i-1] or df["close"].iloc[i-1] > final_upper.iloc[i-1]: final_upper.iloc[i] = upper_band.iloc[i] else: final_upper.iloc[i] = final_upper.iloc[i-1] # Lower band: only move up, never down if lower_band.iloc[i] > final_lower.iloc[i-1] or df["close"].iloc[i-1] < final_lower.iloc[i-1]: final_lower.iloc[i] = lower_band.iloc[i] else: final_lower.iloc[i] = final_lower.iloc[i-1] # Trend direction if df["close"].iloc[i] > final_upper.iloc[i]: trend.iloc[i] = 1 supertrend.iloc[i] = final_lower.iloc[i] elif df["close"].iloc[i] < final_lower.iloc[i]: trend.iloc[i] = -1 supertrend.iloc[i] = final_upper.iloc[i] else: trend.iloc[i] = trend.iloc[i-1] supertrend.iloc[i] = supertrend.iloc[i-1] return pd.DataFrame({ "supertrend": supertrend.round(2), "trend": trend, "signal": trend.map({1: "BUY", -1: "SELL"}) }) st_df = supertrend(df, period=10, multiplier=3.0) print(st_df.tail(5))
Combined Indicator Signal Engine
def add_indicators(df: pd.DataFrame) -> pd.DataFrame: """Add all indicators to OHLCV DataFrame and generate a combined signal.""" close = df["close"] # Moving averages df["ema9"] = close.ewm(span=9, adjust=False).mean() df["ema21"] = close.ewm(span=21, adjust=False).mean() # RSI delta = close.diff() avg_gain = delta.where(delta > 0, 0).ewm(alpha=1/14, adjust=False).mean() avg_loss = (-delta).where(delta < 0, 0).ewm(alpha=1/14, adjust=False).mean() df["rsi"] = (100 - (100 / (1 + avg_gain / avg_loss))).round(2) # VWAP (intraday reset) tp = (df["high"] + df["low"] + df["close"]) / 3 df["vwap"] = (tp * df["volume"]).cumsum() / df["volume"].cumsum() # Combined signal — all three must agree bull_trend = df["ema9"] > df["ema21"] rsi_ok = df["rsi"].between(40, 65) above_vwap = close > df["vwap"] df["combo_signal"] = np.select( [bull_trend & rsi_ok & above_vwap, ~bull_trend & rsi_ok & ~above_vwap], ["BUY", "SELL"], default="WAIT" ) return df.dropna() df_signals = add_indicators(df) print(df_signals[["close","ema9","rsi","vwap","combo_signal"]].tail(5).round(1))
Quiz
Exercises
signal_engine(df) function that adds EMA9/EMA21, RSI(14), and VWAP. Generate a combined "BUY" signal only when: EMA9 > EMA21, RSI between 40 and 65, and close > VWAP. Backtest on NIFTY 5-minute data (last 5 days) and print the number of BUY signals per day.