A candlestick tells a complete story of a trading session — who was in control, how much they fought, and who won. Understanding OHLCV anatomy with Pandas is the foundation of every price-action algorithm.
import pandas as pd import numpy as np # NIFTY 5-minute candle data data = { "open": [21450, 21510, 21490, 21540, 21480, 21520], "high": [21530, 21560, 21560, 21570, 21530, 21610], "low": [21420, 21490, 21460, 21490, 21450, 21510], "close": [21510, 21495, 21545, 21495, 21522, 21595], "volume": [18400, 12300, 21500, 9800, 16700, 28900], } df = pd.DataFrame(data, index=pd.date_range("2024-01-15 09:15", periods=6, freq="5min")) # ── Anatomy calculations ── df["body"] = (df["close"] - df["open"]).abs() df["direction"] = np.where(df["close"] >= df["open"], "BULL", "BEAR") df["range"] = df["high"] - df["low"] df["upper_wick"] = df["high"] - df[["open","close"]].max(axis=1) df["lower_wick"] = df[["open","close"]].min(axis=1) - df["low"] df["body_pct"] = (df["body"] / df["range"] * 100).round(1) # body as % of range print(df[["body","direction","range","upper_wick","lower_wick","body_pct"]])
Candlestick Pattern Detection
Common patterns like Doji, Hammer, Shooting Star and Engulfing can be detected purely with vectorised Pandas conditions — no loop needed.
def detect_patterns(df: pd.DataFrame) -> pd.DataFrame: """Detect common candlestick patterns in a NIFTY candle DataFrame.""" # Pre-calculate anatomy body = (df["close"] - df["open"]).abs() rng = df["high"] - df["low"] upper_wick = df["high"] - df[["open","close"]].max(axis=1) lower_wick = df[["open","close"]].min(axis=1) - df["low"] bull = df["close"] > df["open"] # Doji: body < 10% of range (indecision) df["doji"] = body < (rng * 0.10) # Hammer: small body at top, lower wick ≥ 2× body, upper wick tiny df["hammer"] = ( (body < rng * 0.35) & (lower_wick >= body * 2) & (upper_wick < body * 0.5) ) # Shooting Star: small body at bottom, upper wick ≥ 2× body df["shooting_star"] = ( (body < rng * 0.35) & (upper_wick >= body * 2) & (lower_wick < body * 0.5) ) # Bullish Engulfing: current bull candle body engulfs previous bear candle prev_body = body.shift(1) prev_bear = df["close"].shift(1) < df["open"].shift(1) df["bull_engulf"] = bull & prev_bear & (body > prev_body * 1.0) # Marubozu: body > 90% of range (strong momentum) df["marubozu"] = body > (rng * 0.90) return df df = detect_patterns(df) patterns = ["doji", "hammer", "shooting_star", "bull_engulf", "marubozu"] print(df[patterns])
Volume Analysis
Volume is the only leading indicator in price action. A candle with above-average volume has conviction; one with below-average volume is weak. Every pro algo tracks volume.
# ── Volume analysis columns ── df["vol_sma20"] = df["volume"].rolling(20).mean() df["vol_ratio"] = df["volume"] / df["vol_sma20"] # 1.5 = 50% above avg df["high_volume"] = df["vol_ratio"] >= 1.5 # spike flag # ── Buying vs Selling pressure ── # Estimate: close near high → buyers dominated df["close_loc"] = (df["close"] - df["low"]) / (df["high"] - df["low"]) df["buy_vol"] = df["volume"] * df["close_loc"] # estimated buy volume df["sell_vol"] = df["volume"] * (1 - df["close_loc"]) # estimated sell volume # ── Volume-confirmed signal ── df["vol_bull_signal"] = ( (df["close"] > df["open"]) & # bullish candle df["high_volume"] & # high volume (df["close_loc"] > 0.7) # close in upper 30% ) print(df[["volume", "vol_ratio", "close_loc", "vol_bull_signal"]].round(2))
Multi-Timeframe Analysis
In real trading, you always look at multiple timeframes — typically 5-min for entry and 15-min or hourly for trend direction. With Pandas resample(), you can build all timeframes from a single 1-min or 5-min dataset.
import pandas as pd import yfinance as yf # Fetch 5-minute NIFTY data nifty_5m = yf.download("^NSEI", period="5d", interval="5m", progress=False) nifty_5m.columns = [c.lower() for c in nifty_5m.columns] def resample_ohlcv(df: pd.DataFrame, freq: str) -> pd.DataFrame: """Resample OHLCV data to any timeframe.""" return df.resample(freq).agg({ "open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum" }).dropna() # Build 15-minute and 1-hour from 5-minute data nifty_15m = resample_ohlcv(nifty_5m, "15min") nifty_1h = resample_ohlcv(nifty_5m, "1h") # Multi-timeframe trend agreement sma20_15m = nifty_15m["close"].rolling(20).mean() sma20_1h = nifty_1h["close"].rolling(20).mean() last_15m = nifty_15m["close"].iloc[-1] last_1h = nifty_1h["close"].iloc[-1] trend_15m = "UP" if last_15m > sma20_15m.iloc[-1] else "DOWN" trend_1h = "UP" if last_1h > sma20_1h.iloc[-1] else "DOWN" print(f"15-min trend : {trend_15m}") print(f"1-hour trend : {trend_1h}") print(f"Trend aligned: {trend_15m == trend_1h}") # Only trade in 5-min when both higher timeframes agree if trend_15m == trend_1h == "UP": print("✅ BIAS: LONG — both 15m and 1H trending up") else: print("⏳ WAIT — higher timeframes not aligned")
ATR — Average True Range
ATR measures volatility and is the most important metric for setting stop-losses. Every professional algo uses ATR for position sizing and stop placement.
import pandas as pd import numpy as np def calc_atr(df: pd.DataFrame, period: int = 14) -> pd.Series: """Calculate Average True Range (ATR) for stop-loss placement.""" # True Range = max of these three values tr1 = df["high"] - df["low"] # candle range tr2 = (df["high"] - df["close"].shift(1)).abs() # gap up tr3 = (df["low"] - df["close"].shift(1)).abs() # gap down true_range = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) atr = true_range.rolling(period).mean() return atr # Apply to NIFTY 5-min data df["atr"] = calc_atr(df, period=14) # Use ATR for dynamic stop-loss entry_price = df["close"].iloc[-1] atr_value = df["atr"].iloc[-1] stop_loss = entry_price - (1.5 * atr_value) # 1.5× ATR below entry target = entry_price + (3.0 * atr_value) # 3× ATR above entry (2:1 R:R) print(f"Entry : ₹{entry_price:.0f}") print(f"ATR (14) : ₹{atr_value:.2f}") print(f"Stop Loss : ₹{stop_loss:.0f} (1.5× ATR)") print(f"Target : ₹{target:.0f} (3× ATR, 2:1 R:R)")
Complete OHLCV Analysis Engine
import pandas as pd import numpy as np def enrich_candles(df: pd.DataFrame, atr_period: int = 14) -> pd.DataFrame: """Enrich OHLCV DataFrame with anatomy, patterns, volume and ATR.""" # ── Anatomy ── df["body"] = (df["close"] - df["open"]).abs() df["range"] = df["high"] - df["low"] df["upper_wick"] = df["high"] - df[["open","close"]].max(axis=1) df["lower_wick"] = df[["open","close"]].min(axis=1) - df["low"] df["direction"] = np.where(df["close"] >= df["open"], 1, -1) # 1=bull, -1=bear # ── Patterns ── rng = df["range"] df["doji"] = df["body"] < (rng * 0.10) df["hammer"] = (df["body"] < rng * 0.35) & (df["lower_wick"] >= df["body"] * 2) df["marubozu"] = df["body"] > (rng * 0.90) # ── Volume ── df["vol_sma20"] = df["volume"].rolling(20).mean() df["vol_spike"] = df["volume"] >= df["vol_sma20"] * 1.5 df["close_loc"] = (df["close"] - df["low"]) / rng # 0=at low, 1=at high # ── 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) df["atr"] = tr.rolling(atr_period).mean() # ── Drop NaN rows created by rolling ── df = df.dropna() return df # Run the full enrichment engine df_rich = enrich_candles(df) print(f"Enriched columns: {list(df_rich.columns)}")
Quiz
Exercises
detect_patterns() function. Print a summary: how many Doji, Hammer, Shooting Star, and Marubozu candles appeared. Which date had the most significant Marubozu?