Top 50 Technical Indicators¶
Pick a live ticker with the stock selector and compute 50 trend, momentum, volatility, directional and volume indicators from yfinance — no TA library.
Part 21 of 37 in the ServLoci algo/options trading notebook series — full index in notebooks/README.md.
Setup — no broker account needed¶
!pip install -q yfinance
import yfinance as yf
import pandas as pd
import numpy as np
# No broker account, no API key, no ServLoci setup needed for this chapter —
# yfinance reads public end-of-day data from Yahoo Finance. Indian tickers take
# an ".NS" suffix (NSE) or ".BO" (BSE); index tickers are prefixed with "^"
# (^NSEI = Nifty 50, ^BSESN = Sensex, ^GSPC = S&P 500).
NSE_TICKER = "RELIANCE.NS"
US_TICKER = "AAPL"
INDEX_TICKER = "^NSEI"
print("yfinance", yf.__version__)
The top 50 used in this course¶
- Trend (1–11): SMA, EMA, WMA, HMA, DEMA, TEMA, VWMA, MACD, MACD signal, PPO, TRIX.
- Momentum (12–24): ROC, Momentum, RSI, Stochastic %K/%D, Williams %R, CCI, Ultimate Oscillator, Awesome Oscillator, KST, TSI, Connors RSI, CMO.
- Volatility/channels (25–36): Bollinger upper/lower/bandwidth, ATR, NATR, True Range, Keltner upper/lower, Donchian upper/lower, standard deviation, historical volatility.
- Directional/trend state (37–46): ADX, +DI, −DI, Aroon up/down, Vortex +/−, Parabolic SAR, Ichimoku conversion/base.
- Volume/money flow (47–50): OBV, MFI, CMF, Accumulation/Distribution.
These are features, not buy/sell advice. Parameters are conventional teaching defaults and must be frozen before a fair backtest.
!pip install -q pandas numpy matplotlib ipywidgets
import numpy as np
import pandas as pd
def compute_top_50(frame):
"""Return 50 named indicators from an OHLCV DataFrame.
Input columns are case-insensitive: open, high, low, close and volume.
Warm-up rows contain NaN by design; never backfill them into a live signal.
"""
df = frame.rename(columns={c: str(c).lower() for c in frame.columns}).copy()
required = {"open", "high", "low", "close", "volume"}
missing = required.difference(df.columns)
if missing:
raise ValueError(f"missing OHLCV columns: {sorted(missing)}")
o, h, l, c, v = (df[x].astype(float) for x in ("open", "high", "low", "close", "volume"))
out = pd.DataFrame(index=df.index)
safe = lambda x: x.replace([np.inf, -np.inf], np.nan)
ema = lambda x, n: x.ewm(span=n, adjust=False, min_periods=n).mean()
wma = lambda x, n: x.rolling(n).apply(
lambda a: np.dot(a, np.arange(1, n + 1)) / (n * (n + 1) / 2), raw=True
)
# Trend and moving-average family (1-11)
out["01_sma_20"] = c.rolling(20).mean()
out["02_ema_20"] = ema(c, 20)
out["03_wma_20"] = wma(c, 20)
out["04_hma_20"] = wma(2 * wma(c, 10) - wma(c, 20), 4)
e1 = ema(c, 20); e2 = ema(e1, 20); e3 = ema(e2, 20)
out["05_dema_20"] = 2 * e1 - e2
out["06_tema_20"] = 3 * e1 - 3 * e2 + e3
out["07_vwma_20"] = safe((c * v).rolling(20).sum() / v.rolling(20).sum())
macd = ema(c, 12) - ema(c, 26)
out["08_macd"] = macd
out["09_macd_signal"] = ema(macd, 9)
out["10_ppo"] = safe(100 * macd / ema(c, 26))
ex1 = ema(c, 15); ex2 = ema(ex1, 15); ex3 = ema(ex2, 15)
out["11_trix"] = ex3.pct_change(fill_method=None) * 100
# Momentum and oscillator family (12-24)
out["12_roc_12"] = c.pct_change(12, fill_method=None) * 100
out["13_momentum_10"] = c.diff(10)
delta = c.diff(); gain = delta.clip(lower=0); loss = -delta.clip(upper=0)
avg_gain = gain.ewm(alpha=1/14, adjust=False, min_periods=14).mean()
avg_loss = loss.ewm(alpha=1/14, adjust=False, min_periods=14).mean()
out["14_rsi_14"] = 100 - (100 / (1 + safe(avg_gain / avg_loss)))
low14, high14 = l.rolling(14).min(), h.rolling(14).max()
stoch = safe(100 * (c - low14) / (high14 - low14))
out["15_stochastic_k"] = stoch
out["16_stochastic_d"] = stoch.rolling(3).mean()
out["17_williams_r"] = safe(-100 * (high14 - c) / (high14 - low14))
typical = (h + l + c) / 3
tp_mean = typical.rolling(20).mean()
mean_dev = typical.rolling(20).apply(lambda a: np.mean(np.abs(a - a.mean())), raw=True)
out["18_cci_20"] = safe((typical - tp_mean) / (0.015 * mean_dev))
prev = c.shift(1)
buy_pressure = c - pd.concat([l, prev], axis=1).min(axis=1)
true_range = pd.concat([h - l, (h - prev).abs(), (l - prev).abs()], axis=1).max(axis=1)
out["19_ultimate_oscillator"] = safe(100 * (
4 * buy_pressure.rolling(7).sum() / true_range.rolling(7).sum()
+ 2 * buy_pressure.rolling(14).sum() / true_range.rolling(14).sum()
+ buy_pressure.rolling(28).sum() / true_range.rolling(28).sum()
) / 7)
midpoint = (h + l) / 2
out["20_awesome_oscillator"] = midpoint.rolling(5).mean() - midpoint.rolling(34).mean()
r1, r2, r3, r4 = (c.pct_change(n, fill_method=None) * 100 for n in (10, 15, 20, 30))
out["21_kst"] = r1.rolling(10).sum() + 2*r2.rolling(10).sum() + 3*r3.rolling(10).sum() + 4*r4.rolling(15).sum()
pc = c.diff(); apc = pc.abs()
out["22_tsi"] = safe(100 * ema(ema(pc, 25), 13) / ema(ema(apc, 25), 13))
streak = pd.Series(0.0, index=c.index)
for i in range(1, len(c)):
direction = np.sign(c.iloc[i] - c.iloc[i - 1])
prior = streak.iloc[i - 1]
streak.iloc[i] = 0 if direction == 0 else direction * (abs(prior) + 1 if np.sign(prior) == direction else 1)
def rsi_series(x, n):
d = x.diff(); g = d.clip(lower=0).ewm(alpha=1/n, adjust=False, min_periods=n).mean()
q = (-d.clip(upper=0)).ewm(alpha=1/n, adjust=False, min_periods=n).mean()
return 100 - 100 / (1 + safe(g / q))
pct_rank = c.pct_change(fill_method=None).rolling(100).apply(lambda a: 100 * (a[-1] > a[:-1]).mean(), raw=True)
out["23_connors_rsi"] = (rsi_series(c, 3) + rsi_series(streak, 2) + pct_rank) / 3
sum_gain, sum_loss = gain.rolling(14).sum(), loss.rolling(14).sum()
out["24_cmo_14"] = safe(100 * (sum_gain - sum_loss) / (sum_gain + sum_loss))
# Volatility and channel family (25-36)
mid = c.rolling(20).mean(); std = c.rolling(20).std(ddof=0)
upper, lower = mid + 2*std, mid - 2*std
out["25_bollinger_upper"] = upper
out["26_bollinger_lower"] = lower
out["27_bollinger_bandwidth"] = safe(100 * (upper - lower) / mid)
atr = true_range.ewm(alpha=1/14, adjust=False, min_periods=14).mean()
out["28_atr_14"] = atr
out["29_natr_14"] = safe(100 * atr / c)
out["30_true_range"] = true_range
kel_mid = ema(c, 20)
out["31_keltner_upper"] = kel_mid + 2 * atr
out["32_keltner_lower"] = kel_mid - 2 * atr
out["33_donchian_upper"] = h.rolling(20).max()
out["34_donchian_lower"] = l.rolling(20).min()
out["35_stddev_20"] = std
out["36_historical_volatility"] = np.log(c / c.shift(1)).rolling(20).std(ddof=0) * np.sqrt(252) * 100
# Directional, stop and cloud family (37-46)
up_move, down_move = h.diff(), -l.diff()
plus_dm = up_move.where((up_move > down_move) & (up_move > 0), 0.0)
minus_dm = down_move.where((down_move > up_move) & (down_move > 0), 0.0)
plus_di = safe(100 * plus_dm.ewm(alpha=1/14, adjust=False, min_periods=14).mean() / atr)
minus_di = safe(100 * minus_dm.ewm(alpha=1/14, adjust=False, min_periods=14).mean() / atr)
out["37_adx_14"] = safe(100 * (plus_di - minus_di).abs() / (plus_di + minus_di)).ewm(alpha=1/14, adjust=False, min_periods=14).mean()
out["38_plus_di"] = plus_di
out["39_minus_di"] = minus_di
out["40_aroon_up"] = h.rolling(25).apply(lambda a: 100 * (np.argmax(a) + 1) / len(a), raw=True)
out["41_aroon_down"] = l.rolling(25).apply(lambda a: 100 * (np.argmin(a) + 1) / len(a), raw=True)
out["42_vortex_plus"] = safe((h - l.shift(1)).abs().rolling(14).sum() / true_range.rolling(14).sum())
out["43_vortex_minus"] = safe((l - h.shift(1)).abs().rolling(14).sum() / true_range.rolling(14).sum())
psar = pd.Series(np.nan, index=c.index)
if len(c):
bull, af, extreme = True, 0.02, h.iloc[0]
psar.iloc[0] = l.iloc[0]
for i in range(1, len(c)):
candidate = psar.iloc[i-1] + af * (extreme - psar.iloc[i-1])
if bull:
candidate = min(candidate, l.iloc[i-1], l.iloc[max(i-2, 0)])
if l.iloc[i] < candidate: bull, candidate, af, extreme = False, extreme, 0.02, l.iloc[i]
elif h.iloc[i] > extreme: extreme, af = h.iloc[i], min(af + 0.02, 0.2)
else:
candidate = max(candidate, h.iloc[i-1], h.iloc[max(i-2, 0)])
if h.iloc[i] > candidate: bull, candidate, af, extreme = True, extreme, 0.02, h.iloc[i]
elif l.iloc[i] < extreme: extreme, af = l.iloc[i], min(af + 0.02, 0.2)
psar.iloc[i] = candidate
out["44_parabolic_sar"] = psar
out["45_ichimoku_conversion"] = (h.rolling(9).max() + l.rolling(9).min()) / 2
out["46_ichimoku_base"] = (h.rolling(26).max() + l.rolling(26).min()) / 2
# Volume and money-flow family (47-50)
out["47_obv"] = (np.sign(c.diff()).fillna(0) * v).cumsum()
raw_money = typical * v; positive = raw_money.where(typical.diff() > 0, 0); negative = raw_money.where(typical.diff() < 0, 0)
out["48_mfi_14"] = 100 - 100 / (1 + safe(positive.rolling(14).sum() / negative.rolling(14).sum()))
money_flow_multiplier = safe(((c - l) - (h - c)) / (h - l))
money_flow_volume = money_flow_multiplier * v
out["49_cmf_20"] = safe(money_flow_volume.rolling(20).sum() / v.rolling(20).sum())
out["50_accumulation_distribution"] = money_flow_volume.fillna(0).cumsum()
assert out.shape[1] == 50
return out
Pick a stock and fetch real OHLCV¶
A seeded random walk can prove the engine returns 50 columns. It cannot show how RSI, MACD or Bollinger bands sit on a name that actually traded.
yfinance pulls public daily bars — no broker account, no API key. Yahoo ticker rules:
- NSE:
RELIANCE.NS,TCS.NS· BSE:RELIANCE.BO - US:
AAPL,MSFT· indices:^NSEI,^NSEBANK,^BSESN,^GSPC
Use the stock selector in the next cell. Type any Yahoo symbol in Custom to override the list. Some indices report no volume, so the four volume/money-flow columns will be empty or zero — that is a data property, not a bug in the engine.
Prices are auto-adjusted for splits and dividends so a corporate action does not look like a crash. Unadjusted broker candles belong in notebook 22.
from IPython.display import display # provided by Colab/Jupyter; imported explicitly for portability
import matplotlib.pyplot as plt
STOCK_UNIVERSE = {
"Nifty 50": "^NSEI",
"Bank Nifty": "^NSEBANK",
"Sensex": "^BSESN",
"Reliance": "RELIANCE.NS",
"TCS": "TCS.NS",
"HDFC Bank": "HDFCBANK.NS",
"Infosys": "INFY.NS",
"ICICI Bank": "ICICIBANK.NS",
"Bharti Airtel": "BHARTIARTL.NS",
"SBI": "SBIN.NS",
"ITC": "ITC.NS",
"Larsen & Toubro": "LT.NS",
"Hindustan Unilever": "HINDUNILVR.NS",
"Bajaj Finance": "BAJFINANCE.NS",
"Apple": "AAPL",
"Microsoft": "MSFT",
"NVIDIA": "NVDA",
"S&P 500": "^GSPC",
}
def load_ohlcv(ticker, period="2y"):
# Daily OHLCV from Yahoo, already split/dividend adjusted.
raw = yf.download(
ticker, period=period, interval="1d",
auto_adjust=True, progress=False, multi_level_index=False,
)
if raw is None or raw.empty:
raise ValueError(f"yfinance returned no rows for {ticker!r} — check the Yahoo symbol.")
bars = raw.rename(columns={c: str(c).lower() for c in raw.columns})
needed = ["open", "high", "low", "close", "volume"]
missing = [c for c in needed if c not in bars.columns]
if missing:
raise ValueError(f"{ticker}: download missing {missing}. Got {list(bars.columns)}")
bars = bars[needed].apply(pd.to_numeric, errors="coerce").dropna(how="any")
if len(bars) < 80:
raise ValueError(
f"{ticker}: only {len(bars)} bars — need ~80+ so long-window indicators can warm up."
)
return bars
def plot_families(ticker, bars, indicators):
close = bars["close"]
fig, axes = plt.subplots(
4, 1, figsize=(11, 10), sharex=True,
gridspec_kw={"height_ratios": [2.2, 1, 1, 1]},
)
ax = axes[0]
ax.plot(close.index, close, color="#1a1a1a", lw=1.1, label="Close")
ax.plot(indicators.index, indicators["01_sma_20"], color="#2563eb", lw=1, label="SMA 20")
ax.plot(indicators.index, indicators["02_ema_20"], color="#7c3aed", lw=1, label="EMA 20")
ax.fill_between(
indicators.index, indicators["26_bollinger_lower"], indicators["25_bollinger_upper"],
color="#2563eb", alpha=0.08, label="Bollinger",
)
ax.set_title(f"{ticker} — price, trend and channels")
ax.legend(loc="upper left", fontsize=8, frameon=False)
ax.grid(True, alpha=0.25)
axes[1].plot(indicators.index, indicators["14_rsi_14"], color="#0f766e", lw=1)
axes[1].axhline(70, color="#b45309", ls="--", lw=0.8)
axes[1].axhline(30, color="#b45309", ls="--", lw=0.8)
axes[1].set_ylim(0, 100)
axes[1].set_ylabel("RSI 14")
axes[1].grid(True, alpha=0.25)
axes[2].plot(indicators.index, indicators["08_macd"], color="#1d4ed8", lw=1, label="MACD")
axes[2].plot(indicators.index, indicators["09_macd_signal"], color="#be123c", lw=1, label="Signal")
axes[2].axhline(0, color="#999", lw=0.6)
axes[2].set_ylabel("MACD")
axes[2].legend(loc="upper left", fontsize=8, frameon=False)
axes[2].grid(True, alpha=0.25)
axes[3].plot(indicators.index, indicators["37_adx_14"], color="#334155", lw=1, label="ADX")
axes[3].plot(indicators.index, indicators["38_plus_di"], color="#15803d", lw=0.9, label="+DI")
axes[3].plot(indicators.index, indicators["39_minus_di"], color="#b91c1c", lw=0.9, label="-DI")
axes[3].axhline(25, color="#999", ls="--", lw=0.8)
axes[3].set_ylabel("ADX / DI")
axes[3].legend(loc="upper left", fontsize=8, frameon=False)
axes[3].grid(True, alpha=0.25)
fig.tight_layout()
plt.show()
def run_indicators(ticker, period="2y", plot=True):
bars = load_ohlcv(ticker, period)
indicators = compute_top_50(bars)
print(f"{ticker}: {len(bars)} daily bars, {bars.index.min().date()} -> {bars.index.max().date()}")
if float(bars["volume"].fillna(0).sum()) == 0:
print("Note: this symbol reports no volume (common on some indices). "
"Volume indicators will be empty or zero.")
print("indicator count:", indicators.shape[1])
display(indicators.tail(5).T)
assert indicators.shape[1] == 50
if plot:
plot_families(ticker, bars, indicators)
return bars, indicators
# Seeded smoke test: the engine must always emit exactly 50 named columns,
# independent of whatever live ticker is selected next.
rng = np.random.default_rng(7)
n = 160
close = pd.Series(22000 + rng.normal(0, 70, n).cumsum())
demo = pd.DataFrame({
"open": close.shift(1).fillna(close.iloc[0]),
"high": close + rng.uniform(10, 90, n),
"low": close - rng.uniform(10, 90, n),
"close": close,
"volume": rng.integers(100_000, 900_000, n),
}, index=pd.date_range("2025-01-01", periods=n, freq="B"))
assert compute_top_50(demo).shape[1] == 50
print("engine smoke test passed (50 columns on seeded OHLCV)")
Stock selector — run all 50 on the name you pick¶
# Stock selector. Use the dropdown, or type any Yahoo ticker in Custom.
# If widgets are unavailable (plain script), set TICKER / PERIOD and re-run.
TICKER = "RELIANCE.NS" # default, and the fallback when widgets are missing
PERIOD = "2y" # 6mo, 1y, 2y, 5y
try:
import ipywidgets as W
from ipywidgets import interactive_output
_WIDGETS = True
except ImportError:
_WIDGETS = False
if _WIDGETS:
stock = W.Dropdown(options=list(STOCK_UNIVERSE.items()), value=TICKER, description="Stock:")
custom = W.Text(value="", placeholder="e.g. INFY.NS or AAPL", description="Custom:")
period = W.ToggleButtons(options=["6mo", "1y", "2y", "5y"], value=PERIOD, description="Lookback:")
def _on_change(stock, custom, period):
ticker = (custom or "").strip() or stock
global TICKER, PERIOD, bars, indicators
TICKER, PERIOD = ticker, period
bars, indicators = run_indicators(ticker, period)
ui = W.VBox([W.HBox([stock, period]), custom])
out = interactive_output(_on_change, {"stock": stock, "custom": custom, "period": period})
display(ui, out)
else:
print("ipywidgets not installed — using TICKER / PERIOD. pip install ipywidgets for the dropdown.")
bars, indicators = run_indicators(TICKER, PERIOD)
What happens when an indicator "fires"¶
A textbook label is a description of the last closed bar, not a forecast. The cells below do three things for the ticker you just selected:
- State the conventional reading (what a chartist would say).
- Say whether that condition is true on the latest bar.
- Measure what actually happened on this ticker over the next 10 sessions after past events.
That last step is the point. "RSI oversold" is a story; "RSI crossed below 30, then the next 10 days averaged X% on this name" is a fact about one sample path. Notebook 27 tests a few of these claims more carefully; notebook 33 shows why turning them into a price forecast is usually a leaky demo.
def ensure_selected():
# Re-run the selector cell first if you changed the ticker. This only
# fetches if a later cell is executed in isolation.
global TICKER, PERIOD, bars, indicators
if "bars" not in globals() or "indicators" not in globals():
TICKER = globals().get("TICKER", "RELIANCE.NS")
PERIOD = globals().get("PERIOD", "2y")
bars, indicators = run_indicators(TICKER, PERIOD, plot=False)
return bars, indicators
def scenario_flags(bars, indicators):
close = bars["close"]
sma, ema = indicators["01_sma_20"], indicators["02_ema_20"]
macd, sig = indicators["08_macd"], indicators["09_macd_signal"]
rsi = indicators["14_rsi_14"]
stoch, wr = indicators["15_stochastic_k"], indicators["17_williams_r"]
upper, lower = indicators["25_bollinger_upper"], indicators["26_bollinger_lower"]
bw, atr = indicators["27_bollinger_bandwidth"], indicators["28_atr_14"]
adx = indicators["37_adx_14"]
pdi, mdi = indicators["38_plus_di"], indicators["39_minus_di"]
obv, mfi = indicators["47_obv"], indicators["48_mfi_14"]
flags = pd.DataFrame(index=bars.index)
flags["price_cross_above_sma20"] = (close.shift(1) <= sma.shift(1)) & (close > sma)
flags["price_cross_below_sma20"] = (close.shift(1) >= sma.shift(1)) & (close < sma)
flags["ema_above_sma"] = ema > sma
flags["macd_bullish_cross"] = (macd.shift(1) <= sig.shift(1)) & (macd > sig)
flags["macd_bearish_cross"] = (macd.shift(1) >= sig.shift(1)) & (macd < sig)
flags["rsi_enters_oversold"] = (rsi.shift(1) >= 30) & (rsi < 30)
flags["rsi_enters_overbought"] = (rsi.shift(1) <= 70) & (rsi > 70)
flags["rsi_oversold_now"] = rsi < 30
flags["rsi_overbought_now"] = rsi > 70
flags["stoch_enters_oversold"] = (stoch.shift(1) >= 20) & (stoch < 20)
flags["williams_enters_oversold"] = (wr.shift(1) >= -80) & (wr < -80)
def rising_edge(s):
cur = s.fillna(False).astype(bool)
prev = s.shift(1).fillna(False).astype(bool)
return cur & ~prev
flags["bollinger_squeeze"] = bw < bw.rolling(60, min_periods=40).quantile(0.2)
flags["squeeze_starts"] = rising_edge(flags["bollinger_squeeze"])
flags["close_above_upper_band"] = close > upper
flags["walks_upper_band"] = rising_edge(flags["close_above_upper_band"])
flags["close_below_lower_band"] = close < lower
flags["walks_lower_band"] = rising_edge(flags["close_below_lower_band"])
flags["atr_expanding"] = atr > atr.rolling(20, min_periods=10).mean() * 1.3
flags["atr_expansion_starts"] = rising_edge(flags["atr_expanding"])
flags["adx_trend_turns_on"] = (adx.shift(1) <= 25) & (adx > 25)
flags["adx_uptrend"] = (adx > 25) & (pdi > mdi)
flags["adx_downtrend"] = (adx > 25) & (mdi > pdi)
flags["adx_chop"] = adx < 20
flags["obv_bearish_div"] = (close >= close.rolling(20, min_periods=10).max()) & (
obv < obv.rolling(20, min_periods=10).max()
)
flags["obv_div_starts"] = rising_edge(flags["obv_bearish_div"])
flags["mfi_enters_oversold"] = (mfi.shift(1) >= 20) & (mfi < 20)
return flags.fillna(False)
def event_aftermath(bars, flags, name, horizon=10):
close = bars["close"]
fwd = close.shift(-horizon) / close - 1
hits = flags[name].astype(bool)
sample = fwd[hits].dropna()
last = flags.index[hits][-1].date() if hits.any() else None
return {
"scenario": name,
"events": int(hits.sum()),
f"mean_{horizon}d": None if sample.empty else float(sample.mean()),
f"up_rate_{horizon}d": None if sample.empty else float((sample > 0).mean()),
"last_date": last,
}
def report_scenario(title, story, names, now_keys=None):
b, ind = ensure_selected()
flags = scenario_flags(b, ind)
latest = flags.iloc[-1]
print(f"{TICKER} last bar {b.index[-1].date()} close {float(b['close'].iloc[-1]):.2f}")
print(title)
print(story)
print()
if now_keys:
for key in now_keys:
print(f" now {key:28s} {bool(latest[key])}")
print()
rows = [event_aftermath(b, flags, name) for name in names]
table = pd.DataFrame(rows)
display(table)
recent = flags[names].tail(8)
recent.index = recent.index.date
print("recent flags (last 8 sessions):")
display(recent)
return flags
bars, indicators = ensure_selected()
flags = scenario_flags(bars, indicators)
print(f"scenario columns: {list(flags.columns)}")
print(f"{TICKER}: {int(flags.any(axis=1).sum())} sessions had at least one flag")
Latest snapshot — which stories are true today¶
Run this after the selector. True means the condition holds on the last closed session, so any action belongs on the next tradable bar (notebook 29).
b, ind = ensure_selected()
flags = scenario_flags(b, ind)
last = flags.iloc[-1]
close = float(b["close"].iloc[-1])
row = {
"close": close,
"sma20": float(ind["01_sma_20"].iloc[-1]),
"ema20": float(ind["02_ema_20"].iloc[-1]),
"rsi14": float(ind["14_rsi_14"].iloc[-1]),
"macd": float(ind["08_macd"].iloc[-1]),
"macd_signal": float(ind["09_macd_signal"].iloc[-1]),
"bb_bandwidth": float(ind["27_bollinger_bandwidth"].iloc[-1]),
"atr14": float(ind["28_atr_14"].iloc[-1]),
"adx14": float(ind["37_adx_14"].iloc[-1]),
"+DI": float(ind["38_plus_di"].iloc[-1]),
"-DI": float(ind["39_minus_di"].iloc[-1]),
"mfi14": float(ind["48_mfi_14"].iloc[-1]),
}
print(f"{TICKER} snapshot @ {b.index[-1].date()}")
display(pd.Series(row).to_frame("value"))
live = last[["ema_above_sma", "rsi_oversold_now", "rsi_overbought_now",
"close_above_upper_band", "close_below_lower_band",
"bollinger_squeeze", "atr_expanding",
"adx_uptrend", "adx_downtrend", "adx_chop", "obv_bearish_div"]]
print("live conditions on the last closed bar:")
display(live.to_frame("true"))
firing = [name for name, on in live.items() if bool(on)]
print("firing now:", firing or "(none of the live-state flags)")
Child: trend — SMA/EMA and MACD¶
Textbook. Price crossing back above the 20-day SMA is a short-term "reclaim." EMA sitting above SMA is a rising-average regime. MACD crossing above its signal line is the classic possible shift in short-term trend direction.
What actually happens. MACD is a lagging confirmation, not a prediction. A reclaim in a falling market often fails. The table is this ticker's own 10-session aftermath — not a license to buy the cross.
report_scenario(
"TREND",
"Reclaim / lose the 20-day average, and MACD crossing its signal.",
names=["price_cross_above_sma20", "price_cross_below_sma20",
"macd_bullish_cross", "macd_bearish_cross"],
now_keys=["ema_above_sma"],
)
Child: momentum — RSI, Stochastic, Williams %R¶
Textbook. RSI below 30 (or Stochastic below 20, Williams below −80) is "oversold": price has fallen far and fast relative to its recent range. Above 70 / 80 is "overbought."
What actually happens. In a strong uptrend RSI can sit above 70 for weeks while price keeps climbing. Oversold is a stretched reading, not a scheduled bounce. If the 10-day up-rate after rsi_enters_oversold is near a coin flip on this name, the folklore is not earning its keep here.
report_scenario(
"MOMENTUM",
"Oscillators entering stretched zones. Entry = first bar that crosses the threshold.",
names=["rsi_enters_oversold", "rsi_enters_overbought",
"stoch_enters_oversold", "williams_enters_oversold"],
now_keys=["rsi_oversold_now", "rsi_overbought_now"],
)
Child: volatility — Bollinger squeeze, band walks, ATR expansion¶
Textbook. A Bollinger squeeze (bandwidth in the lowest 20% of the last 60 sessions) says range has compressed and a larger move often follows — direction unknown. Close above the upper band is a "walk"; close below the lower band is a stretch the other way. ATR expanding means the typical daily range just jumped.
What actually happens. Squeezes precede both breakouts and fakeouts. Walking the upper band in a trend is continuation more often than reversal. ATR expansion is a size signal (widen stops, cut size), not an entry.
report_scenario(
"VOLATILITY",
"Compressed range, band extremes, and a jump in typical true range.",
names=["squeeze_starts", "walks_upper_band",
"walks_lower_band", "atr_expansion_starts"],
now_keys=["bollinger_squeeze", "close_above_upper_band",
"close_below_lower_band", "atr_expanding"],
)
Child: directional state — ADX, +DI, −DI¶
Textbook. ADX above ~25: the market is trending, not chopping. +DI above −DI is the direction; ADX is only the strength gauge. ADX crossing up through 25 is "a trend is turning on." ADX below 20 is chop — trend-following setups usually bleed.
What actually happens. ADX says nothing about which way, and a newly "on" trend can be the last third of the move. Use it as a filter on a trend idea, not as a standalone long/short.
report_scenario(
"DIRECTIONAL",
"Trend on/off and which side is in control.",
names=["adx_trend_turns_on"],
now_keys=["adx_uptrend", "adx_downtrend", "adx_chop"],
)
print()
print(f"ADX now {float(indicators['37_adx_14'].iloc[-1]):.1f} "
f"+DI {float(indicators['38_plus_di'].iloc[-1]):.1f} "
f"-DI {float(indicators['39_minus_di'].iloc[-1]):.1f}")
Child: volume — OBV divergence and MFI¶
Textbook. Price making a 20-day high while OBV is not is a bearish divergence: fewer participants are backing the high. MFI below 20 is an RSI-like stretch that includes volume.
What actually happens. On indices with empty volume these flags are meaningless. Even with real volume, a single 20-day divergence is a warning to treat trend/momentum reads more skeptically — not a sell ticket. Skip this cell's conclusion if the volume sum printed above was zero.
report_scenario(
"VOLUME",
"Price high without OBV confirmation, and MFI entering oversold.",
names=["obv_div_starts", "mfi_enters_oversold"],
now_keys=["obv_bearish_div"],
)
Child: aftermath board — every event, one table¶
One ticker, one window, overlapping 10-day forward returns. This is a research question ("did this condition tend to precede that outcome"), not a backtest you can trade. Overlapping windows overstate how much independent evidence you have — see notebook 27 and 29.
b, ind = ensure_selected()
flags = scenario_flags(b, ind)
event_names = [
"price_cross_above_sma20", "price_cross_below_sma20",
"macd_bullish_cross", "macd_bearish_cross",
"rsi_enters_oversold", "rsi_enters_overbought",
"stoch_enters_oversold", "williams_enters_oversold",
"squeeze_starts", "walks_upper_band", "walks_lower_band",
"atr_expansion_starts", "adx_trend_turns_on",
"obv_div_starts", "mfi_enters_oversold",
]
board = pd.DataFrame([event_aftermath(b, flags, name) for name in event_names])
board = board.sort_values("events", ascending=False)
print(f"{TICKER} 10-session aftermath after each event type")
display(board)
# Mark the four most common event types on price so you can see clustering.
top = board["scenario"].head(4).tolist()
fig, ax = plt.subplots(figsize=(11, 3.6))
ax.plot(b.index, b["close"], color="#1a1a1a", lw=1.0, label="Close")
colors = ["#2563eb", "#be123c", "#0f766e", "#7c3aed"]
for name, color in zip(top, colors):
hits = b.index[flags[name].astype(bool)]
ax.scatter(hits, b.loc[hits, "close"], s=18, color=color, label=name, zorder=3)
ax.set_title(f"{TICKER} — most frequent scenario dates")
ax.legend(loc="upper left", fontsize=7, frameon=False)
ax.grid(True, alpha=0.25)
fig.tight_layout()
plt.show()
Reading each family¶
A number alone is not a signal — read each family for what it actually measures, not what you want it to say.
- Trend (SMA/EMA/MACD): where price has been, smoothed. A rising EMA doesn't predict the next bar; it describes the recent path. Classic read: MACD crossing above its signal line marks a possible shift in short-term trend direction — it lags, so it confirms more often than it predicts.
- Momentum (RSI/Stochastic/Williams %R): how fast and how far price has moved relative to its own recent range. Textbook thresholds — RSI above 70 "overbought", below 30 "oversold" — describe stretched conditions, not reversal timing. In a strong trend, RSI can sit above 70 for weeks while price keeps climbing.
- Volatility (Bollinger/ATR/Keltner): how much price is moving, not which direction. A Bollinger "squeeze" (bands narrowing) flags compressed volatility that often precedes a bigger move — in either direction. ATR is mainly used to size stops and positions to current volatility, not to time entries.
- Directional state (ADX/+DI/−DI): ADX above roughly 25 is a conventional cutoff for "the market is trending, not chopping" — it says nothing about which way. +DI above −DI is the direction; ADX is the strength gauge.
- Volume (OBV/MFI/CMF): whether volume is confirming or contradicting the price move. Price rising while OBV falls (bearish divergence) means fewer participants are backing the rally — a warning to weigh trend/momentum reads more skeptically, not a stand-alone sell signal.
None of these families is reliable in isolation. Every real system in this course combines at least a trend/momentum read with a volatility or directional filter before treating anything as a signal — see notebook 23.
Next in this school: correlation across a basket, prediction baselines that have to beat naive, portfolio weights, frontier and drawdown.
Avoid the three common research errors¶
- Warm-up leakage: keep early
NaNvalues; do not backfill an indicator with future information. - Same-bar fills: a signal using a candle close can normally act only on the next tradable event in a bar-based backtest.
- Unadjusted data: splits, bonuses, symbol changes and futures rolls can create fake signals. Use broker/exchange metadata and document adjustments.
For production, compare a sample against a second implementation. Small differences can come from Wilder versus EMA smoothing, population versus sample deviation, and candle/session boundaries.
« Previous: Indian Broker API Landscape
Next: Broker Data to Indicator Pipeline »
Try the concepts above interactively: Options Strategy Builder · Docs · Get your static IP