Learning Hub Market Data & APIs Lesson 13
Phase 2 — Lesson 4 of 8
Hub
Phase 2 · Market Data & APIs · Lesson 13

Technical Indicators
Built from Scratch

Build SMA, EMA, RSI, VWAP, MACD and Supertrend from the ground up using only Pandas. No TA-Lib required — understand the math behind every indicator you trade with.

~50 min
Intermediate
3 Quiz Questions
3 Exercises
Phase 2 Progress50%
Section 1
LESSON 13 · TECHNICAL INDICATORS
SMA & EMA — Moving Averages
intermediateindicators

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))
EMA Crossover
close sma9 ema9 ema21 signal 7 21580 21522.2 21549.6 21508.3 BUY 8 21530 21534.4 21543.5 21510.8 BUY 9 21610 21544.4 21557.0 21519.4 BUY 10 21590 21552.2 21562.4 21526.7 BUY 11 21640 21562.2 21576.2 21536.8 BUY
💡
EMA9/EMA21 crossover is one of the most widely used intraday signals on NIFTY and BANKNIFTY 5-minute charts. EMA reacts faster than SMA, reducing lag on fast-moving index options entries.
Section 2

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))
RSI Output
close rsi14 rsi_overbought ok_to_buy 4 21480 47.23 False False 5 21520 53.41 False True 6 21560 61.87 False True 7 21580 65.03 False False 8 21530 58.42 False True 9 21610 66.11 False False 10 21590 62.83 False True 11 21640 68.45 False True
💜
RSI Trading Rule
Don't short RSI just because it's at 70. In strong trending markets, RSI can stay above 70 for extended periods. Use RSI as a filter ("don't buy above 70") not as a standalone buy/sell signal.
Section 3

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))
VWAP Signal
close vwap above_vwap vwap_signal 6 21560 21512.4 True BUY 7 21580 21519.8 True BUY 8 21530 21520.1 True BUY 9 21610 21528.6 True BUY 10 21590 21533.2 True BUY 11 21640 21540.7 True BUY
Section 4

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)}")
MACD Signal Output
MACD BUY signals : 3 MACD SELL signals: 2
Section 5

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))
Supertrend Output
supertrend trend signal 7 21445.30 1 BUY 8 21467.50 1 BUY 9 21485.20 1 BUY 10 21502.80 1 BUY 11 21522.40 1 BUY
💡
Standard Supertrend settings for NIFTY intraday: period=10, multiplier=3.0 on 5-minute charts. For daily swing trading: period=7, multiplier=3.0. The multiplier controls how "tight" or "loose" the bands are.
Section 6

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))
Combined Signal
close ema9 rsi vwap combo_signal 7 21580 21549.6 65.0 21524.2 WAIT 8 21530 21543.5 58.4 21524.0 BUY 9 21610 21557.0 66.1 21526.8 WAIT 10 21590 21562.4 62.8 21528.3 BUY 11 21640 21576.2 68.5 21530.7 WAIT
Section 7

Quiz

Q1. What is the key difference between SMA and EMA?
Q2. VWAP resets at the start of each trading day. Which Pandas method is used to implement this daily reset?
Q3. In Supertrend, what does the multiplier parameter control?
Section 8

Exercises

Exercise 01
EMA Crossover Backtester
Load 3 months of NIFTY daily data using yfinance. Add EMA9 and EMA21. Find all days where EMA9 crosses above EMA21 (BUY signal) and all days where it crosses below (SELL signal). Print the date, close price, and signal for each crossover.
Exercise 02
RSI Divergence Scanner
Using 60 days of BANKNIFTY 15-minute data, calculate RSI(14). Find all instances where RSI drops below 30 (oversold) while price is above its 20-period SMA. These are "hidden bullish divergence" setups. Print a table of these signals.
Exercise 03
Three-Indicator Signal Engine
Build a complete 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.
Section 9

Lesson Summary

SMA vs EMA
EMA uses ewm(span=n). Faster response to price. SMA uses rolling(n).mean() — smoother but lagging.
RSI
Uses ewm(alpha=1/period). Overbought >70, oversold <30. Use as a filter, not a standalone signal.
VWAP
groupby(date).cumsum() resets each day. Most important NSE intraday reference level.
MACD
EMA(12) − EMA(26). Signal = EMA(9) of MACD. Crossovers = buy/sell signals. Histogram = momentum.
Supertrend
ATR-based bands. Standard NIFTY settings: period=10, mult=3.0 on 5-min. Clear BUY/SELL flips.
Combo Signal
np.select() with multiple conditions. "BUY" only when ALL indicators agree — reduces false signals.
Prev