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

!pip -q install yfinance pandas numpy ta

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

  1. Phase 1 — Daily scanning: Run after market close and export ranked setups to CSV.

  2. 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.

  3. Phase 3 — Portfolio simulation: Enforce available cash, simultaneous-position limits, sector concentration limits, and a shared risk budget.

  4. Phase 4 — Paper trading: Compare next-day planned entries with actual prices for at least several weeks before considering live deployment.

  5. 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

Popular posts from this blog

📈 Opening Range Breakout Strategy Explained with Python Code

🐢 Turtle Trading – Classic Breakout and Trend-Following Method (with Python)

🗞️💹 News Sentiment Analysis – NLP-Based Headline Trading Using Python