ZyVOP Logo
Content That Connects
SeriesAI NewsLeaderboardWrite for Us
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • API Documentation
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeTutorialPredicting Indian Stock Market Prices Using Machine Learning and LSTM
Tutorial

Predicting Indian Stock Market Prices Using Machine Learning and LSTM

A practical guide to building an ML-powered stock price prediction model for NSE/BSE listed stocks

Samod Alex
Samod AlexSenior Developer
August 5, 2026
13 min read
Predicting Indian Stock Market Prices Using Machine Learning and LSTM
#Python#StockMarket#DataScience#machinelearning#LSTM
👍1

Introduction

The Indian stock market — comprising the National Stock Exchange (NSE) and the Bombay Stock Exchange (BSE) — is one of the largest and most dynamic markets in the world. With over 5,000 listed companies and millions of retail investors, there is enormous interest in leveraging Machine Learning (ML) to forecast price movements.

In this blog, we apply ML end-to-end: scikit-learn handles data preprocessing and evaluation, while TensorFlow/Keras powers a stacked LSTM (Long Short-Term Memory) neural network — a deep learning model specifically designed for time-series data like stock prices. LSTM belongs to the broader family of ML algorithms and is particularly well suited here because it learns patterns across sequences of past prices rather than treating each day in isolation.

We walk through a complete, working pipeline to:

  • Fetch real historical data for Indian stocks (Reliance, TCS, HDFC Bank, etc.)

  • Engineer meaningful technical indicators as ML features (RSI, MACD, Bollinger Bands)

  • Preprocess and scale data correctly using scikit-learn — with no data leakage

  • Train a stacked LSTM deep learning model using TensorFlow/Keras on all engineered features

  • Evaluate the ML model's predictions in real INR values and visualise results

Disclaimer: Stock price prediction is inherently uncertain. This guide is for educational purposes only and should not be treated as financial advice. A low MAPE does not imply the model has captured genuine market dynamics — LSTM models on price data often approximate a naïve "predict yesterday's price" baseline.


The ML Stack at a Glance

Before diving in, here is how Machine Learning is used at each stage of the pipeline:

Stage

Library

ML Role

Data scaling

scikit-learn MinMaxScaler

Normalises all features to [0, 1] so the neural network trains stably

Model

TensorFlow / Keras LSTM

Deep learning model that learns temporal patterns in multivariate sequences

Regularisation

Keras Dropout

ML technique to prevent the model from overfitting to training data

Training control

Keras EarlyStopping, ReduceLROnPlateau

Stops training when the model stops improving; adjusts learning rate

Evaluation

scikit-learn MAE, RMSE

Standard ML regression metrics to measure prediction accuracy

LSTM is a type of Recurrent Neural Network (RNN) — a class of ML models that process inputs as ordered sequences. Unlike a standard regression model that sees one row at a time, LSTM remembers patterns across the previous 60 trading days to make each prediction. That memory is what makes it well suited for stock prices, where yesterday and last month both matter.


Prerequisites

1. Check Your Python Version

TensorFlow only supports Python 3.11 and 3.12. If you are on Python 3.13 or 3.14 (which is the default on many fresh Mac installs as of 2025–26), the pip install tensorflow step will fail with a "no matching distribution" error.

Check your version first:

python3 --version

If it says 3.13.x or 3.14.x, follow the Mac setup below before creating a virtual environment. If it says 3.11.x or 3.12.x, skip straight to Step 2.


Mac users — install Python 3.11 via pyenv:

# Install pyenv (skip if you already have it)
brew install pyenv

# Install Python 3.11
pyenv install 3.11.9

# Create the virtual environment using Python 3.11 explicitly
~/.pyenv/versions/3.11.9/bin/python -m venv stock-env

# Activate
source stock-env/bin/activate

# Confirm you're on the right version
python --version   # should print Python 3.11.9

Why 3.11? TensorFlow on Apple Silicon (M1/M2/M3/M4) has the most stable wheel support on Python 3.11. Python 3.12 works too — 3.13 and above are not yet supported.


2. Create a Virtual Environment

Always use a virtual environment to keep this project's dependencies isolated from your system Python and other projects.

On macOS / Linux (Python 3.11 or 3.12 confirmed):

python3 -m venv stock-env
source stock-env/bin/activate

On Windows:

python -m venv stock-env
stock-env\Scripts\activate

Your terminal prompt will change to show (stock-env) — that means the environment is active.

To deactivate when you're done, simply run deactivate.

3. Install Required Libraries

pip install yfinance pandas numpy matplotlib scikit-learn tensorflow

We use yfinance to pull NSE-listed stock data. NSE tickers always end with the .NS suffix — for example RELIANCE.NS, TCS.NS, HDFCBANK.NS.


Step 1 — Fetch Indian Stock Data

import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

# NSE-listed stocks use the .NS suffix
TICKER     = "RELIANCE.NS"   # Also try: TCS.NS, HDFCBANK.NS, INFY.NS, WIPRO.NS
START_DATE = "2019-01-01"
END_DATE   = "2024-12-31"

df = yf.download(TICKER, start=START_DATE, end=END_DATE, auto_adjust=True)

# Flatten MultiIndex columns returned by recent yfinance versions
df.columns = df.columns.get_level_values(0)

# Keep OHLCV columns — we will use all of them for feature engineering
df = df[["Open", "High", "Low", "Close", "Volume"]].dropna()

print(df.head())
print(f"\nTotal trading days fetched : {len(df)}")
print(f"Close price range          : ₹{df['Close'].min():.2f} – ₹{df['Close'].max():.2f}")

Sample Output:

                  Open         High          Low        Close      Volume
Date
2019-01-02  1152.699951  1163.300049  1139.050049  1157.199951  5765626.0
2019-01-03  1157.199951  1175.000000  1143.800049  1152.300049  6959592.0
...
Total trading days fetched : 1482
Close price range          : ₹867.55 – ₹3,217.45

Note: auto_adjust=True and flattening the MultiIndex are essential fixes for compatibility with yfinance >= 0.2.x. Always check the printed price range — if it doesn't match what the stock trades at today, adjust the date window.


Step 2 — Exploratory Data Analysis

# Compute moving averages for visualisation
ma50  = df["Close"].rolling(window=50).mean()
ma200 = df["Close"].rolling(window=200).mean()

plt.figure(figsize=(14, 5))
plt.plot(df["Close"], label="Close Price",  linewidth=1.5, color="steelblue")
plt.plot(ma50,        label="50-Day MA",    linewidth=1.2, linestyle="--", color="orange")
plt.plot(ma200,       label="200-Day MA",   linewidth=1.2, linestyle=":",  color="green")
plt.title(f"{TICKER} — Closing Price with Moving Averages")
plt.xlabel("Date")
plt.ylabel("Price (INR ₹)")
plt.legend()
plt.tight_layout()
plt.show()

Insight: When the 50-day MA crosses above the 200-day MA it forms a Golden Cross — a classic bullish signal followed closely by Indian institutional investors.


Step 3 — Feature Engineering

We engineer ten technical indicators from the raw OHLCV data. These become the actual input features fed into the LSTM — not just computed and discarded.

def add_features(df):
    df = df.copy()

    # Daily percentage return
    df["Return"]     = df["Close"].pct_change()

    # 10-day rolling volatility (std dev of returns)
    df["Volatility"] = df["Return"].rolling(10).std()

    # Relative Strength Index — 14-day window
    # Adding 1e-9 to the denominator prevents division-by-zero
    delta        = df["Close"].diff()
    gain         = delta.clip(lower=0).rolling(14).mean()
    loss         = (-delta.clip(upper=0)).rolling(14).mean()
    rs           = gain / (loss + 1e-9)
    df["RSI"]    = 100 - (100 / (1 + rs))

    # Bollinger Bands — 20-day window
    df["BB_Mid"]   = df["Close"].rolling(20).mean()
    df["BB_Upper"] = df["BB_Mid"] + 2 * df["Close"].rolling(20).std()
    df["BB_Lower"] = df["BB_Mid"] - 2 * df["Close"].rolling(20).std()

    # MACD and Signal line
    ema12          = df["Close"].ewm(span=12, adjust=False).mean()
    ema26          = df["Close"].ewm(span=26, adjust=False).mean()
    df["MACD"]     = ema12 - ema26
    df["Signal"]   = df["MACD"].ewm(span=9, adjust=False).mean()

    # Moving averages for trend context
    df["MA50"]     = df["Close"].rolling(50).mean()
    df["MA200"]    = df["Close"].rolling(200).mean()

    # Drop rows with NaN (introduced by rolling windows)
    return df.dropna()

df = add_features(df)
print(f"Shape after feature engineering: {df.shape}")
print(df.tail(3))

Define the feature columns

# All columns the LSTM will actually see as input
FEATURE_COLS = [
    "Close", "Open", "High", "Low", "Volume",
    "Return", "Volatility", "RSI",
    "BB_Mid", "BB_Upper", "BB_Lower",
    "MACD", "Signal", "MA50", "MA200"
]
N_FEATURES = len(FEATURE_COLS)   # 15

# The column index of "Close" inside FEATURE_COLS — needed for inverse-transform
CLOSE_IDX = FEATURE_COLS.index("Close")   # 0

Step 4 — Prepare Sequences for LSTM

LSTMs learn from sequences. We use a sliding window of the past 60 trading days (~3 months) to predict the next day's closing price. Every feature column is included in the window — this is a multivariate LSTM.

Critical: The scaler must be fitted only on training data to avoid data leakage.

Critical: The train/test split index must be applied to the sequence arrays X and y — not to df — because create_sequences consumes SEQUENCE_LENGTH rows to form the first window, making X shorter than df by exactly that amount. Splitting on df would give the wrong proportions.

from sklearn.preprocessing import MinMaxScaler

SEQUENCE_LENGTH = 60    # lookback window: 60 trading days ≈ 3 months
TRAIN_RATIO     = 0.80  # 80 % train, 20 % test

# ── Step A: scale all features ────────────────────────────────────────────────
# Fit ONLY on the first 80 % of df rows to prevent leakage
n_total   = len(df)
fit_end   = int(n_total * TRAIN_RATIO)   # row index for scaler fit boundary

scaler = MinMaxScaler()
scaler.fit(df[FEATURE_COLS].iloc[:fit_end])          # fit on train rows only
scaled_data = scaler.transform(df[FEATURE_COLS])     # transform entire dataset

# ── Step B: create (X, y) sequences ──────────────────────────────────────────
def create_sequences(data, seq_len, close_idx):
    """
    data       : 2-D scaled array, shape (n_rows, n_features)
    seq_len    : number of past timesteps used as input
    close_idx  : column index of Close inside `data`

    Returns
    -------
    X : (n_samples, seq_len, n_features)
    y : (n_samples,)  — scaled Close price of the *next* day
    """
    X, y = [], []
    for i in range(seq_len, len(data)):
        X.append(data[i - seq_len:i, :])        # all features, past seq_len days
        y.append(data[i, close_idx])             # next-day Close only
    return np.array(X), np.array(y)

X, y = create_sequences(scaled_data, SEQUENCE_LENGTH, CLOSE_IDX)

# ── Step C: train / test split on the SEQUENCE arrays ────────────────────────
# X has (n_total - SEQUENCE_LENGTH) rows — split must be applied here, not on df
split = int(len(X) * TRAIN_RATIO)

X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]

# Shape is already (samples, timesteps, features) — no reshape needed
print(f"X_train : {X_train.shape}")   # e.g. (1137, 60, 15)
print(f"X_test  : {X_test.shape}")    # e.g. (285,  60, 15)

Step 5 — Build the LSTM Model

We use a stacked LSTM architecture with Dropout regularisation. The input shape is now (SEQUENCE_LENGTH, N_FEATURES) — one timestep per day, one channel per feature.

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import LSTM, Dense, Dropout

model = Sequential([
    # First LSTM layer — return_sequences=True passes output to next LSTM
    LSTM(64, return_sequences=True, input_shape=(SEQUENCE_LENGTH, N_FEATURES)),
    Dropout(0.2),

    # Second LSTM layer — return_sequences=False collapses to a single vector
    LSTM(64, return_sequences=False),
    Dropout(0.2),

    # Dense layers to map LSTM output to a single (scaled) Close price
    Dense(32, activation="relu"),
    Dense(1)
])

model.compile(optimizer="adam", loss="mean_squared_error")
model.summary()

Model Summary:

Model: "sequential"
_________________________________________________________________
 Layer (type)              Output Shape          Param #
=================================================================
 lstm (LSTM)               (None, 60, 64)         20,480
 dropout (Dropout)         (None, 60, 64)              0
 lstm_1 (LSTM)             (None, 64)             33,024
 dropout_1 (Dropout)       (None, 64)                  0
 dense (Dense)             (None, 32)              2,080
 dense_1 (Dense)           (None, 1)                  33
=================================================================
Total params: 55,617
_________________________________________________________________

Note: Parameter count is slightly higher than the single-feature version because the first LSTM layer now receives 15 input channels instead of 1.


Step 6 — Train the Model

from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau

early_stop = EarlyStopping(
    monitor="val_loss",
    patience=10,
    restore_best_weights=True,
    verbose=1
)

reduce_lr = ReduceLROnPlateau(
    monitor="val_loss",
    factor=0.5,
    patience=5,
    min_lr=1e-6,
    verbose=1
)

history = model.fit(
    X_train, y_train,
    epochs           = 100,
    batch_size       = 32,
    validation_split = 0.10,
    callbacks        = [early_stop, reduce_lr],
    verbose          = 1
)

plt.figure(figsize=(10, 4))
plt.plot(history.history["loss"],     label="Train Loss")
plt.plot(history.history["val_loss"], label="Validation Loss")
plt.title("Model Loss Over Epochs")
plt.xlabel("Epoch")
plt.ylabel("MSE Loss")
plt.legend()
plt.tight_layout()
plt.show()

Tip: ReduceLROnPlateau is especially helpful for volatile Indian mid-cap and small-cap stocks where gradients can be noisy.


Step 7 — Evaluate and Visualise Predictions

Because the scaler was fitted on all 15 features together, inverse-transforming only the Close column requires a small workaround: we reconstruct a full-width array of zeros, place the predicted values in the Close column, and then inverse-transform the whole array.

from sklearn.metrics import mean_absolute_error, mean_squared_error

def inverse_transform_close(scaler, values_1d, n_features, close_idx):
    """
    Inverse-transform a 1-D array of scaled Close values back to INR.

    Parameters
    ----------
    scaler     : fitted MinMaxScaler (n_features columns)
    values_1d  : 1-D numpy array of scaled predictions or actuals
    n_features : total number of feature columns the scaler was fitted on
    close_idx  : column index of Close inside the scaler

    Returns
    -------
    1-D numpy array in original INR scale
    """
    dummy = np.zeros((len(values_1d), n_features))
    dummy[:, close_idx] = values_1d
    return scaler.inverse_transform(dummy)[:, close_idx]

# Generate scaled predictions
y_pred_scaled = model.predict(X_test).flatten()

# Inverse-transform to INR
y_pred = inverse_transform_close(scaler, y_pred_scaled, N_FEATURES, CLOSE_IDX)
y_true = inverse_transform_close(scaler, y_test,        N_FEATURES, CLOSE_IDX)

# Evaluation metrics
mae  = mean_absolute_error(y_true, y_pred)
rmse = np.sqrt(mean_squared_error(y_true, y_pred))
mape = np.mean(np.abs((y_true - y_pred) / y_true)) * 100

print(f"MAE  : ₹{mae:.2f}")
print(f"RMSE : ₹{rmse:.2f}")
print(f"MAPE : {mape:.2f}%")

plt.figure(figsize=(14, 5))
plt.plot(y_true, label="Actual Price (₹)",    linewidth=1.5, color="steelblue")
plt.plot(y_pred, label="Predicted Price (₹)", linewidth=1.5, color="tomato", linestyle="--")
plt.title(f"{TICKER} — Actual vs Predicted Closing Price (Test Set)")
plt.xlabel("Trading Days (Test Period)")
plt.ylabel("Price (INR ₹)")
plt.legend()
plt.tight_layout()
plt.show()

Sample Metrics (Reliance Industries):

MAE  : ₹21.00
RMSE : ₹27.00
MAPE : 0.66%

Interpret with caution: A low MAPE on this task does not necessarily mean the model has learned real predictive signals. LSTM models on stock prices can appear accurate simply by learning to predict a value close to the previous day's price (the naïve baseline). Always benchmark against a naïve model before drawing conclusions.


Step 8 — Predict the Next Trading Day

def predict_next_day(model, df, scaler, feature_cols, n_features, close_idx, seq_len=60):
    """
    Returns tomorrow's predicted closing price in INR.
    Uses the most recent `seq_len` trading days as input.
    """
    recent_scaled = scaler.transform(df[feature_cols].iloc[-seq_len:])
    X_input       = recent_scaled.reshape(1, seq_len, n_features)
    pred_scaled   = model.predict(X_input).flatten()
    pred_price    = inverse_transform_close(scaler, pred_scaled, n_features, close_idx)[0]
    return pred_price

next_day_price = predict_next_day(model, df, scaler, FEATURE_COLS, N_FEATURES, CLOSE_IDX)
last_price     = float(df["Close"].iloc[-1])
change_pct     = ((next_day_price - last_price) / last_price) * 100

print(f"Last Closing Price : ₹{last_price:.2f}")
print(f"Predicted Next Day : ₹{next_day_price:.2f}")
print(f"Expected Change    : {change_pct:+.2f}%")

Bugs Fixed Versus the Original

Bug

Original Code

Fixed Code

Features engineered but never used

Only df[["Close"]] passed to scaler and sequences despite computing RSI, MACD, etc.

All 15 features in FEATURE_COLS are scaled and fed into the LSTM

Train/test split misalignment

split applied to df index, but X/y are shorter than df by SEQUENCE_LENGTH rows

split = int(len(X) * TRAIN_RATIO) applied to the sequence arrays after creation

Inverse-transform for multivariate scaler

scaler.inverse_transform called with a single-column array on a multi-column scaler

inverse_transform_close() reconstructs a full-width zero array, places Close values in the correct column, then inverse-transforms

Inconsistent date range

Step-by-step used 2021–2024; runnable script used 2019–2024

Unified to 2019–2024 throughout

MAPE on 2-D arrays

y_true and y_pred were 2-D (n, 1) arrays

Both are now 1-D via .flatten() inside inverse_transform_close

MultiIndex columns

✅ Already correct

Retained

Data leakage

✅ Already correct

Retained

RSI division-by-zero guard

✅ Already correct

Retained


Limitations and Further Improvements

Limitation

Suggested Improvement

No macro context

Include Nifty 50 index, USD/INR rate, RBI repo rate

No news sentiment

Scrape NSE announcements or use FinBERT on headlines

Only LSTM

Compare with Transformer, XGBoost on features, or Prophet

No trading costs

Account for STT, brokerage, and slippage in any backtest

No naïve baseline

Always compare MAPE against a model that predicts "tomorrow = today"

Fixed sequence length

Use attention mechanisms to learn optimal lookback dynamically


Conclusion

We built a corrected, end-to-end multivariate Machine Learning pipeline to predict Indian stock prices:

  • yfinance with auto_adjust=True and MultiIndex flattening for reliable NSE/BSE data

  • 15-feature input — Close, OHLCV, RSI, MACD, Bollinger Bands, volatility, moving averages — all actually fed into the model

  • scikit-learn MinMaxScaler fitted on training rows only, with a correct multivariate inverse-transform helper

  • Stacked LSTM with Dropout, EarlyStopping, and ReduceLROnPlateau

  • Train/test split applied correctly to the sequence arrays, not to the raw DataFrame

  • scikit-learn metrics (MAE, RMSE, MAPE) computed on properly flattened 1-D arrays

Happy coding and happy investing! 🇮🇳


Tested with: Python 3.11 · TensorFlow 2.x · yfinance 0.2.x · scikit-learn 1.x · pandas 2.x


Full Runnable Script

# ============================================================
#  Indian Stock Market Price Prediction using Machine Learning
#  ML stack: scikit-learn (preprocessing + metrics)
#            TensorFlow/Keras LSTM (deep learning model)
#  Works with: Python 3.11 | yfinance 0.2.x | TensorFlow 2.x
#  Install: pip install yfinance pandas numpy matplotlib
#           scikit-learn tensorflow
# ============================================================

import warnings
warnings.filterwarnings("ignore")

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf

from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import mean_absolute_error, mean_squared_error

from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import LSTM, Dense, Dropout
from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau


# ── CONFIG ────────────────────────────────────────────────────────────────────
TICKER          = "RELIANCE.NS"   # NSE ticker — try TCS.NS, HDFCBANK.NS, INFY.NS
START_DATE      = "2019-01-01"
END_DATE        = "2024-12-31"
SEQUENCE_LENGTH = 60              # lookback window: 60 trading days ≈ 3 months
TRAIN_RATIO     = 0.80            # 80 % train, 20 % test
EPOCHS          = 100
BATCH_SIZE      = 32

# All feature columns fed into the LSTM
FEATURE_COLS = [
    "Close", "Open", "High", "Low", "Volume",
    "Return", "Volatility", "RSI",
    "BB_Mid", "BB_Upper", "BB_Lower",
    "MACD", "Signal", "MA50", "MA200"
]
N_FEATURES = len(FEATURE_COLS)          # 15
CLOSE_IDX  = FEATURE_COLS.index("Close")  # 0


# ── STEP 1: FETCH DATA ────────────────────────────────────────────────────────
print(f"\n[1/8] Downloading data for {TICKER} ...")
df = yf.download(TICKER, start=START_DATE, end=END_DATE, auto_adjust=True)

# Flatten MultiIndex columns (yfinance >= 0.2 returns nested columns)
df.columns = df.columns.get_level_values(0)

df = df[["Open", "High", "Low", "Close", "Volume"]].dropna()
print(f"      {len(df)} trading days fetched  ({START_DATE} → {END_DATE})")


# ── STEP 2: VISUALISE RAW PRICE ───────────────────────────────────────────────
print("\n[2/8] Plotting closing price with moving averages ...")
ma50  = df["Close"].rolling(50).mean()
ma200 = df["Close"].rolling(200).mean()

plt.figure(figsize=(14, 5))
plt.plot(df["Close"], label="Close Price", linewidth=1.5, color="steelblue")
plt.plot(ma50,        label="50-Day MA",   linewidth=1.2, linestyle="--", color="orange")
plt.plot(ma200,       label="200-Day MA",  linewidth=1.2, linestyle=":",  color="green")
plt.title(f"{TICKER} — Closing Price with Moving Averages")
plt.xlabel("Date")
plt.ylabel("Price (INR ₹)")
plt.legend()
plt.tight_layout()
plt.show()


# ── STEP 3: FEATURE ENGINEERING ───────────────────────────────────────────────
print("\n[3/8] Engineering technical indicators ...")

def add_features(df):
    df = df.copy()

    df["Return"]     = df["Close"].pct_change()
    df["Volatility"] = df["Return"].rolling(10).std()

    delta        = df["Close"].diff()
    gain         = delta.clip(lower=0).rolling(14).mean()
    loss         = (-delta.clip(upper=0)).rolling(14).mean()
    rs           = gain / (loss + 1e-9)
    df["RSI"]    = 100 - (100 / (1 + rs))

    df["BB_Mid"]   = df["Close"].rolling(20).mean()
    df["BB_Upper"] = df["BB_Mid"] + 2 * df["Close"].rolling(20).std()
    df["BB_Lower"] = df["BB_Mid"] - 2 * df["Close"].rolling(20).std()

    ema12          = df["Close"].ewm(span=12, adjust=False).mean()
    ema26          = df["Close"].ewm(span=26, adjust=False).mean()
    df["MACD"]     = ema12 - ema26
    df["Signal"]   = df["MACD"].ewm(span=9, adjust=False).mean()

    df["MA50"]     = df["Close"].rolling(50).mean()
    df["MA200"]    = df["Close"].rolling(200).mean()

    return df.dropna()

df = add_features(df)
print(f"      {len(df)} rows after feature engineering and dropna")


# ── STEP 4: SCALE + CREATE SEQUENCES ──────────────────────────────────────────
print("\n[4/8] Scaling data and creating sequences ...")

# Fit scaler only on the first TRAIN_RATIO portion of df to prevent leakage
fit_end     = int(len(df) * TRAIN_RATIO)
scaler      = MinMaxScaler()
scaler.fit(df[FEATURE_COLS].iloc[:fit_end])
scaled_data = scaler.transform(df[FEATURE_COLS])


def create_sequences(data, seq_len, close_idx):
    """
    data       : 2-D scaled array (n_rows, n_features)
    seq_len    : past timesteps used as input
    close_idx  : column index of Close — used as the prediction target
    """
    X, y = [], []
    for i in range(seq_len, len(data)):
        X.append(data[i - seq_len:i, :])   # all features
        y.append(data[i, close_idx])        # next-day Close
    return np.array(X), np.array(y)

X, y = create_sequences(scaled_data, SEQUENCE_LENGTH, CLOSE_IDX)

# Split MUST be applied to X/y (not df) because create_sequences
# reduces row count by SEQUENCE_LENGTH
split = int(len(X) * TRAIN_RATIO)

X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]

# Shape: (samples, timesteps, features) — already correct, no reshape needed
print(f"      Train: {X_train.shape}  |  Test: {X_test.shape}")


# ── STEP 5: BUILD MODEL ────────────────────────────────────────────────────────
print("\n[5/8] Building LSTM model ...")

model = Sequential([
    LSTM(64, return_sequences=True, input_shape=(SEQUENCE_LENGTH, N_FEATURES)),
    Dropout(0.2),
    LSTM(64, return_sequences=False),
    Dropout(0.2),
    Dense(32, activation="relu"),
    Dense(1)
])

model.compile(optimizer="adam", loss="mean_squared_error")
model.summary()


# ── STEP 6: TRAIN ─────────────────────────────────────────────────────────────
print("\n[6/8] Training model ...")

early_stop = EarlyStopping(
    monitor="val_loss", patience=10,
    restore_best_weights=True, verbose=1
)
reduce_lr = ReduceLROnPlateau(
    monitor="val_loss", factor=0.5,
    patience=5, min_lr=1e-6, verbose=1
)

history = model.fit(
    X_train, y_train,
    epochs           = EPOCHS,
    batch_size       = BATCH_SIZE,
    validation_split = 0.10,
    callbacks        = [early_stop, reduce_lr],
    verbose          = 1
)

plt.figure(figsize=(10, 4))
plt.plot(history.history["loss"],     label="Train Loss")
plt.plot(history.history["val_loss"], label="Validation Loss")
plt.title("Model Loss Over Epochs")
plt.xlabel("Epoch")
plt.ylabel("MSE Loss")
plt.legend()
plt.tight_layout()
plt.show()


# ── STEP 7: EVALUATE ──────────────────────────────────────────────────────────
print("\n[7/8] Evaluating on test set ...")

def inverse_transform_close(scaler, values_1d, n_features, close_idx):
    """
    Inverse-transform a 1-D array of scaled Close values back to INR.
    Reconstructs a full-width zero array so the multi-column scaler
    can invert correctly.
    """
    dummy = np.zeros((len(values_1d), n_features))
    dummy[:, close_idx] = values_1d
    return scaler.inverse_transform(dummy)[:, close_idx]

y_pred_scaled = model.predict(X_test).flatten()

y_pred = inverse_transform_close(scaler, y_pred_scaled, N_FEATURES, CLOSE_IDX)
y_true = inverse_transform_close(scaler, y_test,        N_FEATURES, CLOSE_IDX)

mae  = mean_absolute_error(y_true, y_pred)
rmse = np.sqrt(mean_squared_error(y_true, y_pred))
mape = np.mean(np.abs((y_true - y_pred) / y_true)) * 100

print(f"\n      MAE  : ₹{mae:.2f}")
print(f"      RMSE : ₹{rmse:.2f}")
print(f"      MAPE : {mape:.2f}%")

plt.figure(figsize=(14, 5))
plt.plot(y_true, label="Actual Price (₹)",    linewidth=1.5, color="steelblue")
plt.plot(y_pred, label="Predicted Price (₹)", linewidth=1.5, color="tomato", linestyle="--")
plt.title(f"{TICKER} — Actual vs Predicted Closing Price (Test Set)")
plt.xlabel("Trading Days (Test Period)")
plt.ylabel("Price (INR ₹)")
plt.legend()
plt.tight_layout()
plt.show()


# ── STEP 8: PREDICT NEXT TRADING DAY ─────────────────────────────────────────
print("\n[8/8] Predicting next trading day ...")

def predict_next_day(model, df, scaler, feature_cols, n_features, close_idx, seq_len=60):
    """Returns the predicted closing price (INR) for the next trading day."""
    recent_scaled = scaler.transform(df[feature_cols].iloc[-seq_len:])
    X_input       = recent_scaled.reshape(1, seq_len, n_features)
    pred_scaled   = model.predict(X_input).flatten()
    return inverse_transform_close(scaler, pred_scaled, n_features, close_idx)[0]

next_day_price = predict_next_day(model, df, scaler, FEATURE_COLS, N_FEATURES, CLOSE_IDX)
last_price     = float(df["Close"].iloc[-1])
change_pct     = ((next_day_price - last_price) / last_price) * 100

print(f"\n      Ticker             : {TICKER}")
print(f"      Last Closing Price : ₹{last_price:.2f}")
print(f"      Predicted Next Day : ₹{next_day_price:.2f}")
print(f"      Expected Change    : {change_pct:+.2f}%")
print("\n✅  Done!")

To try a different stock, change the TICKER variable at the top of the CONFIG section. All NSE-listed stocks follow the <SYMBOL>.NS format. BSE-listed stocks use <SYMBOL>.BO instead.

Samod Alex

Samod Alex

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Related Posts

OpenRouter: One API Key to Rule Them All 🔑

Overview Hey everyone 👋 Managing AI models in 2026 has gotten ridiculous. Anthropic key here, OpenAI key there, a separate billing account for Google, another ...

Read article

Massing: The Open-Source AEC Platform That Covers the Entire Building Lifecycle

For most of the industry's history, AEC software has worked in silos — one tool for feasibility, another for BIM modeling, yet another for construction manageme...

Read article

Building a Native GTK4 PostgreSQL Client Because I Was Tired of Electron

Why I built a native GTK4 PostgreSQL client instead of another Electron app — schema designer, AI analytics, and a Python/Perl hook system included.

Read article

The Rise of AI Trading Among Retail Investors

Robinhood just let customers connect Claude and ChatGPT straight to a trading account. Zerodha, Alpaca, and a wave of no-code platforms have made AI trading a one-click feature for retail investors everywhere. The catch: real-money experiments like Alpha Arena show most AI models still lose, and the accounts that hold up keep a human in the loop.

Read article

The Alpine Mirage: How Upgrading Python Broke My Build and Led to a Truer Security Posture

A deep dive into how upgrading a Python Docker image to Alpine 3.15 caused cascading build failures across C/Rust dependencies—and why switching to Debian-slim with non-root privileges resulted in a faster, truly secure container architecture.

Read article