Trend-following pullback and breakout scanner
NSE Swing Trading Stock Scanner — Python / Google Colab
The goal is to build a rule-based scanner that automatically finds promising NSE cash-market stocks, ranks them by trend strength and trading volume, and calculates entry, stop-loss, target, and position size for a capital base of ₹2–10 lakh.
I recommend starting with a trend-following pullback and breakout scanner. Rather than buying every stock showing momentum, it looks for stocks in an established uptrend that are either breaking above resistance or pulling back towards support.
The scanner will generate a shortlist for your review. It will not automatically place orders.
1. Scanner design
The intended holding period is approximately 1–10 trading days, with daily candles used to generate signals after market close.
2. Screening rules
These are the initial parameters. Treat them as hypotheses to validate through historical testing, not as proven profitable settings.
Filter | Initial rule | Purpose |
|---|---|---|
Trend | Close > 50 DMA > 200 DMA | Favour established uptrends |
RSI (14) | 50–70 | Positive momentum without chasing extreme readings |
ADX (14) | Above 20 | Identify a reasonably strong trend |
Volume | Above 1.2× 20-day average | Confirm participation |
Liquidity | Average daily traded value above ₹5 crore | Avoid thinly traded stocks |
Breakout | Close above previous 20-day high | Identify potential continuation |
Pullback | Price recovers above 20 DMA while above 50 and 200 DMA | Identify potential trend resumption |
Reward/risk | At least 2:1 | Seek trades with favourable planned payoff |
Use daily adjusted OHLCV data for initial research. The NSE publishes official end-of-day reports and historical-data resources; Yahoo Finance data through yfinance is a convenient prototype source, but should be checked against reliable exchange or broker data before trading.
3. Entry, stop-loss and target rules
4. Position sizing for ₹2–10 lakh
Start by risking no more than 0.5% of total capital per trade, with 1% as a maximum research setting rather than a default.
For example, with ₹5 lakh capital:
Risk per trade: ₹2,500 at 0.5%.
Planned entry: ₹500.
Stop-loss: ₹480.
Risk per share: ₹20.
Quantity by risk: 125 shares.
Position value: ₹62,500.
Target at 2R: ₹540.
The actual quantity must also respect your available cash, liquidity, and portfolio exposure.
A sensible initial portfolio rule is to cap total open planned risk at 2% of capital and avoid accumulating several highly correlated stocks in the same sector. Gaps can cause actual losses to exceed the planned stop-loss risk.
5. Build the scanner in Google Colab
Open Google Colab, create a new notebook, and run the following cells in order.
Cell 1 — Install the libraries
Cell 2 — Configure the scanner and stock universe
This starter universe contains a selection of commonly traded NSE stocks. Expand it to a properly maintained NSE universe before relying on the scanner for broad-market research.
import pandas as pd
import numpy as np
import yfinance as yf
from ta.momentum import RSIIndicator
from ta.trend import ADXIndicator
from ta.volatility import AverageTrueRange
from datetime import datetime
# Capital and risk settings
CAPITAL = 500_000 # Change to 200000–1000000
RISK_PER_TRADE = 0.005 # 0.5%
MAX_POSITION_PCT = 0.20 # 20% of capital per stock
MAX_OPEN_RISK_PCT = 0.02 # Portfolio risk ceiling: 2%
MIN_AVG_VALUE = 5e7 # ₹5 crore average daily turnover
VOLUME_MULTIPLIER = 1.2
MIN_ADX = 20
MIN_RR = 2.0
ATR_MULTIPLIER = 2.0
HOLDING_DAYS = 10
# Starter list, not the complete NSE universe
SYMBOLS = [
"RELIANCE", "HDFCBANK", "ICICIBANK", "SBIN",
"AXISBANK", "KOTAKBANK", "INFY", "TCS",
"HCLTECH", "WIPRO", "TECHM", "LT",
"BHARTIARTL", "ITC", "HINDUNILVR", "TATAMOTORS",
"M&M", "MARUTI", "SUNPHARMA", "DRREDDY",
"CIPLA", "TATASTEEL", "JSWSTEEL", "HINDALCO",
"ONGC", "NTPC", "POWERGRID", "COALINDIA",
"TITAN", "TRENT", "BAJFINANCE", "BAJAJFINSV",
"ASIANPAINT", "ULTRACEMCO", "GRASIM", "BEL",
"HAL", "ETERNAL", "ADANIPORTS", "ADANIENT",
"INDIGO", "PIDILITIND", "DABUR", "GAIL",
"IOC", "PNB", "BANKBARODA", "EXIDEIND",
"TVSMOTOR", "HEROMOTOCO"
]
print("Stocks configured:", len(SYMBOLS))
print("Capital: ₹{:,.0f}".format(CAPITAL))
print("Risk per trade: {:.2%}".format(RISK_PER_TRADE))
Cell 3 — Download data and calculate indicators
The code uses daily candles and calculates indicators from historical data. yfinance uses Yahoo Finance symbols, so NSE stocks are requested with the .NS suffix. Its data availability and accuracy should be checked before use.
def download_stock(symbol, period="5y"):
ticker = symbol + ".NS"
try:
df = yf.download(
ticker,
period=period,
interval="1d",
auto_adjust=True,
progress=False,
threads=False
)
if df.empty:
return None
# Handle single-ticker MultiIndex responses
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
required = ["Open", "High", "Low", "Close", "Volume"]
df = df[required].copy()
df = df[~df.index.duplicated(keep="last")]
df = df.dropna(subset=required).sort_index()
if len(df) < 220:
return None
return df
except Exception as exc:
print(f"Download failed for {symbol}: {exc}")
return None
def add_indicators(df):
df = df.copy()
df["SMA20"] = df["Close"].rolling(20).mean()
df["SMA50"] = df["Close"].rolling(50).mean()
df["SMA200"] = df["Close"].rolling(200).mean()
df["RSI"] = RSIIndicator(
close=df["Close"], window=14
).rsi()
adx = ADXIndicator(
high=df["High"],
low=df["Low"],
close=df["Close"],
window=14
)
df["ADX"] = adx.adx()
atr = AverageTrueRange(
high=df["High"],
low=df["Low"],
close=df["Close"],
window=14
)
df["ATR"] = atr.average_true_range()
df["AvgVolume20"] = df["Volume"].rolling(20).mean()
df["AvgValue20"] = (
df["Close"] * df["Volume"]
).rolling(20).mean()
# Previous 20-day high excludes today's candle
df["PreviousHigh20"] = (
df["High"].rolling(20).max().shift(1)
)
df["VolumeRatio"] = (
df["Volume"] / df["AvgVolume20"]
)
# Trend filter
df["Uptrend"] = (
(df["Close"] > df["SMA50"]) &
(df["SMA50"] > df["SMA200"])
)
# Breakout confirmed by today's closing price
df["Breakout"] = (
df["Uptrend"] &
(df["Close"] > df["PreviousHigh20"]) &
(df["VolumeRatio"] >= VOLUME_MULTIPLIER) &
(df["RSI"].between(50, 70)) &
(df["ADX"] > MIN_ADX)
)
# Pullback recovery: crosses above SMA20
df["Pullback"] = (
df["Uptrend"] &
(df["Close"] > df["SMA20"]) &
(df["Close"].shift(1) <= df["SMA20"].shift(1)) &
(df["RSI"].between(45, 65)) &
(df["ADX"] > MIN_ADX) &
(df["VolumeRatio"] >= 1.0)
)
return df
Cell 4 — Scan, rank and calculate trade levels
This cell uses the latest available daily candle to identify setups. The displayed entry is a reference price, not a confirmed execution price. A trade should be evaluated at the next session's executable price, with gaps and slippage taken into account.
def analyse_stock(symbol):
df = download_stock(symbol)
if df is None:
return None, None
df = add_indicators(df)
row = df.iloc[-1]
if row[[
"SMA50", "SMA200", "RSI", "ADX", "ATR",
"AvgValue20", "VolumeRatio"
]].isna().any():
return None, df
if row["AvgValue20"] < MIN_AVG_VALUE:
return None, df
if not (row["Breakout"] or row["Pullback"]):
return None, df
setup = "Breakout" if row["Breakout"] else "Pullback"
entry = float(row["Close"])
stop = entry - ATR_MULTIPLIER * float(row["ATR"])
risk_per_share = entry - stop
if risk_per_share <= 0:
return None, df
target = entry + MIN_RR * risk_per_share
# Quantity constrained by both risk and position value
risk_budget = CAPITAL * RISK_PER_TRADE
qty_by_risk = int(risk_budget / risk_per_share)
qty_by_value = int(
CAPITAL * MAX_POSITION_PCT / entry
)
quantity = min(qty_by_risk, qty_by_value)
if quantity < 1:
return None, df
return {
"Symbol": symbol,
"Setup": setup,
"Signal Date": df.index[-1].strftime("%Y-%m-%d"),
"Close / Reference Entry": round(entry, 2),
"Stop Loss": round(stop, 2),
"Target (2R)": round(target, 2),
"Risk/Share": round(risk_per_share, 2),
"Quantity": quantity,
"Position Value": round(quantity * entry, 2),
"Planned Risk": round(quantity * risk_per_share, 2),
"RSI": round(float(row["RSI"]), 2),
"ADX": round(float(row["ADX"]), 2),
"Volume Ratio": round(float(row["VolumeRatio"]), 2),
"Avg Daily Value (₹)": round(float(row["AvgValue20"]), 0)
}, df
results = []
history = {}
for symbol in SYMBOLS:
result, df = analyse_stock(symbol)
if df is not None:
history[symbol] = df
if result is not None:
results.append(result)
scanner = pd.DataFrame(results)
if not scanner.empty:
# Transparent, rule-based ranking score
scanner["Score"] = (
scanner["ADX"].clip(upper=40) / 40 * 30
+ scanner["Volume Ratio"].clip(upper=3) / 3 * 25
+ (scanner["RSI"] - 50).clip(lower=0) / 20 * 20
+ 25
).round(1)
scanner = scanner.sort_values(
"Score", ascending=False
).reset_index(drop=True)
display(scanner.head(20))
scanner.to_csv("nse_swing_scanner.csv", index=False)
print("Saved: nse_swing_scanner.csv")
else:
print(
"No stocks currently match all filters. "
"Do not weaken rules solely to force a trade."
)
The score is a ranking aid, not a probability of profit. The scanner may find no setups on some days, which is a normal outcome.
Cell 5 — Basic historical backtest
A scanner should be tested before real-money use. The following backtest uses signals known at the previous close and enters at the next day's open. It tests one trade at a time per stock, assumes the stop is hit first if the same candle touches both stop and target, and deducts a configurable illustrative transaction-cost allowance.
This is a trade-level research backtest, not a portfolio-level simulation. It does not yet model simultaneous portfolio exposure, circuit limits, corporate actions beyond adjusted-price data, or actual broker charges in full.
# Illustrative costs; replace with your actual broker and tax costs.
COST_RATE_PER_SIDE = 0.001 # 0.10% per side, approximation
def backtest_stock(symbol, df):
df = add_indicators(df).dropna(
subset=["ATR", "SMA200", "RSI", "ADX"]
)
trades = []
position = None
for i in range(1, len(df)):
row = df.iloc[i]
prev = df.iloc[i - 1]
date = df.index[i]
if position is not None:
entry = position["entry"]
stop = position["stop"]
target = position["target"]
qty = position["qty"]
exit_price = None
reason = None
# Conservative gap and intraday execution assumptions
if row["Open"] <= stop:
exit_price, reason = float(row["Open"]), "Gap Stop"
elif row["Open"] >= target:
exit_price, reason = target, "Target"
elif row["Low"] <= stop:
exit_price, reason = stop, "Stop"
elif row["High"] >= target:
exit_price, reason = target, "Target"
elif i - position["entry_i"] >= HOLDING_DAYS:
exit_price, reason = float(row["Close"]), "Time Exit"
if exit_price is not None:
gross_pnl = (exit_price - entry) * qty
costs = (
entry + exit_price
) * qty * COST_RATE_PER_SIDE
trades.append({
"Symbol": symbol,
"Entry Date": position["date"],
"Exit Date": date.strftime("%Y-%m-%d"),
"Entry": round(entry, 2),
"Exit": round(exit_price, 2),
"Quantity": qty,
"Reason": reason,
"Gross PnL": round(gross_pnl, 2),
"Costs (estimated)": round(costs, 2),
"Net PnL": round(gross_pnl - costs, 2)
})
position = None
# Signals from yesterday's completed candle only
if position is None:
if not (bool(prev["Breakout"]) or bool(prev["Pullback"])):
continue
entry = float(row["Open"])
atr = float(prev["ATR"])
if not np.isfinite(atr) or atr <= 0:
continue
stop = entry - ATR_MULTIPLIER * atr
risk_per_share = entry - stop
qty_by_risk = int(
CAPITAL * RISK_PER_TRADE / risk_per_share
)
qty_by_value = int(
CAPITAL * MAX_POSITION_PCT / entry
)
qty = min(qty_by_risk, qty_by_value)
if qty < 1:
continue
# Skip an excessive upward opening gap relative to
# the prior signal close.
signal_close = float(prev["Close"])
if entry > signal_close * 1.03:
continue
position = {
"entry": entry,
"stop": stop,
"target": entry + MIN_RR * risk_per_share,
"qty": qty,
"entry_i": i,
"date": date.strftime("%Y-%m-%d")
}
# Do not force-close the final open position using future data.
return trades
all_trades = []
for symbol, df in history.items():
all_trades.extend(backtest_stock(symbol, df))
trades_df = pd.DataFrame(all_trades)
if trades_df.empty:
print(
"No completed trades found in the downloaded history. "
"Check data coverage and signal frequency."
)
else:
trades_df = trades_df.sort_values("Entry Date")
display(trades_df.tail(20))
pnl = trades_df["Net PnL"]
winners = pnl[pnl > 0]
losers = pnl[pnl < 0]
gross_profit = winners.sum()
gross_loss = abs(losers.sum())
summary = pd.DataFrame([{
"Completed Trades": len(trades_df),
"Win Rate (%)": round((pnl > 0).mean() * 100, 2),
"Net PnL (₹)": round(pnl.sum(), 2),
"Average Trade PnL (₹)": round(pnl.mean(), 2),
"Profit Factor": (
round(gross_profit / gross_loss, 2)
if gross_loss > 0 else np.nan
)
}])
display(summary)
trades_df.to_csv("nse_swing_backtest_trades.csv", index=False)
summary.to_csv("nse_swing_backtest_summary.csv", index=False)
print("Saved backtest trades and summary CSV files.")
Important backtest correction
In Cell 5, the entry candle must also be checked for stop-loss or target hits. Insert this block immediately after the position = {...} assignment, at the same indentation level as that assignment:
# Check stop/target on the entry day too.
# If both are touched, assume the stop fills first.
if row["Low"] <= stop:
exit_price, reason = stop, "Entry-Day Stop"
elif row["High"] >= position["target"]:
exit_price, reason = position["target"], "Entry-Day Target"
else:
exit_price, reason = None, None
if exit_price is not None:
gross_pnl = (exit_price - entry) * qty
costs = (
entry + exit_price
) * qty * COST_RATE_PER_SIDE
trades.append({
"Symbol": symbol,
"Entry Date": position["date"],
"Exit Date": date.strftime("%Y-%m-%d"),
"Entry": round(entry, 2),
"Exit": round(exit_price, 2),
"Quantity": qty,
"Reason": reason,
"Gross PnL": round(gross_pnl, 2),
"Costs (estimated)": round(costs, 2),
"Net PnL": round(gross_pnl - costs, 2)
})
position = None
For a rigorous backtest, also handle gaps below the stop on the entry day by exiting at the opening price, and apply the same stop-first assumption if both levels are touched.
6. How to evaluate the scanner
Do not judge it by win rate alone. Review the following metrics over multiple market conditions.
Metric | What to look for |
|---|---|
Win rate | Percentage of profitable completed trades |
Profit factor | Gross profits divided by gross losses |
Maximum drawdown | Largest peak-to-trough decline in portfolio equity |
Expectancy | Average net profit or loss per trade |
Average holding period | Whether the strategy fits your swing-trading horizon |
Costs and slippage | Whether results remain viable after realistic expenses |
Benchmark comparison | Whether results justify the risk versus a suitable index |
The supplied backtest is a starting point, not a validated performance report. It uses a fixed capital base for each stock's trade sizing and does not enforce a shared portfolio-wide cash balance or the 2% open-risk ceiling. It also does not account for all NSE transaction charges. Build those features before interpreting the results as portfolio returns.
7. Recommended development roadmap
Phase 1 — Daily scanning: Run after market close and export ranked setups to CSV.
Phase 2 — Historical validation: Test at least 3–5 years of data, use a point-in-time stock universe where possible, and separate development data from out-of-sample testing.
Phase 3 — Portfolio simulation: Enforce available cash, simultaneous-position limits, sector concentration limits, and a shared risk budget.
Phase 4 — Paper trading: Compare next-day planned entries with actual prices for at least several weeks before considering live deployment.
Phase 5 — Alerts: Add email, Telegram, or other notifications only after the signal logic and data quality have been verified.
My recommendation: Begin with daily scanning and paper trading. Once the backtest includes realistic execution, corporate actions, transaction costs, portfolio-level risk and out-of-sample validation, you can decide whether the rules merit further development. No combination of RSI, ADX, moving averages, or volume guarantees a profitable trade.


Comments
Post a Comment