AI Trading Signal Generator on AWS

Build an ML pipeline that generates stock signals and sends alerts via AWS.

Introduction

30 Second Summary

Imagine waking up every morning to a message on your phone telling you exactly which stocks and crypto look like a buy or sell today. No manual chart-reading, no guesswork.

In this project, you will build a fully automated AI trading signal pipeline that collects real Indian stock and crypto market data, trains a machine learning model to predict BUY, SELL, and HOLD signals, and delivers daily alerts to your email and WhatsApp.

What You'll Build

A Python-based signal generator that fetches live market data, computes technical indicators, trains a Random Forest classifier, exports results to Excel, and runs daily on AWS Lambda with automatic email and WhatsApp notifications.

By the end of this project, you'll have:

  • A Python script that fetches historical OHLCV data for Indian NSE stocks and crypto pairs, computes six technical indicators (RSI, MACD, SMA, Bollinger Bands, ADX), and trains an ML model to generate BUY/SELL/HOLD predictions.
  • A cloud-deployed Lambda function scheduled to run daily via EventBridge Scheduler, so signals are generated without any manual intervention.
  • Automatic email alerts via AWS SES and WhatsApp notifications via Twilio delivered every time new signals are detected.
  • Secret Mission: Add a portfolio simulation that calculates profit/loss for each signal and includes cumulative returns in your exports and notifications.

Are there any prerequisites?

You will need Python and pip installed on your machine, along with basic familiarity with Python and pandas.

You will also need an AWS account (free tier is sufficient) and a free Twilio trial account. Both are set up during the project.

Before We Start

Stock markets and crypto exchanges generate massive amounts of price data every single day. Traders use technical indicators and machine learning to spot patterns in this data and generate buy or sell signals.

In this step, you will set up your development environment and install all the Python packages needed to build an AI-powered trading signal generator. By the end, you will have a working script file ready for the data collection and model training ahead.

In this step, get ready to:
  • Install all required Python packages for data analysis, ML, and notifications.
  • Create the main script file and verify all library imports.
  • Configure stock symbols, time periods, and notification settings.
Check prerequisites and install packages

This project requires Python and pip to be installed on your machine. Let's verify that first.

  • Check your Python installation by running this command:
python --version

✔️ I see a version number

You are all set. Move on to the next instruction.

ⓧ Command not found

You need to install Python before continuing.

  • Download Python from python.org/downloads.
  • During installation, check the box that says Add Python to PATH.
  • After installation, close and reopen your terminal, then run python --version again to confirm.

You also need a code editor and two cloud accounts to complete this project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it. If it is not installed, download it from code.visualstudio.com.
  • Create a free AWS account at aws.amazon.com/free if you do not already have one.
  • Create a free Twilio trial account at twilio.com/try-twilio. You will use this for WhatsApp notifications in a later step.

Now let's create your project folder and install all the Python packages you need.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir trading-signal-generator
cd trading-signal-generator
  • Confirm the folder exists by running:
ls

You should see an empty directory. That is expected since you just created it.

  • Install all required Python packages by running this command:
pip install yfinance==1.3.0 pandas-ta-classic==0.4.47 scikit-learn==1.8.0 matplotlib openpyxl==3.1.5 boto3 twilio

What are these packages?

yfinance pulls historical stock and crypto price data from Yahoo Finance. pandas-ta-classic computes technical indicators like RSI and MACD directly on pandas DataFrames.

scikit-learn provides the Random Forest classifier for generating trading signals. matplotlib and openpyxl handle chart generation and Excel export.

boto3 is the AWS SDK for Python, used for sending emails via SES. twilio is the SDK for sending WhatsApp notifications.

The installation may take a minute or two. You should see Successfully installed messages for each package once it finishes.

Seeing installation errors?

Make sure you are running the command from inside your trading-signal-generator folder. If pip is not found, try pip3 instead.

help me fix pip installation errors for yfinance, pandas-ta-classic, and scikit-learn

Create the main script and verify imports

With all packages installed, the next step is creating your main Python script and verifying that every library imports correctly.

  • Open your trading-signal-generator folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the trading-signal-generator folder on your Desktop, then click Open.
  • Create a new file called signal_generator.py by clicking the new file icon in the Explorer sidebar.
  • Add the following imports to signal_generator.py by copying this code into the file:
import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
from datetime import datetime

# Quick check to confirm all libraries loaded
print("All libraries imported successfully!")
print(f"pandas-ta-classic version: {ta.version}")

What does this code do?

  • yfinance (aliased as yf) downloads historical market data from Yahoo Finance for any stock or crypto symbol.
  • pandas-ta-classic (aliased as ta) adds technical indicator functions directly to pandas DataFrames.
  • RandomForestClassifier from scikit-learn is the ML model that will predict BUY, SELL, or HOLD signals.
  • The two print() lines at the bottom confirm that every library loaded without errors.
  • Save signal_generator.py.
  • Open the VS Code terminal by pressing Ctrl+` (backtick key, usually above Tab).
  • Run the script by entering this command:
python signal_generator.py

You should see All libraries imported successfully! followed by the pandas-ta-classic version number in your terminal.

Seeing a ModuleNotFoundError?

This means one or more packages did not install correctly. Double check that the pip install command from the previous substep completed without errors. Re-run the install command and try again.

Also confirm your terminal is in the trading-signal-generator folder. If you are using a virtual environment, make sure it is activated.

help me fix a ModuleNotFoundError when running signal_generator.py

Add the configuration variables

Your script needs to know which stock and crypto symbols to analyze, how far back to pull historical data, and where to send notifications. Configuration variables keep all of these settings in one place at the top of your file.

  • In signal_generator.py, add the following configuration below the print() lines:
# Configuration
STOCK_SYMBOL = "RELIANCE.NS"
CRYPTO_SYMBOL = "BTC-INR"
PERIOD = "2y"
INTERVAL = "1d"
EXPORT_FILENAME = "trading_signals.xlsx"

# Notification config (will be populated in later steps)
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
AWS_REGION = "ap-south-1"

What do these settings control?

  • RELIANCE.NS is Reliance Industries on India's National Stock Exchange. The .NS suffix tells yfinance to pull NSE data.
  • BTC-INR is Bitcoin priced in Indian Rupees.
  • PERIOD = "2y" pulls two years of history. INTERVAL = "1d" means one row per trading day.
  • ap-south-1 is the AWS Mumbai region, closest to Indian markets.
  • Save signal_generator.py and run it again:
python signal_generator.py

You should see the same All libraries imported successfully! message with no errors. The configuration variables load silently since nothing uses them yet.

✔️ Awesome, I've got everything!

Great. Double check you have saved signal_generator.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete signal_generator.py so far. Make sure your file matches this exactly.

import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
from datetime import datetime

print("All libraries imported successfully!")
print(f"pandas-ta-classic version: {ta.version}")

# Configuration
STOCK_SYMBOL = "RELIANCE.NS"
CRYPTO_SYMBOL = "BTC-INR"
PERIOD = "2y"
INTERVAL = "1d"
EXPORT_FILENAME = "trading_signals.xlsx"

# Notification config (will be populated in later steps)
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
AWS_REGION = "ap-south-1"

Your environment is ready and your script structure is in place. Next up, you will fetch real market data and compute technical indicators for both symbols.

Set Up the Project Environment

Before writing any data collection or ML logic, your script needs to know where to send its results. The signal generator will support three notification channels: AWS SES for email, Gmail as a local alternative, and Twilio for WhatsApp alerts.

In this step, you will expand the configuration section of signal_generator.py with all the credentials and settings these channels need. You will not fill in the real values yet. Placeholder strings keep the script runnable while you set up each service in later steps.

In this step, get ready to:
  • Add Gmail SMTP configuration as a local email alternative.
  • Add Twilio WhatsApp configuration for mobile alerts.
  • Set up an email mode toggle to switch between AWS SES and Gmail.
Expand the configuration for notifications

Your script currently has a small configuration block with market data settings and a placeholder notification comment. You need to replace this with a structured configuration that groups settings by purpose: market data, AWS SES, Gmail, and Twilio.

  • In VS Code, open signal_generator.py.
  • Find the # Configuration comment line. Select everything from that line down to the last line of the file (AWS_REGION = "ap-south-1").
  • Replace the selected block with the following expanded configuration:
# ============================================================
# Configuration
# ============================================================
STOCK_SYMBOL = "RELIANCE.NS"
CRYPTO_SYMBOL = "BTC-INR"
PERIOD = "2y"
INTERVAL = "1d"
EXPORT_FILENAME = "trading_signals.xlsx"

# AWS SES config
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
AWS_REGION = "ap-south-1"

# Gmail config (local alternative)
GMAIL_ADDRESS = "your-gmail@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"

# Twilio config
TWILIO_ACCOUNT_SID = "your-account-sid-here"
TWILIO_AUTH_TOKEN = "your-auth-token-here"
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = "whatsapp:+91XXXXXXXXXX"

# Set to "aws" to use SES, or "gmail" to use Gmail SMTP
EMAIL_MODE = "aws"

What does this configuration do?

The configuration is split into four groups, each serving a different part of the pipeline:

  • Market data settings tell yfinance which symbols to fetch and how much history to pull. RELIANCE.NS is Reliance Industries on the NSE. BTC-INR is Bitcoin priced in Indian Rupees.
  • AWS SES config holds the sender/recipient email addresses and the AWS region for the cloud email path.
  • Gmail config provides a zero-cloud alternative using Python's built-in SMTP library. You will fill in a real App Password in a later step.
  • Twilio config stores the credentials for the WhatsApp sandbox. +14155238886 is Twilio's shared sandbox number.
  • EMAIL_MODE is a toggle that lets you switch between the AWS SES and Gmail email paths without changing any other code.

Placeholder values are intentional

You do not need to fill in real credentials right now. Every placeholder string like your-account-sid-here will be replaced with your actual values when you set up each notification service in Steps 5, 6, and 8.

  • Save signal_generator.py.
  • Run the script to confirm it still executes without errors by running:
python signal_generator.py

You should see All libraries imported successfully! followed by the pandas-ta-classic version number.

Seeing a SyntaxError?

  • Check that every string in the configuration is wrapped in matching double quotes. A missing closing quote on one line will break everything below it.
  • Make sure the TWILIO_WHATSAPP_FROM value starts with whatsapp: followed by the phone number. The colon is part of the string.
  • Confirm no lines were accidentally deleted from the import section above the configuration.

Why is my signal_generator.py throwing a SyntaxError after I updated the configuration section?

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

Here is the complete signal_generator.py file as it should look at the end of this step:

import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
from datetime import datetime

print("All libraries imported successfully!")
print(f"pandas-ta-classic version: {ta.version}")

# ============================================================
# Configuration
# ============================================================
STOCK_SYMBOL = "RELIANCE.NS"
CRYPTO_SYMBOL = "BTC-INR"
PERIOD = "2y"
INTERVAL = "1d"
EXPORT_FILENAME = "trading_signals.xlsx"

# AWS SES config
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
AWS_REGION = "ap-south-1"

# Gmail config (local alternative)
GMAIL_ADDRESS = "your-gmail@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"

# Twilio config
TWILIO_ACCOUNT_SID = "your-account-sid-here"
TWILIO_AUTH_TOKEN = "your-auth-token-here"
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = "whatsapp:+91XXXXXXXXXX"

# Set to "aws" to use SES, or "gmail" to use Gmail SMTP
EMAIL_MODE = "aws"

Your script is configured and ready. Next up, you will fetch real market data for Reliance Industries and Bitcoin, then compute technical indicators to feed your ML model.

Collect Market Data and Compute Technical Indicators

Your environment is ready and every library is installed. Now it's time to pull in real financial data and start preparing it for machine learning.

A trading signal model is only as good as its input data. Raw price data alone is not enough. You need to compute technical indicators that capture momentum, trend strength, and volatility. In this step, you'll fetch historical OHLCV data using yfinance, then compute six indicators using pandas-ta-classic.

In this step, get ready to:
  • Fetch two years of daily OHLCV data for an Indian stock and a crypto pair.
  • Compute six technical indicators including RSI, MACD, SMA, Bollinger Bands, and ADX.
  • Verify the full data pipeline works for both symbols.
Fetch historical data using yfinance

The yfinance library connects to Yahoo Finance and downloads historical price data for any ticker symbol. Indian NSE stocks use a .NS suffix (like RELIANCE.NS), and crypto pairs like BTC-INR are also supported.

  • In signal_generator.py, find the line EMAIL_MODE = "aws" at the bottom of the configuration section. Add the data collection function below it by copying this code:
# ============================================================
# Data Collection
# ============================================================
def fetch_market_data(symbol, period, interval):
    """Fetch historical OHLCV data from Yahoo Finance."""
    print(f"Fetching data for {symbol}...")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period, interval=interval)
    df = df[["Open", "High", "Low", "Close", "Volume"]]
    df.index.name = "Date"
    print(f"  Retrieved {len(df)} rows from {df.index[0].date()} to {df.index[-1].date()}")
    return df

What does this code do?

  • yf.Ticker(symbol) creates a connection to the Yahoo Finance page for any given stock or crypto symbol.
  • ticker.history() downloads the historical OHLCV data. OHLCV stands for Open, High, Low, Close, Volume. These are the five core price metrics recorded for every trading day.
  • The function filters to only those five columns and sets the index name to Date for clean downstream processing.
  • Save signal_generator.py.

You won't see any output from this function yet. The next substep adds the indicator computation, and then you'll run both together.

Seeing a red underline under yf or ta?

Make sure the function is placed below all the import statements at the top of the file. If VS Code shows an import error, confirm you installed yfinance in Step 1 and that VS Code is using the correct Python interpreter.

Help me fix import errors in my signal_generator.py file.

Compute technical indicators

Raw prices don't tell a model much on their own. Technical indicators transform price data into features that capture momentum, trend direction, and volatility. Each indicator highlights a different aspect of market behavior.

  • Below the fetch_market_data function you just added, add the technical indicators function by copying this code:
# ============================================================
# Technical Indicators
# ============================================================
def add_technical_indicators(df):
    """Add technical indicators to the DataFrame."""
    print("Computing technical indicators...")
    df.ta.rsi(length=14, append=True)
    df.ta.macd(fast=12, slow=26, signal=9, append=True)
    df.ta.sma(length=20, append=True)
    df.ta.sma(length=50, append=True)
    df.ta.bbands(length=20, std=2, append=True)
    df.ta.adx(length=14, append=True)
    df.dropna(inplace=True)
    print(f"  Added indicators. {len(df)} rows after removing warm-up NaN values.")
    return df

What do these indicators measure?

  • RSI (Relative Strength Index): measures whether a stock is overbought (above 70) or oversold (below 30) based on recent price changes.
  • MACD (Moving Average Convergence Divergence): tracks the relationship between two moving averages to spot trend changes. It produces three columns: the MACD line, histogram, and signal line.
  • SMA (Simple Moving Average): smooths out price noise. The 20-day SMA captures short-term trends. The 50-day SMA captures medium-term trends.
  • Bollinger Bands: three bands (lower, middle, upper) that expand and contract with volatility. Prices near the upper band may signal overbought conditions.
  • ADX (Average Directional Index): measures how strong a trend is, regardless of direction. Values above 25 indicate a strong trend.

The append=True parameter tells pandas-ta-classic to add new columns directly to the existing DataFrame. The dropna() call removes the first ~50 rows where indicators don't have enough history to compute values.

  • Save signal_generator.py.

You won't see output yet. The next substep calls both functions and prints the results.

Seeing a syntax error in the indicator function?

Check that every line inside the function is indented with four spaces. Also confirm the function is placed after the closing return df of fetch_market_data, not inside it.

Help me fix a syntax error in my add_technical_indicators function.

Run data collection for both symbols

With both functions defined, it's time to call them for your two target markets: RELIANCE.NS (an Indian equity) and BTC-INR (Bitcoin priced in Indian Rupees).

  • Below the add_technical_indicators function, add the main execution code that fetches data and computes indicators for both symbols by copying this code:
# ============================================================
# Main Execution (local run)
# ============================================================
stock_df = fetch_market_data(STOCK_SYMBOL, PERIOD, INTERVAL)
stock_df = add_technical_indicators(stock_df)

crypto_df = fetch_market_data(CRYPTO_SYMBOL, PERIOD, INTERVAL)
crypto_df = add_technical_indicators(crypto_df)

print(f"\nStock columns: {list(stock_df.columns)}")
print(f"Crypto columns: {list(crypto_df.columns)}")

What does this code do?

  • It calls fetch_market_data for both RELIANCE.NS and BTC-INR, downloading two years of daily price data for each.
  • It passes each DataFrame through add_technical_indicators to add all six indicators and drop incomplete rows.
  • The final print statements let you verify that all 17 columns (5 OHLCV + 12 indicator columns) are present.
  • Save signal_generator.py.
  • Run the script by running this command:
python signal_generator.py
  • You should see output showing the number of rows retrieved for each symbol, the number of rows remaining after dropping warm-up NaN values, and a full list of column names including RSI_14, MACD_12_26_9, SMA_20, BBL_20_2.0, ADX_14, and more.

Why pandas-ta-classic instead of TA-Lib?

pandas-ta-classic is pure Python and installs with a simple pip install. TA-Lib requires C binaries that are notoriously difficult to install on Windows. pandas-ta-classic provides over 70 indicators and integrates directly with pandas DataFrames using the .ta extension.

Seeing an error when running the script?

  • If you see ModuleNotFoundError: No module named 'pandas_ta_classic', run pip install pandas-ta-classic==0.4.47 again and retry.
  • If you see No data found for this date range, check your internet connection. yfinance needs to reach Yahoo Finance servers to download data.
  • If you see an IndentationError, make sure each function's body is indented with exactly four spaces. Mixing tabs and spaces causes this error.

Help me debug an error when running my signal_generator.py script.

Want to double check your full code so far? Use the tabs below to compare.

✔️ Awesome, I've got everything!

Great. Double check you've saved signal_generator.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete signal_generator.py file as it should look at the end of this step.

import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
from datetime import datetime

print("All libraries imported successfully!")
print(f"pandas-ta-classic version: {ta.version}")

# ============================================================
# Configuration
# ============================================================
STOCK_SYMBOL = "RELIANCE.NS"
CRYPTO_SYMBOL = "BTC-INR"
PERIOD = "2y"
INTERVAL = "1d"
EXPORT_FILENAME = "trading_signals.xlsx"

# AWS SES config
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
AWS_REGION = "ap-south-1"

# Gmail config (local alternative)
GMAIL_ADDRESS = "your-gmail@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"

# Twilio config
TWILIO_ACCOUNT_SID = "your-account-sid-here"
TWILIO_AUTH_TOKEN = "your-auth-token-here"
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = "whatsapp:+91XXXXXXXXXX"

# Set to "aws" to use SES, or "gmail" to use Gmail SMTP
EMAIL_MODE = "aws"


# ============================================================
# Data Collection
# ============================================================
def fetch_market_data(symbol, period, interval):
    """Fetch historical OHLCV data from Yahoo Finance."""
    print(f"Fetching data for {symbol}...")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period, interval=interval)
    df = df[["Open", "High", "Low", "Close", "Volume"]]
    df.index.name = "Date"
    print(f"  Retrieved {len(df)} rows from {df.index[0].date()} to {df.index[-1].date()}")
    return df


# ============================================================
# Technical Indicators
# ============================================================
def add_technical_indicators(df):
    """Add technical indicators to the DataFrame."""
    print("Computing technical indicators...")
    df.ta.rsi(length=14, append=True)
    df.ta.macd(fast=12, slow=26, signal=9, append=True)
    df.ta.sma(length=20, append=True)
    df.ta.sma(length=50, append=True)
    df.ta.bbands(length=20, std=2, append=True)
    df.ta.adx(length=14, append=True)
    df.dropna(inplace=True)
    print(f"  Added indicators. {len(df)} rows after removing warm-up NaN values.")
    return df


# ============================================================
# Main Execution (local run)
# ============================================================
stock_df = fetch_market_data(STOCK_SYMBOL, PERIOD, INTERVAL)
stock_df = add_technical_indicators(stock_df)

crypto_df = fetch_market_data(CRYPTO_SYMBOL, PERIOD, INTERVAL)
crypto_df = add_technical_indicators(crypto_df)

print(f"\nStock columns: {list(stock_df.columns)}")
print(f"Crypto columns: {list(crypto_df.columns)}")

You now have two DataFrames loaded with real market data and 12 indicator columns each. Next up, you'll label this data with BUY, SELL, and HOLD signals and train a machine learning model to predict them.

Label Data and Train the Random Forest Model

Your DataFrames are loaded with two years of market data and 12 technical indicators. The next question is: how does a machine learning model learn when to buy or sell?

The model needs labeled examples. In this step, you will label each historical row as BUY, SELL, or HOLD based on what the price actually did next. Then you will train a Random Forest classifier to predict those labels from the technical indicators alone.

In this step, get ready to:
  • Write a labeling function that tags each row as BUY, SELL, or HOLD using future price movement.
  • Train a Random Forest classifier on the labeled data and evaluate its accuracy.
  • Run the full labeling and training pipeline for both the stock and crypto DataFrames.
Create trend labels from future price movement

To teach the model what a good entry or exit looks like, you need to look ahead in the historical data. If the average closing price over the next 10 days rose significantly, that row is labeled BUY. If it dropped, it is labeled SELL. Everything in between is HOLD.

  • In signal_generator.py, find the comment that says # Main Execution (local run).
  • Add the following create_labels function directly above that comment:
# ============================================================
# Trend Labeling
# ============================================================
def create_labels(df, horizon=10, threshold=0.02):
    """Label each row based on future price movement."""
    print(f"Creating labels (horizon={horizon}, threshold={threshold*100}%)...")
    # Calculate the average closing price over the next 'horizon' days
    future_avg = df["Close"].shift(-horizon).rolling(window=horizon).mean().shift(-horizon + 1)
    # Compute the percentage change between current close and future average
    future_returns = (future_avg - df["Close"]) / df["Close"]

    # Label as BUY (1) if future return exceeds threshold, SELL (-1) if below
    conditions = [future_returns > threshold, future_returns < -threshold]
    choices = [1, -1]
    df["Signal"] = np.select(conditions, choices, default=0)

    # Remove rows with NaN values and trim the look-ahead tail
    df.dropna(inplace=True)
    df = df[df.index < df.index[-horizon]]

    label_counts = df["Signal"].value_counts()
    print(f"  Labels - BUY: {label_counts.get(1, 0)}, SELL: {label_counts.get(-1, 0)}, HOLD: {label_counts.get(0, 0)}")
    return df

What does this code do?

This function uses a forward-looking approach to decide what the "right" action was at each point in history.

  • future_avg calculates the average closing price over the next 10 trading days.
  • future_returns measures the percentage change from today's close to that future average.
  • np.select assigns BUY (1) when the future return exceeds the threshold, SELL (-1) when it drops below, and HOLD (0) for everything in between.
  • The tail of the DataFrame is trimmed because the last 10 rows do not have enough future data to form a reliable label.

Why a 2% threshold for stocks?

A 2% move over 10 days is a meaningful trend for NSE equities like RELIANCE.NS. It filters out day-to-day noise so the model focuses on real directional moves.

For crypto, you will use a 3% threshold instead because crypto markets are significantly more volatile.

  • Save signal_generator.py.

You will not see a change yet. The next substep builds the training function that uses these labels.

Train the Random Forest classifier

With labeled data, you can now train a model to predict signals from the technical indicators. The Random Forest algorithm creates many decision trees and combines their votes for a final prediction. It handles correlated features well and does not require you to normalize your data first.

  • In signal_generator.py, add the following train_model function directly below the create_labels function you just wrote (still above the # Main Execution comment):
# ============================================================
# Model Training
# ============================================================
def train_model(df):
    """Train a Random Forest classifier on the labeled data."""
    print("Training Random Forest model...")
    # Use all 12 technical indicators as input features
    feature_columns = [
        "RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
        "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
        "ADX_14", "DMP_14", "DMN_14"
    ]
    X = df[feature_columns]
    y = df["Signal"]

    # Split chronologically: first 80% for training, last 20% for testing
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False)

    # Train a Random Forest with 100 trees and max depth of 10
    model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1)
    model.fit(X_train, y_train)

    # Evaluate the model on the test set
    y_pred = model.predict(X_test)
    accuracy = accuracy_score(y_test, y_pred)
    print(f"\n  Model Accuracy: {accuracy:.2%}")
    print("\n  Classification Report:")
    print(classification_report(y_test, y_pred, target_names=["SELL", "HOLD", "BUY"]))

    # Add predictions for the entire dataset
    df["Predicted_Signal"] = model.predict(X)

    # Show which indicators matter most to the model
    importance = pd.Series(model.feature_importances_, index=feature_columns)
    importance = importance.sort_values(ascending=False)
    print("  Top Feature Importances:")
    for feat, imp in importance.head(5).items():
        print(f"    {feat}: {imp:.4f}")

    return df, model, accuracy

What does this code do?

  • feature_columns lists the 12 technical indicators the model uses as inputs. These are the exact column names that pandas-ta-classic added in the previous step.
  • train_test_split(shuffle=False) splits the data chronologically. The first 80% becomes training data and the last 20% becomes testing data. Shuffling is turned off to avoid look-ahead bias with time series data.
  • classification_report prints precision, recall, and F1-score for each class (SELL, HOLD, BUY) so you can see where the model performs well and where it struggles.
  • feature_importances_ reveals which indicators the model relies on most. This helps you understand what drives its predictions.

Why shuffle=False?

Financial data is sequential. If you shuffle rows before splitting, the model trains on future data and tests on past data. That inflates accuracy and produces a model that looks great on paper but fails in live markets.

Keeping shuffle=False ensures the model only ever trains on older data and is tested against newer data it has never seen.

  • Save signal_generator.py.

You will not see a change yet. The next substep calls both functions and prints the results.

Run labeling and training for both symbols

Now you will wire up the labeling and training pipeline for both your NSE stock and crypto DataFrames.

  • In signal_generator.py, scroll down to the bottom of the file and find these two lines:
print(f"\nStock columns: {list(stock_df.columns)}")
print(f"Crypto columns: {list(crypto_df.columns)}")
  • Delete both print lines and replace them with the following code:
# Label the stock data with a 2% threshold
stock_df = create_labels(stock_df, horizon=10, threshold=0.02)
stock_df, stock_model, stock_accuracy = train_model(stock_df)

# Label the crypto data with a higher 3% threshold for its volatility
crypto_df = create_labels(crypto_df, horizon=10, threshold=0.03)
crypto_df, crypto_model, crypto_accuracy = train_model(crypto_df)

What does this code do?

  • create_labels adds a Signal column to each DataFrame with values 1 (BUY), -1 (SELL), or 0 (HOLD).
  • train_model trains a separate Random Forest for each symbol and returns the updated DataFrame, the trained model, and the accuracy score.
  • The crypto threshold is set to 0.03 (3%) instead of 0.02 (2%) because crypto prices swing more than equities.
  • Save signal_generator.py.
  • Run the script by entering this command in your terminal:
python signal_generator.py

You should see the label counts for each symbol (BUY, SELL, HOLD), followed by a model accuracy percentage and a classification report table with precision, recall, and F1-score columns.

Seeing a KeyError for a column name?

  • Make sure the add_technical_indicators function from the previous step is still intact. The feature column names (like RSI_14, MACD_12_26_9) are generated by pandas-ta-classic and must match exactly.
  • Check that the feature_columns list in train_model is copied exactly as shown, including decimal points in BBL_20_2.0.
  • If you see ValueError: Found input variables with inconsistent numbers of samples, make sure create_labels is called before train_model for each symbol.

Help me debug a KeyError in my trading signal generator

✔️ Awesome, I've got everything!

Great. Double check you have saved signal_generator.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete signal_generator.py file as it should look at the end of this step. Compare it against your file and fix any differences.

import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
from datetime import datetime

print("All libraries imported successfully!")
print(f"pandas-ta-classic version: {ta.version}")

# ============================================================
# Configuration
# ============================================================
STOCK_SYMBOL = "RELIANCE.NS"
CRYPTO_SYMBOL = "BTC-INR"
PERIOD = "2y"
INTERVAL = "1d"
EXPORT_FILENAME = "trading_signals.xlsx"

# AWS SES config
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
AWS_REGION = "ap-south-1"

# Gmail config (local alternative)
GMAIL_ADDRESS = "your-gmail@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"

# Twilio config
TWILIO_ACCOUNT_SID = "your-account-sid-here"
TWILIO_AUTH_TOKEN = "your-auth-token-here"
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = "whatsapp:+91XXXXXXXXXX"

# Set to "aws" to use SES, or "gmail" to use Gmail SMTP
EMAIL_MODE = "aws"


# ============================================================
# Data Collection
# ============================================================
def fetch_market_data(symbol, period, interval):
    """Fetch historical OHLCV data from Yahoo Finance."""
    print(f"Fetching data for {symbol}...")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period, interval=interval)
    df = df[["Open", "High", "Low", "Close", "Volume"]]
    df.index.name = "Date"
    print(f"  Retrieved {len(df)} rows from {df.index[0].date()} to {df.index[-1].date()}")
    return df


# ============================================================
# Technical Indicators
# ============================================================
def add_technical_indicators(df):
    """Add technical indicators to the DataFrame."""
    print("Computing technical indicators...")
    df.ta.rsi(length=14, append=True)
    df.ta.macd(fast=12, slow=26, signal=9, append=True)
    df.ta.sma(length=20, append=True)
    df.ta.sma(length=50, append=True)
    df.ta.bbands(length=20, std=2, append=True)
    df.ta.adx(length=14, append=True)
    df.dropna(inplace=True)
    print(f"  Added indicators. {len(df)} rows after removing warm-up NaN values.")
    return df


# ============================================================
# Trend Labeling
# ============================================================
def create_labels(df, horizon=10, threshold=0.02):
    """Label each row based on future price movement."""
    print(f"Creating labels (horizon={horizon}, threshold={threshold*100}%)...")
    future_avg = df["Close"].shift(-horizon).rolling(window=horizon).mean().shift(-horizon + 1)
    future_returns = (future_avg - df["Close"]) / df["Close"]

    conditions = [future_returns > threshold, future_returns < -threshold]
    choices = [1, -1]
    df["Signal"] = np.select(conditions, choices, default=0)

    df.dropna(inplace=True)
    df = df[df.index < df.index[-horizon]]

    label_counts = df["Signal"].value_counts()
    print(f"  Labels - BUY: {label_counts.get(1, 0)}, SELL: {label_counts.get(-1, 0)}, HOLD: {label_counts.get(0, 0)}")
    return df


# ============================================================
# Model Training
# ============================================================
def train_model(df):
    """Train a Random Forest classifier on the labeled data."""
    print("Training Random Forest model...")
    feature_columns = [
        "RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
        "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
        "ADX_14", "DMP_14", "DMN_14"
    ]
    X = df[feature_columns]
    y = df["Signal"]

    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False)

    model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1)
    model.fit(X_train, y_train)

    y_pred = model.predict(X_test)
    accuracy = accuracy_score(y_test, y_pred)
    print(f"\n  Model Accuracy: {accuracy:.2%}")
    print("\n  Classification Report:")
    print(classification_report(y_test, y_pred, target_names=["SELL", "HOLD", "BUY"]))

    df["Predicted_Signal"] = model.predict(X)

    importance = pd.Series(model.feature_importances_, index=feature_columns)
    importance = importance.sort_values(ascending=False)
    print("  Top Feature Importances:")
    for feat, imp in importance.head(5).items():
        print(f"    {feat}: {imp:.4f}")

    return df, model, accuracy


# ============================================================
# Main Execution (local run)
# ============================================================
stock_df = fetch_market_data(STOCK_SYMBOL, PERIOD, INTERVAL)
stock_df = add_technical_indicators(stock_df)

crypto_df = fetch_market_data(CRYPTO_SYMBOL, PERIOD, INTERVAL)
crypto_df = add_technical_indicators(crypto_df)

stock_df = create_labels(stock_df, horizon=10, threshold=0.02)
stock_df, stock_model, stock_accuracy = train_model(stock_df)

crypto_df = create_labels(crypto_df, horizon=10, threshold=0.03)
crypto_df, crypto_model, crypto_accuracy = train_model(crypto_df)

Your model is trained and producing predictions. Next up, you will visualize the BUY and SELL signals on a price chart and export everything to a structured Excel workbook.

Visualize Signals and Export to Excel

Your Random Forest model is trained and generating BUY, SELL, and HOLD predictions for both symbols. Numbers in a DataFrame are useful, but they do not tell the full story.

In this step, you will plot the predicted signals directly on price charts so you can visually inspect where the model enters and exits. You will also export everything to a structured Excel workbook and build a text summary that later steps will use for email and WhatsApp notifications.

In this step, get ready to:
  • Plot BUY/SELL signals on price charts with an RSI subplot.
  • Export raw data, indicators, predictions, and signal summaries to a multi-sheet Excel workbook.
  • Generate a text report summarizing the latest signals for notifications.
Visualize signals on a price chart

A chart with entry and exit points overlaid on the price line makes it easy to spot whether the model is catching trends or generating noise. You will create a two-panel figure: the top panel shows the closing price with SMA lines and BUY/SELL markers, and the bottom panel shows the RSI oscillator with overbought and oversold zones.

  • In signal_generator.py, find the line that says # Main Execution (local run). Add the following code above that section comment:
# ============================================================
# Visualization
# ============================================================
def plot_signals(df, symbol, accuracy):
    """Plot price chart with BUY/SELL signals."""
    print(f"Generating signal chart for {symbol}...")
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), gridspec_kw={"height_ratios": [3, 1]})

    # Plot closing price and moving averages
    ax1.plot(df.index, df["Close"], label="Close Price", color="black", linewidth=1)
    ax1.plot(df.index, df["SMA_20"], label="SMA 20", color="blue", linewidth=0.8, alpha=0.7)
    ax1.plot(df.index, df["SMA_50"], label="SMA 50", color="orange", linewidth=0.8, alpha=0.7)

    # Overlay BUY and SELL signals as scatter markers
    buy_signals = df[df["Predicted_Signal"] == 1]
    sell_signals = df[df["Predicted_Signal"] == -1]

    ax1.scatter(buy_signals.index, buy_signals["Close"], marker="^", color="green",
                label=f"BUY ({len(buy_signals)})", s=50, zorder=5)
    ax1.scatter(sell_signals.index, sell_signals["Close"], marker="v", color="red",
                label=f"SELL ({len(sell_signals)})", s=50, zorder=5)

    ax1.set_title(f"{symbol} - AI Trading Signals (Accuracy: {accuracy:.1%})")
    ax1.set_ylabel("Price")
    ax1.legend(loc="upper left")
    ax1.grid(True, alpha=0.3)

    # Plot RSI with overbought/oversold zones
    ax2.plot(df.index, df["RSI_14"], color="purple", linewidth=1)
    ax2.axhline(y=70, color="red", linestyle="--", alpha=0.5)
    ax2.axhline(y=30, color="green", linestyle="--", alpha=0.5)
    ax2.fill_between(df.index, 70, 100, alpha=0.1, color="red")
    ax2.fill_between(df.index, 0, 30, alpha=0.1, color="green")
    ax2.set_title("RSI (14)")
    ax2.set_ylabel("RSI")
    ax2.set_ylim(0, 100)
    ax2.grid(True, alpha=0.3)

    # Save chart to PNG
    plt.tight_layout()
    chart_filename = f"{symbol.replace('.', '_').replace('-', '_')}_signals.png"
    plt.savefig(chart_filename, dpi=150, bbox_inches="tight")
    print(f"  Chart saved as {chart_filename}")

What does this code do?

  • plt.subplots(2, 1) creates a two-panel figure. The top panel takes 3x the height of the bottom panel, giving the price chart more room.
  • Green up-triangles (^) mark BUY signals and red down-triangles (v) mark SELL signals, scattered at the closing price on that date.
  • The RSI subplot draws horizontal lines at 70 (overbought) and 30 (oversold) with shaded zones so you can quickly spot extreme readings.
  • The chart is saved as a PNG file. Dots and dashes in the symbol name are replaced with underscores for a clean filename.
  • Save signal_generator.py.

You will not see output yet. The next substep adds the remaining functions, then you will run everything together.

Export to Excel and generate a signal summary

Charts are great for visual inspection, but you also need the raw numbers for deeper analysis. An Excel workbook with dedicated sheets for OHLCV data, indicators, predictions, and signal summaries gives you a structured backtesting reference. You will also build a text summary function that condenses the latest signal into a short report for notifications.

  • In signal_generator.py, add the following code directly below the plot_signals function you just added (still above the # Main Execution (local run) section):
# ============================================================
# Excel Export and Signal Summary
# ============================================================
def export_to_excel(df, symbol, filename):
    """Export data to a multi-sheet Excel workbook."""
    print(f"Exporting {symbol} data to Excel...")

    # Separate the DataFrame into logical sheets
    raw_data = df[["Open", "High", "Low", "Close", "Volume"]].copy()
    indicator_cols = ["RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
                      "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
                      "ADX_14", "DMP_14", "DMN_14"]
    indicators = df[indicator_cols].copy()

    # Build a predictions sheet with human-readable labels
    predictions = df[["Close", "Signal", "Predicted_Signal"]].copy()
    predictions["Signal_Label"] = predictions["Predicted_Signal"].map({1: "BUY", -1: "SELL", 0: "HOLD"})

    # Filter to actionable signals only
    signal_summary = predictions[predictions["Predicted_Signal"] != 0].copy()
    signal_summary["Price_at_Signal"] = signal_summary["Close"]

    # Use a clean sheet prefix from the symbol name
    sheet_prefix = symbol.replace(".", "_").replace("-", "_")

    # Write all four sheets, appending if the file already exists
    with pd.ExcelWriter(filename, engine="openpyxl",
                         mode="a" if pd.io.common.file_exists(filename) else "w") as writer:
        raw_data.to_excel(writer, sheet_name=f"{sheet_prefix}_OHLCV")
        indicators.to_excel(writer, sheet_name=f"{sheet_prefix}_Indicators")
        predictions.to_excel(writer, sheet_name=f"{sheet_prefix}_Predictions")
        signal_summary.to_excel(writer, sheet_name=f"{sheet_prefix}_Signals")
    print(f"  Exported 4 sheets for {symbol} to {filename}")

What does this code do?

  • The function slices the DataFrame into four logical groups: raw OHLCV prices, technical indicator columns, predictions with human-readable labels, and a filtered sheet of actionable (non-HOLD) signals only.
  • pd.ExcelWriter with the openpyxl engine creates the workbook. The mode="a" flag appends sheets to an existing file so you can call this function for both symbols without overwriting.
  • Sheet names use a clean prefix derived from the symbol name. For example, RELIANCE.NS becomes RELIANCE_NS_OHLCV.
  • Now add the signal summary function directly below export_to_excel:
def generate_signal_summary(df, symbol, accuracy):
    """Generate a text summary of the latest signals for notifications."""
    latest = df.iloc[-1]
    signal_map = {1: "BUY", -1: "SELL", 0: "HOLD"}
    latest_signal = signal_map.get(latest["Predicted_Signal"], "UNKNOWN")

    # Count recent BUY/SELL signals in the last 5 trading days
    recent_buys = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == 1])
    recent_sells = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == -1])

    # Build a compact text report for email/WhatsApp
    summary = (
        f"--- {symbol} Signal Report ---\n"
        f"Date: {latest.name.strftime('%Y-%m-%d')}\n"
        f"Close Price: {latest['Close']:.2f}\n"
        f"Latest Signal: {latest_signal}\n"
        f"RSI: {latest['RSI_14']:.1f}\n"
        f"MACD: {latest['MACD_12_26_9']:.4f}\n"
        f"ADX: {latest['ADX_14']:.1f}\n"
        f"Model Accuracy: {accuracy:.1%}\n"
        f"Last 5 days: {recent_buys} BUY, {recent_sells} SELL\n"
    )
    return summary, latest_signal

What does this code do?

  • df.iloc[-1] grabs the most recent row in the DataFrame, which is the latest trading day with a prediction.
  • The summary string includes the closing price, key indicator values, and a count of recent BUY/SELL signals. This compact format is what you will send via email and WhatsApp in later steps.
  • The function returns both the summary text and the latest signal string. You will use the signal string to decide whether to trigger an alert.
  • Save signal_generator.py.

You will not see output yet. The next substep calls all three functions and runs the full pipeline.

Run visualization and export

All three functions are defined. Now you will call them at the bottom of the file to generate charts, create the Excel workbook, and assemble the full signal report.

  • Scroll to the very bottom of signal_generator.py. Find this line:
crypto_df, crypto_model, crypto_accuracy = train_model(crypto_df)
  • Add the following code directly below that line:
# Generate signal charts for both symbols
plot_signals(stock_df, STOCK_SYMBOL, stock_accuracy)
plot_signals(crypto_df, CRYPTO_SYMBOL, crypto_accuracy)

# Export all data to a multi-sheet Excel workbook
export_to_excel(stock_df, STOCK_SYMBOL, EXPORT_FILENAME)
export_to_excel(crypto_df, CRYPTO_SYMBOL, EXPORT_FILENAME)

# Build signal summaries for notifications
stock_summary, stock_latest = generate_signal_summary(stock_df, STOCK_SYMBOL, stock_accuracy)
crypto_summary, crypto_latest = generate_signal_summary(crypto_df, CRYPTO_SYMBOL, crypto_accuracy)

# Assemble the full report
full_report = f"AI Trading Signal Report - {datetime.now().strftime('%Y-%m-%d')}\n\n"
full_report += stock_summary + "\n" + crypto_summary
print(full_report)

What does this code do?

  • plot_signals is called for both the stock and crypto DataFrames, saving two PNG chart files to your project folder.
  • export_to_excel is called twice. The first call creates the Excel file with 4 sheets for the stock. The second call appends 4 more sheets for crypto, giving you 8 sheets total.
  • generate_signal_summary produces a compact text report for each symbol. The full_report variable combines both summaries into one message that you will send via email and WhatsApp in later steps.
  • Save signal_generator.py.
  • Run the script by running:
python signal_generator.py
  • Confirm the files exist by running:
ls

You should see RELIANCE_NS_signals.png, BTC_INR_signals.png, and trading_signals.xlsx in the output alongside your signal_generator.py file.

Your terminal should also print the full signal report with both the stock and crypto summaries, including the latest signal, RSI, MACD, and model accuracy for each symbol.

Open the charts to inspect them

Double-click either PNG file in your file explorer to view it. You should see the closing price line with green up-arrows (BUY) and red down-arrows (SELL) scattered along it. The RSI subplot at the bottom shows the momentum oscillator with shaded overbought and oversold zones.

Not seeing the chart files?

  • Make sure you saved signal_generator.py before running the script. Unsaved changes will not execute.
  • Check that the plot_signals function is placed above the Main Execution section, not inside it. If Python does not see the function definition before the call, you will get a NameError.
  • If you see an error about file_exists, delete any leftover trading_signals.xlsx file from a previous run and try again.
  • Help me debug my signal generator script.

✔️ Awesome, I've got everything!

Great. Double check you have saved signal_generator.py and can see both chart PNGs and the Excel file in your project folder.

ⓧ I'd like to double check the full code

Here is the complete signal_generator.py file as it should look at the end of this step.

import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
from datetime import datetime

print("All libraries imported successfully!")
print(f"pandas-ta-classic version: {ta.version}")

# ============================================================
# Configuration
# ============================================================
STOCK_SYMBOL = "RELIANCE.NS"
CRYPTO_SYMBOL = "BTC-INR"
PERIOD = "2y"
INTERVAL = "1d"
EXPORT_FILENAME = "trading_signals.xlsx"

# AWS SES config
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
AWS_REGION = "ap-south-1"

# Gmail config (local alternative)
GMAIL_ADDRESS = "your-gmail@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"

# Twilio config
TWILIO_ACCOUNT_SID = "your-account-sid-here"
TWILIO_AUTH_TOKEN = "your-auth-token-here"
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = "whatsapp:+91XXXXXXXXXX"

# Set to "aws" to use SES, or "gmail" to use Gmail SMTP
EMAIL_MODE = "aws"


# ============================================================
# Data Collection
# ============================================================
def fetch_market_data(symbol, period, interval):
    """Fetch historical OHLCV data from Yahoo Finance."""
    print(f"Fetching data for {symbol}...")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period, interval=interval)
    df = df[["Open", "High", "Low", "Close", "Volume"]]
    df.index.name = "Date"
    print(f"  Retrieved {len(df)} rows from {df.index[0].date()} to {df.index[-1].date()}")
    return df


# ============================================================
# Technical Indicators
# ============================================================
def add_technical_indicators(df):
    """Add technical indicators to the DataFrame."""
    print("Computing technical indicators...")
    df.ta.rsi(length=14, append=True)
    df.ta.macd(fast=12, slow=26, signal=9, append=True)
    df.ta.sma(length=20, append=True)
    df.ta.sma(length=50, append=True)
    df.ta.bbands(length=20, std=2, append=True)
    df.ta.adx(length=14, append=True)
    df.dropna(inplace=True)
    print(f"  Added indicators. {len(df)} rows after removing warm-up NaN values.")
    return df


# ============================================================
# Trend Labeling
# ============================================================
def create_labels(df, horizon=10, threshold=0.02):
    """Label each row based on future price movement."""
    print(f"Creating labels (horizon={horizon}, threshold={threshold*100}%)...")
    future_avg = df["Close"].shift(-horizon).rolling(window=horizon).mean().shift(-horizon + 1)
    future_returns = (future_avg - df["Close"]) / df["Close"]

    conditions = [future_returns > threshold, future_returns < -threshold]
    choices = [1, -1]
    df["Signal"] = np.select(conditions, choices, default=0)

    df.dropna(inplace=True)
    df = df[df.index < df.index[-horizon]]

    label_counts = df["Signal"].value_counts()
    print(f"  Labels - BUY: {label_counts.get(1, 0)}, SELL: {label_counts.get(-1, 0)}, HOLD: {label_counts.get(0, 0)}")
    return df


# ============================================================
# Model Training
# ============================================================
def train_model(df):
    """Train a Random Forest classifier on the labeled data."""
    print("Training Random Forest model...")
    feature_columns = [
        "RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
        "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
        "ADX_14", "DMP_14", "DMN_14"
    ]
    X = df[feature_columns]
    y = df["Signal"]

    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False)

    model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1)
    model.fit(X_train, y_train)

    y_pred = model.predict(X_test)
    accuracy = accuracy_score(y_test, y_pred)
    print(f"\n  Model Accuracy: {accuracy:.2%}")
    print("\n  Classification Report:")
    print(classification_report(y_test, y_pred, target_names=["SELL", "HOLD", "BUY"]))

    df["Predicted_Signal"] = model.predict(X)

    importance = pd.Series(model.feature_importances_, index=feature_columns)
    importance = importance.sort_values(ascending=False)
    print("  Top Feature Importances:")
    for feat, imp in importance.head(5).items():
        print(f"    {feat}: {imp:.4f}")

    return df, model, accuracy


# ============================================================
# Visualization
# ============================================================
def plot_signals(df, symbol, accuracy):
    """Plot price chart with BUY/SELL signals."""
    print(f"Generating signal chart for {symbol}...")
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), gridspec_kw={"height_ratios": [3, 1]})

    ax1.plot(df.index, df["Close"], label="Close Price", color="black", linewidth=1)
    ax1.plot(df.index, df["SMA_20"], label="SMA 20", color="blue", linewidth=0.8, alpha=0.7)
    ax1.plot(df.index, df["SMA_50"], label="SMA 50", color="orange", linewidth=0.8, alpha=0.7)

    buy_signals = df[df["Predicted_Signal"] == 1]
    sell_signals = df[df["Predicted_Signal"] == -1]

    ax1.scatter(buy_signals.index, buy_signals["Close"], marker="^", color="green",
                label=f"BUY ({len(buy_signals)})", s=50, zorder=5)
    ax1.scatter(sell_signals.index, sell_signals["Close"], marker="v", color="red",
                label=f"SELL ({len(sell_signals)})", s=50, zorder=5)

    ax1.set_title(f"{symbol} - AI Trading Signals (Accuracy: {accuracy:.1%})")
    ax1.set_ylabel("Price")
    ax1.legend(loc="upper left")
    ax1.grid(True, alpha=0.3)

    ax2.plot(df.index, df["RSI_14"], color="purple", linewidth=1)
    ax2.axhline(y=70, color="red", linestyle="--", alpha=0.5)
    ax2.axhline(y=30, color="green", linestyle="--", alpha=0.5)
    ax2.fill_between(df.index, 70, 100, alpha=0.1, color="red")
    ax2.fill_between(df.index, 0, 30, alpha=0.1, color="green")
    ax2.set_title("RSI (14)")
    ax2.set_ylabel("RSI")
    ax2.set_ylim(0, 100)
    ax2.grid(True, alpha=0.3)

    plt.tight_layout()
    chart_filename = f"{symbol.replace('.', '_').replace('-', '_')}_signals.png"
    plt.savefig(chart_filename, dpi=150, bbox_inches="tight")
    print(f"  Chart saved as {chart_filename}")


# ============================================================
# Excel Export and Signal Summary
# ============================================================
def export_to_excel(df, symbol, filename):
    """Export data to a multi-sheet Excel workbook."""
    print(f"Exporting {symbol} data to Excel...")
    raw_data = df[["Open", "High", "Low", "Close", "Volume"]].copy()
    indicator_cols = ["RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
                      "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
                      "ADX_14", "DMP_14", "DMN_14"]
    indicators = df[indicator_cols].copy()
    predictions = df[["Close", "Signal", "Predicted_Signal"]].copy()
    predictions["Signal_Label"] = predictions["Predicted_Signal"].map({1: "BUY", -1: "SELL", 0: "HOLD"})
    signal_summary = predictions[predictions["Predicted_Signal"] != 0].copy()
    signal_summary["Price_at_Signal"] = signal_summary["Close"]
    sheet_prefix = symbol.replace(".", "_").replace("-", "_")

    with pd.ExcelWriter(filename, engine="openpyxl",
                         mode="a" if pd.io.common.file_exists(filename) else "w") as writer:
        raw_data.to_excel(writer, sheet_name=f"{sheet_prefix}_OHLCV")
        indicators.to_excel(writer, sheet_name=f"{sheet_prefix}_Indicators")
        predictions.to_excel(writer, sheet_name=f"{sheet_prefix}_Predictions")
        signal_summary.to_excel(writer, sheet_name=f"{sheet_prefix}_Signals")
    print(f"  Exported 4 sheets for {symbol} to {filename}")


def generate_signal_summary(df, symbol, accuracy):
    """Generate a text summary of the latest signals for notifications."""
    latest = df.iloc[-1]
    signal_map = {1: "BUY", -1: "SELL", 0: "HOLD"}
    latest_signal = signal_map.get(latest["Predicted_Signal"], "UNKNOWN")

    recent_buys = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == 1])
    recent_sells = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == -1])

    summary = (
        f"--- {symbol} Signal Report ---\n"
        f"Date: {latest.name.strftime('%Y-%m-%d')}\n"
        f"Close Price: {latest['Close']:.2f}\n"
        f"Latest Signal: {latest_signal}\n"
        f"RSI: {latest['RSI_14']:.1f}\n"
        f"MACD: {latest['MACD_12_26_9']:.4f}\n"
        f"ADX: {latest['ADX_14']:.1f}\n"
        f"Model Accuracy: {accuracy:.1%}\n"
        f"Last 5 days: {recent_buys} BUY, {recent_sells} SELL\n"
    )
    return summary, latest_signal


# ============================================================
# Main Execution (local run)
# ============================================================
stock_df = fetch_market_data(STOCK_SYMBOL, PERIOD, INTERVAL)
stock_df = add_technical_indicators(stock_df)

crypto_df = fetch_market_data(CRYPTO_SYMBOL, PERIOD, INTERVAL)
crypto_df = add_technical_indicators(crypto_df)

stock_df = create_labels(stock_df, horizon=10, threshold=0.02)
stock_df, stock_model, stock_accuracy = train_model(stock_df)

crypto_df = create_labels(crypto_df, horizon=10, threshold=0.03)
crypto_df, crypto_model, crypto_accuracy = train_model(crypto_df)

plot_signals(stock_df, STOCK_SYMBOL, stock_accuracy)
plot_signals(crypto_df, CRYPTO_SYMBOL, crypto_accuracy)

export_to_excel(stock_df, STOCK_SYMBOL, EXPORT_FILENAME)
export_to_excel(crypto_df, CRYPTO_SYMBOL, EXPORT_FILENAME)

stock_summary, stock_latest = generate_signal_summary(stock_df, STOCK_SYMBOL, stock_accuracy)
crypto_summary, crypto_latest = generate_signal_summary(crypto_df, CRYPTO_SYMBOL, crypto_accuracy)

full_report = f"AI Trading Signal Report - {datetime.now().strftime('%Y-%m-%d')}\n\n"
full_report += stock_summary + "\n" + crypto_summary
print(full_report)

Your pipeline now fetches data, computes indicators, trains a model, plots professional signal charts, and exports everything to Excel. Next up, you will wire up email alerts so the signal report lands in your inbox automatically.

Send Email Alerts with AWS SES

Your signal generator now produces charts, Excel workbooks, and text summaries. But you still have to run the script manually and read the output yourself.

In this step, you will connect your script to AWS SES (Simple Email Service) so it can send the signal report straight to your inbox. Every time the script runs, you will get an email with the latest BUY, SELL, and HOLD signals for both your stock and crypto symbols.

In this step, get ready to:
  • Install and configure the AWS CLI with your credentials.
  • Verify your email address in AWS SES.
  • Write and test an email notification function using boto3.
Install and configure AWS CLI

The AWS CLI lets you interact with AWS services directly from your terminal. You need it to verify your email address in SES and to configure the credentials that boto3 (the Python AWS SDK) will use when sending emails.

  • Check if the AWS CLI is already installed by running this command:
aws --version

✔️ I see a version number

The AWS CLI is already installed. You are ready to configure it.

ⓧ Command not found

  • Download the AWS CLI installer from https://awscli.amazonaws.com/AWSCLIV2.msi.
  • Run the downloaded .msi file and follow the setup wizard prompts to complete the installation.
  • Close and reopen your terminal, then run aws --version again to confirm the installation.

Still seeing command not found?

Make sure you closed and reopened your terminal after the install. The AWS CLI is added to your system PATH during installation, but existing terminal sessions do not pick it up automatically.

Help me troubleshoot the AWS CLI installation on my machine.

Before you can use the CLI, you need an Access Key from your AWS account. This key pair lets the CLI authenticate as your IAM user.

  • Sign in to the AWS Management Console.
  • Search for IAM in the top search bar and open the IAM service.
  • Click Users in the left sidebar, then click on your IAM user name.
  • Select the Security credentials tab.
  • Scroll to Access keys and click Create access key.
  • Select Command Line Interface (CLI) as the use case, acknowledge the recommendation, and click Next.
  • Click Create access key.
  • Copy both the Access key ID and Secret access key and save them somewhere safe. You will not be able to see the secret key again after closing this page.

Keep your keys safe

Your Access Key ID and Secret Access Key are sensitive credentials. Never share them publicly, commit them to Git, or paste them into code files.

  • Configure the AWS CLI with your credentials by running this command:
aws configure

What will the CLI ask me?

The command prompts you for four values, one at a time:

  • AWS Access Key ID - paste the Access Key ID you just copied.
  • AWS Secret Access Key - paste the Secret Access Key you just copied.
  • Default region name - enter ap-south-1 (Mumbai region).
  • Default output format - enter json.

Now you need to verify your email address in SES. In sandbox mode, SES requires both the sender and recipient email to be verified. Since you are sending notifications to yourself, you only need to verify one address.

  • Verify your email address in SES by running this command (replace the email with your own):
aws ses verify-email-identity --email-address your-email@example.com --region ap-south-1
  • Open your email inbox and look for a message from AWS with the subject "Amazon Web Services - Email Address Verification Request".
  • Click the verification link inside the email.

What is SES sandbox mode?

New AWS accounts start in SES sandbox mode. This means you can only send emails to verified addresses. For this project, that is perfectly fine because you are sending signal reports to yourself.

The free tier covers 3,000 emails per month for the first 12 months. One daily signal report stays well within that limit.

Did not receive the verification email?

Check your spam and junk folders. The email comes from no-reply-aws@amazon.com. If it still does not appear, wait a couple of minutes and try running the verify command again.

Help me troubleshoot AWS SES email verification.

Write the email notification function

With SES verified, you can now add a function to your script that sends the signal report as an email. The function uses boto3 to create an SES client and call the send_email() API.

  • In signal_generator.py, find the import line that says import matplotlib.pyplot as plt.
  • Add these two new import lines directly below it, before the from datetime import datetime line:
import boto3
from botocore.exceptions import ClientError

What do these imports do?

boto3 is the official AWS SDK for Python. It lets your script create service clients (like SES) and call AWS APIs directly from code.

ClientError is the specific exception boto3 raises when an AWS API call fails. Catching it lets you print a clear error message instead of crashing the script.

  • Save signal_generator.py.

Your import block should now look like this:

import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
import boto3
from botocore.exceptions import ClientError
from datetime import datetime

Now add the email function itself. It creates an SES client, constructs the email, and handles errors gracefully.

  • In signal_generator.py, scroll down to the section comment # Main Execution (local run).
  • Add the following function and its section header above that main execution comment:
# ============================================================
# Email: AWS SES
# ============================================================
def send_email_alert(report, sender, recipient, region):
    """Send trading signal report via AWS SES."""
    print("Sending email alert via AWS SES...")
    # Create an SES client for the specified region
    ses_client = boto3.client("ses", region_name=region)
    try:
        # Build and send the email
        response = ses_client.send_email(
            Source=sender,
            Destination={"ToAddresses": [recipient]},
            Message={
                "Subject": {
                    "Data": f"Trading Signals - {datetime.now().strftime('%Y-%m-%d')}",
                    "Charset": "UTF-8"
                },
                "Body": {"Text": {"Data": report, "Charset": "UTF-8"}}
            }
        )
        print(f"  Email sent! Message ID: {response['MessageId']}")
        return True
    except ClientError as e:
        # Print the AWS error message if sending fails
        print(f"  Email failed: {e.response['Error']['Message']}")
        return False

What does this function do?

boto3.client("ses", region_name=region) creates an SES client pointed at the Mumbai region where you verified your email.

send_email() accepts a source address, a list of recipients, and a message body. The function returns True if the email sends successfully, or False if SES returns an error. This lets the rest of the script continue running even if the email fails.

  • Save signal_generator.py.
Test the email notification locally

Before you test, the configuration section of your script still has placeholder email addresses. You need to update them so SES knows where to send the report.

  • In signal_generator.py, scroll up to the Configuration section and find these two lines:
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
  • Replace both placeholder strings with the email address you verified in SES. Since sandbox mode requires both sender and recipient to be verified, use the same email address for both.

Now add the function call that actually sends the email. The full_report variable already holds the combined signal summary from the previous step.

  • Scroll to the very bottom of signal_generator.py and find this line:
print(full_report)
  • Add this line directly below it to call the email function:
# Send the signal report via email
email_sent = send_email_alert(full_report, EMAIL_SENDER, EMAIL_RECIPIENT, AWS_REGION)

What does this line do?

This calls your new send_email_alert() function with the full signal report, your sender and recipient email addresses, and the AWS region. The result is stored in email_sent so you can check whether the email was delivered successfully.

  • Save signal_generator.py.
  • Run the script by running this command:
python signal_generator.py
  • Watch the terminal output. You should see Sending email alert via AWS SES... followed by Email sent! Message ID: and a long ID string.
  • Open your email inbox. You should see an email with the subject "Trading Signals" followed by today's date, containing the full signal report for both RELIANCE.NS and BTC-INR.

Seeing "Email failed" in the terminal?

If the error says "Email address is not verified", make sure you clicked the verification link from AWS and that the email address in EMAIL_SENDER matches exactly what you verified (including capitalization).

If the error mentions credentials or authorization, double-check that aws configure completed successfully and your IAM user has SES permissions.

Help me debug my AWS SES email sending error.

✔️ Awesome, I've got everything!

Double check you have saved your file.

ⓧ I'd like to double check the full code

Here is the full signal_generator.py file up to this point. Compare it with yours to spot any differences.

import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
import boto3
from botocore.exceptions import ClientError
from datetime import datetime

print("All libraries imported successfully!")
print(f"pandas-ta-classic version: {ta.version}")

# ============================================================
# Configuration
# ============================================================
STOCK_SYMBOL = "RELIANCE.NS"
CRYPTO_SYMBOL = "BTC-INR"
PERIOD = "2y"
INTERVAL = "1d"
EXPORT_FILENAME = "trading_signals.xlsx"

# AWS SES config
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
AWS_REGION = "ap-south-1"

# Gmail config (local alternative)
GMAIL_ADDRESS = "your-gmail@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"

# Twilio config
TWILIO_ACCOUNT_SID = "your-account-sid-here"
TWILIO_AUTH_TOKEN = "your-auth-token-here"
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = "whatsapp:+91XXXXXXXXXX"

# Set to "aws" to use SES, or "gmail" to use Gmail SMTP
EMAIL_MODE = "aws"


# ============================================================
# Data Collection
# ============================================================
def fetch_market_data(symbol, period, interval):
    print(f"Fetching data for {symbol}...")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period, interval=interval)
    df = df[["Open", "High", "Low", "Close", "Volume"]]
    df.index.name = "Date"
    print(f"  Retrieved {len(df)} rows from {df.index[0].date()} to {df.index[-1].date()}")
    return df


def add_technical_indicators(df):
    print("Computing technical indicators...")
    df.ta.rsi(length=14, append=True)
    df.ta.macd(fast=12, slow=26, signal=9, append=True)
    df.ta.sma(length=20, append=True)
    df.ta.sma(length=50, append=True)
    df.ta.bbands(length=20, std=2, append=True)
    df.ta.adx(length=14, append=True)
    df.dropna(inplace=True)
    print(f"  Added indicators. {len(df)} rows after removing warm-up NaN values.")
    return df


def create_labels(df, horizon=10, threshold=0.02):
    print(f"Creating labels (horizon={horizon}, threshold={threshold*100}%)...")
    future_avg = df["Close"].shift(-horizon).rolling(window=horizon).mean().shift(-horizon + 1)
    future_returns = (future_avg - df["Close"]) / df["Close"]
    conditions = [future_returns > threshold, future_returns < -threshold]
    choices = [1, -1]
    df["Signal"] = np.select(conditions, choices, default=0)
    df.dropna(inplace=True)
    df = df[df.index < df.index[-horizon]]
    label_counts = df["Signal"].value_counts()
    print(f"  Labels - BUY: {label_counts.get(1, 0)}, SELL: {label_counts.get(-1, 0)}, HOLD: {label_counts.get(0, 0)}")
    return df


def train_model(df):
    print("Training Random Forest model...")
    feature_columns = [
        "RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
        "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
        "ADX_14", "DMP_14", "DMN_14"
    ]
    X = df[feature_columns]
    y = df["Signal"]
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False)
    model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1)
    model.fit(X_train, y_train)
    y_pred = model.predict(X_test)
    accuracy = accuracy_score(y_test, y_pred)
    print(f"\n  Model Accuracy: {accuracy:.2%}")
    print("\n  Classification Report:")
    print(classification_report(y_test, y_pred, target_names=["SELL", "HOLD", "BUY"]))
    df["Predicted_Signal"] = model.predict(X)
    importance = pd.Series(model.feature_importances_, index=feature_columns)
    importance = importance.sort_values(ascending=False)
    print("  Top Feature Importances:")
    for feat, imp in importance.head(5).items():
        print(f"    {feat}: {imp:.4f}")
    return df, model, accuracy


def plot_signals(df, symbol, accuracy):
    print(f"Generating signal chart for {symbol}...")
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), gridspec_kw={"height_ratios": [3, 1]})
    ax1.plot(df.index, df["Close"], label="Close Price", color="black", linewidth=1)
    ax1.plot(df.index, df["SMA_20"], label="SMA 20", color="blue", linewidth=0.8, alpha=0.7)
    ax1.plot(df.index, df["SMA_50"], label="SMA 50", color="orange", linewidth=0.8, alpha=0.7)
    buy_signals = df[df["Predicted_Signal"] == 1]
    sell_signals = df[df["Predicted_Signal"] == -1]
    ax1.scatter(buy_signals.index, buy_signals["Close"], marker="^", color="green",
                label=f"BUY ({len(buy_signals)})", s=50, zorder=5)
    ax1.scatter(sell_signals.index, sell_signals["Close"], marker="v", color="red",
                label=f"SELL ({len(sell_signals)})", s=50, zorder=5)
    ax1.set_title(f"{symbol} - AI Trading Signals (Accuracy: {accuracy:.1%})")
    ax1.set_ylabel("Price")
    ax1.legend(loc="upper left")
    ax1.grid(True, alpha=0.3)
    ax2.plot(df.index, df["RSI_14"], color="purple", linewidth=1)
    ax2.axhline(y=70, color="red", linestyle="--", alpha=0.5)
    ax2.axhline(y=30, color="green", linestyle="--", alpha=0.5)
    ax2.fill_between(df.index, 70, 100, alpha=0.1, color="red")
    ax2.fill_between(df.index, 0, 30, alpha=0.1, color="green")
    ax2.set_title("RSI (14)")
    ax2.set_ylabel("RSI")
    ax2.set_ylim(0, 100)
    ax2.grid(True, alpha=0.3)
    plt.tight_layout()
    chart_filename = f"{symbol.replace('.', '_').replace('-', '_')}_signals.png"
    plt.savefig(chart_filename, dpi=150, bbox_inches="tight")
    print(f"  Chart saved as {chart_filename}")


def export_to_excel(df, symbol, filename):
    print(f"Exporting {symbol} data to Excel...")
    raw_data = df[["Open", "High", "Low", "Close", "Volume"]].copy()
    indicator_cols = ["RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
                      "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
                      "ADX_14", "DMP_14", "DMN_14"]
    indicators = df[indicator_cols].copy()
    predictions = df[["Close", "Signal", "Predicted_Signal"]].copy()
    predictions["Signal_Label"] = predictions["Predicted_Signal"].map({1: "BUY", -1: "SELL", 0: "HOLD"})
    signal_summary = predictions[predictions["Predicted_Signal"] != 0].copy()
    signal_summary["Price_at_Signal"] = signal_summary["Close"]
    sheet_prefix = symbol.replace(".", "_").replace("-", "_")
    with pd.ExcelWriter(filename, engine="openpyxl",
                         mode="a" if pd.io.common.file_exists(filename) else "w") as writer:
        raw_data.to_excel(writer, sheet_name=f"{sheet_prefix}_OHLCV")
        indicators.to_excel(writer, sheet_name=f"{sheet_prefix}_Indicators")
        predictions.to_excel(writer, sheet_name=f"{sheet_prefix}_Predictions")
        signal_summary.to_excel(writer, sheet_name=f"{sheet_prefix}_Signals")
    print(f"  Exported 4 sheets for {symbol} to {filename}")


def generate_signal_summary(df, symbol, accuracy):
    latest = df.iloc[-1]
    signal_map = {1: "BUY", -1: "SELL", 0: "HOLD"}
    latest_signal = signal_map.get(latest["Predicted_Signal"], "UNKNOWN")
    recent_buys = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == 1])
    recent_sells = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == -1])
    summary = (
        f"--- {symbol} Signal Report ---\n"
        f"Date: {latest.name.strftime('%Y-%m-%d')}\n"
        f"Close Price: {latest['Close']:.2f}\n"
        f"Latest Signal: {latest_signal}\n"
        f"RSI: {latest['RSI_14']:.1f}\n"
        f"MACD: {latest['MACD_12_26_9']:.4f}\n"
        f"ADX: {latest['ADX_14']:.1f}\n"
        f"Model Accuracy: {accuracy:.1%}\n"
        f"Last 5 days: {recent_buys} BUY, {recent_sells} SELL\n"
    )
    return summary, latest_signal


# ============================================================
# Email: AWS SES
# ============================================================
def send_email_alert(report, sender, recipient, region):
    """Send trading signal report via AWS SES."""
    print("Sending email alert via AWS SES...")
    ses_client = boto3.client("ses", region_name=region)
    try:
        response = ses_client.send_email(
            Source=sender,
            Destination={"ToAddresses": [recipient]},
            Message={
                "Subject": {
                    "Data": f"Trading Signals - {datetime.now().strftime('%Y-%m-%d')}",
                    "Charset": "UTF-8"
                },
                "Body": {"Text": {"Data": report, "Charset": "UTF-8"}}
            }
        )
        print(f"  Email sent! Message ID: {response['MessageId']}")
        return True
    except ClientError as e:
        print(f"  Email failed: {e.response['Error']['Message']}")
        return False


# ============================================================
# Main Execution (local run)
# ============================================================
stock_df = fetch_market_data(STOCK_SYMBOL, PERIOD, INTERVAL)
stock_df = add_technical_indicators(stock_df)

crypto_df = fetch_market_data(CRYPTO_SYMBOL, PERIOD, INTERVAL)
crypto_df = add_technical_indicators(crypto_df)

stock_df = create_labels(stock_df, horizon=10, threshold=0.02)
stock_df, stock_model, stock_accuracy = train_model(stock_df)

crypto_df = create_labels(crypto_df, horizon=10, threshold=0.03)
crypto_df, crypto_model, crypto_accuracy = train_model(crypto_df)

plot_signals(stock_df, STOCK_SYMBOL, stock_accuracy)
plot_signals(crypto_df, CRYPTO_SYMBOL, crypto_accuracy)

export_to_excel(stock_df, STOCK_SYMBOL, EXPORT_FILENAME)
export_to_excel(crypto_df, CRYPTO_SYMBOL, EXPORT_FILENAME)

stock_summary, stock_latest = generate_signal_summary(stock_df, STOCK_SYMBOL, stock_accuracy)
crypto_summary, crypto_latest = generate_signal_summary(crypto_df, CRYPTO_SYMBOL, crypto_accuracy)

full_report = f"AI Trading Signal Report - {datetime.now().strftime('%Y-%m-%d')}\n\n"
full_report += stock_summary + "\n" + crypto_summary
print(full_report)

email_sent = send_email_alert(full_report, EMAIL_SENDER, EMAIL_RECIPIENT, AWS_REGION)

Your signal generator can now email you the latest trading signals automatically. Next up, you will add WhatsApp notifications via Twilio so you get instant alerts on your phone too.

Send WhatsApp Alerts with Twilio

Your signal generator already sends email alerts through AWS SES. Email is reliable, but it is easy to miss in a crowded inbox.

In this step, you will add WhatsApp notifications using the Twilio sandbox. When a BUY or SELL signal fires, you will get an instant alert on your phone.

In this step, get ready to:
  • Set up the Twilio WhatsApp sandbox and link your phone number.
  • Write a WhatsApp notification function using the Twilio Python SDK.
  • Test the full notification pipeline with both email and WhatsApp alerts.
Set up the Twilio WhatsApp sandbox

Twilio provides a WhatsApp sandbox that lets you send messages from a shared number without any business verification or template approvals. Your personal WhatsApp number opts in by sending a "join" message to the sandbox.

  • Open your browser and go to the Twilio Console. Sign in with your free trial account.
  • In the left sidebar, click Messaging.
  • Click Try it out, then click Send a WhatsApp message.
  • Note the sandbox number and the join code displayed on the page (e.g., "join example-word").
  • On your personal phone, open WhatsApp and send the join message to +14155238886.
  • Wait for a confirmation reply from Twilio in WhatsApp.

Why the Twilio sandbox?

The Meta WhatsApp Cloud API requires a Meta Developer Account, business verification, and template approval that can take 24 to 48 hours. The Twilio sandbox gives you instant access with zero setup friction.

The tradeoff is that sandbox sessions expire every 3 days. You will need to re-send the join message to keep it active.

Now grab your Twilio credentials so your Python script can authenticate with the API.

Your script already has placeholder config variables for Twilio from earlier steps. Update them now with your real credentials.

  • In signal_generator.py, scroll to the Twilio config section and find these lines:
TWILIO_ACCOUNT_SID = "your-account-sid-here"
TWILIO_AUTH_TOKEN = "your-auth-token-here"
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = "whatsapp:+91XXXXXXXXXX"
  • Replace your-account-sid-here with the Account SID you copied from the Twilio dashboard.
  • Replace your-auth-token-here with your Auth Token.
  • Replace +91XXXXXXXXXX with your personal WhatsApp phone number (including country code).
  • Leave TWILIO_WHATSAPP_FROM as-is. That is the shared Twilio sandbox number.

Keep your credentials safe

Your Account SID and Auth Token are sensitive credentials. Do not share them publicly or commit them to a Git repository.

Didn't receive the WhatsApp confirmation?

Make sure you sent the exact join code shown on the Twilio sandbox page (e.g., "join example-word") to +14155238886. The number and the join phrase must match exactly.

If you still do not see a reply after a minute, try sending the join message again. Why isn't my Twilio WhatsApp sandbox responding to the join message?

Write the WhatsApp notification function

The Twilio Python SDK handles authentication and message delivery in a few lines. You will create a function that sends a condensed version of the signal report to WhatsApp.

  • In signal_generator.py, find the import lines at the top of the file. Add this import after the from botocore.exceptions import ClientError line:
from twilio.rest import Client as TwilioClient
  • Now scroll down to the send_email_alert function. Add the following function directly below it, before the Main Execution section:
def send_whatsapp_alert(report):
    """Send trading signal alert via Twilio WhatsApp sandbox."""
    print("Sending WhatsApp alert via Twilio...")
    try:
        # Create the Twilio client with your credentials
        client = TwilioClient(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
        # WhatsApp messages have a 1600 char limit, so truncate to 1500 for safety
        message_body = report[:1500] if len(report) > 1500 else report
        message = client.messages.create(
            from_=TWILIO_WHATSAPP_FROM,
            to=WHATSAPP_TO,
            body=message_body
        )
        print(f"  WhatsApp sent! SID: {message.sid}")
        return True
    except Exception as e:
        print(f"  WhatsApp failed: {e}")
        return False

What does this code do?

`TwilioClient(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)` authenticates your script with the Twilio API using the credentials you pasted into the config section.

`report[:1500]` truncates the message body to 1500 characters. WhatsApp messages have a 1600 character limit, so this leaves a small buffer. The `messages.create()` call sends the message from the sandbox number to your personal WhatsApp.

  • Save signal_generator.py.

Seeing an import error for twilio?

Make sure the import reads from twilio.rest import Client as TwilioClient and not from twilio import Client. The SDK lives inside the twilio.rest submodule. Help me fix my Twilio import error.

Test the WhatsApp notification locally

With the function in place, update the main execution section to call both notification channels and print a summary of what was sent.

  • In signal_generator.py, scroll to the bottom and find this line in the Main Execution section:
email_sent = send_email_alert(full_report, EMAIL_SENDER, EMAIL_RECIPIENT, AWS_REGION)
  • Replace that single line with the following block, which adds the WhatsApp call and a notification summary:
# Send email (choose mode)
if EMAIL_MODE == "aws":
    email_sent = send_email_alert(full_report, EMAIL_SENDER, EMAIL_RECIPIENT, AWS_REGION)
else:
    email_sent = send_email_local(full_report, GMAIL_ADDRESS, EMAIL_RECIPIENT)

# Send WhatsApp alert
whatsapp_sent = send_whatsapp_alert(full_report)

# Print delivery summary
print(f"\nNotification Summary:")
print(f"  Email: {'Sent' if email_sent else 'Failed'}")
print(f"  WhatsApp: {'Sent' if whatsapp_sent else 'Failed'}")
print(f"\nDone! All data exported to {EXPORT_FILENAME}")

What does this code do?

The EMAIL_MODE conditional lets you switch between AWS SES and a Gmail-based email function later (Step 8 covers the local alternative). For now, the default is "aws", so it uses the SES function you already built.

The notification summary at the bottom prints whether each channel succeeded or failed, giving you a single place to confirm delivery.

  • Save signal_generator.py.
  • Run the full script by running this command:
python signal_generator.py
  • Check your terminal output. You should see WhatsApp sent! SID: followed by a message ID.
  • Check your phone. You should see the trading signal report arrive as a WhatsApp message from the Twilio sandbox number.
  • At the very bottom of the terminal output, you should see:
Notification Summary:
  Email: Sent
  WhatsApp: Sent

Done! All data exported to trading_signals.xlsx

Sandbox session expiry

The Twilio sandbox session expires every 3 days. If your WhatsApp alerts stop arriving, re-send the join message from your phone to +14155238886 to reactivate the session.

WhatsApp message failed?

If you see WhatsApp failed: in your terminal, check the following:

  • Verify that TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN match what your Twilio Console dashboard shows. Even one extra space will cause authentication to fail.
  • Make sure WHATSAPP_TO starts with whatsapp: followed by your phone number with country code (e.g., whatsapp:+919876543210).
  • Confirm your sandbox session is still active. If it has been more than 3 days, re-send the join message from WhatsApp.

Help me debug my Twilio WhatsApp notification failure.

✔️ Awesome, I've got everything!

Great. Double check you have saved signal_generator.py.

ⓧ I'd like to double check the full code

Here is the full signal_generator.py file as it should look at the end of this step.

import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
import boto3
from botocore.exceptions import ClientError
from twilio.rest import Client as TwilioClient
from datetime import datetime

print("All libraries imported successfully!")
print(f"pandas-ta-classic version: {ta.version}")

# ============================================================
# Configuration
# ============================================================
STOCK_SYMBOL = "RELIANCE.NS"
CRYPTO_SYMBOL = "BTC-INR"
PERIOD = "2y"
INTERVAL = "1d"
EXPORT_FILENAME = "trading_signals.xlsx"

# AWS SES config
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
AWS_REGION = "ap-south-1"

# Gmail config (local alternative)
GMAIL_ADDRESS = "your-gmail@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"

# Twilio config
TWILIO_ACCOUNT_SID = "your-account-sid-here"
TWILIO_AUTH_TOKEN = "your-auth-token-here"
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = "whatsapp:+91XXXXXXXXXX"

# Set to "aws" to use SES, or "gmail" to use Gmail SMTP
EMAIL_MODE = "aws"


def fetch_market_data(symbol, period, interval):
    print(f"Fetching data for {symbol}...")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period, interval=interval)
    df = df[["Open", "High", "Low", "Close", "Volume"]]
    df.index.name = "Date"
    print(f"  Retrieved {len(df)} rows from {df.index[0].date()} to {df.index[-1].date()}")
    return df


def add_technical_indicators(df):
    print("Computing technical indicators...")
    df.ta.rsi(length=14, append=True)
    df.ta.macd(fast=12, slow=26, signal=9, append=True)
    df.ta.sma(length=20, append=True)
    df.ta.sma(length=50, append=True)
    df.ta.bbands(length=20, std=2, append=True)
    df.ta.adx(length=14, append=True)
    df.dropna(inplace=True)
    print(f"  Added indicators. {len(df)} rows after removing warm-up NaN values.")
    return df


def create_labels(df, horizon=10, threshold=0.02):
    print(f"Creating labels (horizon={horizon}, threshold={threshold*100}%)...")
    future_avg = df["Close"].shift(-horizon).rolling(window=horizon).mean().shift(-horizon + 1)
    future_returns = (future_avg - df["Close"]) / df["Close"]
    conditions = [future_returns > threshold, future_returns < -threshold]
    choices = [1, -1]
    df["Signal"] = np.select(conditions, choices, default=0)
    df.dropna(inplace=True)
    df = df[df.index < df.index[-horizon]]
    label_counts = df["Signal"].value_counts()
    print(f"  Labels - BUY: {label_counts.get(1, 0)}, SELL: {label_counts.get(-1, 0)}, HOLD: {label_counts.get(0, 0)}")
    return df


def train_model(df):
    print("Training Random Forest model...")
    feature_columns = [
        "RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
        "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
        "ADX_14", "DMP_14", "DMN_14"
    ]
    X = df[feature_columns]
    y = df["Signal"]
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False)
    model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1)
    model.fit(X_train, y_train)
    y_pred = model.predict(X_test)
    accuracy = accuracy_score(y_test, y_pred)
    print(f"\n  Model Accuracy: {accuracy:.2%}")
    print("\n  Classification Report:")
    print(classification_report(y_test, y_pred, target_names=["SELL", "HOLD", "BUY"]))
    df["Predicted_Signal"] = model.predict(X)
    importance = pd.Series(model.feature_importances_, index=feature_columns)
    importance = importance.sort_values(ascending=False)
    print("  Top Feature Importances:")
    for feat, imp in importance.head(5).items():
        print(f"    {feat}: {imp:.4f}")
    return df, model, accuracy


def plot_signals(df, symbol, accuracy):
    print(f"Generating signal chart for {symbol}...")
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), gridspec_kw={"height_ratios": [3, 1]})
    ax1.plot(df.index, df["Close"], label="Close Price", color="black", linewidth=1)
    ax1.plot(df.index, df["SMA_20"], label="SMA 20", color="blue", linewidth=0.8, alpha=0.7)
    ax1.plot(df.index, df["SMA_50"], label="SMA 50", color="orange", linewidth=0.8, alpha=0.7)
    buy_signals = df[df["Predicted_Signal"] == 1]
    sell_signals = df[df["Predicted_Signal"] == -1]
    ax1.scatter(buy_signals.index, buy_signals["Close"], marker="^", color="green",
                label=f"BUY ({len(buy_signals)})", s=50, zorder=5)
    ax1.scatter(sell_signals.index, sell_signals["Close"], marker="v", color="red",
                label=f"SELL ({len(sell_signals)})", s=50, zorder=5)
    ax1.set_title(f"{symbol} - AI Trading Signals (Accuracy: {accuracy:.1%})")
    ax1.set_ylabel("Price")
    ax1.legend(loc="upper left")
    ax1.grid(True, alpha=0.3)
    ax2.plot(df.index, df["RSI_14"], color="purple", linewidth=1)
    ax2.axhline(y=70, color="red", linestyle="--", alpha=0.5)
    ax2.axhline(y=30, color="green", linestyle="--", alpha=0.5)
    ax2.fill_between(df.index, 70, 100, alpha=0.1, color="red")
    ax2.fill_between(df.index, 0, 30, alpha=0.1, color="green")
    ax2.set_title("RSI (14)")
    ax2.set_ylabel("RSI")
    ax2.set_ylim(0, 100)
    ax2.grid(True, alpha=0.3)
    plt.tight_layout()
    chart_filename = f"{symbol.replace('.', '_').replace('-', '_')}_signals.png"
    plt.savefig(chart_filename, dpi=150, bbox_inches="tight")
    print(f"  Chart saved as {chart_filename}")


def export_to_excel(df, symbol, filename):
    print(f"Exporting {symbol} data to Excel...")
    raw_data = df[["Open", "High", "Low", "Close", "Volume"]].copy()
    indicator_cols = ["RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
                      "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
                      "ADX_14", "DMP_14", "DMN_14"]
    indicators = df[indicator_cols].copy()
    predictions = df[["Close", "Signal", "Predicted_Signal"]].copy()
    predictions["Signal_Label"] = predictions["Predicted_Signal"].map({1: "BUY", -1: "SELL", 0: "HOLD"})
    signal_summary = predictions[predictions["Predicted_Signal"] != 0].copy()
    signal_summary["Price_at_Signal"] = signal_summary["Close"]
    sheet_prefix = symbol.replace(".", "_").replace("-", "_")
    with pd.ExcelWriter(filename, engine="openpyxl",
                         mode="a" if pd.io.common.file_exists(filename) else "w") as writer:
        raw_data.to_excel(writer, sheet_name=f"{sheet_prefix}_OHLCV")
        indicators.to_excel(writer, sheet_name=f"{sheet_prefix}_Indicators")
        predictions.to_excel(writer, sheet_name=f"{sheet_prefix}_Predictions")
        signal_summary.to_excel(writer, sheet_name=f"{sheet_prefix}_Signals")
    print(f"  Exported 4 sheets for {symbol} to {filename}")


def generate_signal_summary(df, symbol, accuracy):
    latest = df.iloc[-1]
    signal_map = {1: "BUY", -1: "SELL", 0: "HOLD"}
    latest_signal = signal_map.get(latest["Predicted_Signal"], "UNKNOWN")
    recent_buys = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == 1])
    recent_sells = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == -1])
    summary = (
        f"--- {symbol} Signal Report ---\n"
        f"Date: {latest.name.strftime('%Y-%m-%d')}\n"
        f"Close Price: {latest['Close']:.2f}\n"
        f"Latest Signal: {latest_signal}\n"
        f"RSI: {latest['RSI_14']:.1f}\n"
        f"MACD: {latest['MACD_12_26_9']:.4f}\n"
        f"ADX: {latest['ADX_14']:.1f}\n"
        f"Model Accuracy: {accuracy:.1%}\n"
        f"Last 5 days: {recent_buys} BUY, {recent_sells} SELL\n"
    )
    return summary, latest_signal


def send_email_alert(report, sender, recipient, region):
    print("Sending email alert via AWS SES...")
    ses_client = boto3.client("ses", region_name=region)
    try:
        response = ses_client.send_email(
            Source=sender,
            Destination={"ToAddresses": [recipient]},
            Message={
                "Subject": {
                    "Data": f"Trading Signals - {datetime.now().strftime('%Y-%m-%d')}",
                    "Charset": "UTF-8"
                },
                "Body": {"Text": {"Data": report, "Charset": "UTF-8"}}
            }
        )
        print(f"  Email sent! Message ID: {response['MessageId']}")
        return True
    except ClientError as e:
        print(f"  Email failed: {e.response['Error']['Message']}")
        return False


def send_whatsapp_alert(report):
    """Send trading signal alert via Twilio WhatsApp sandbox."""
    print("Sending WhatsApp alert via Twilio...")
    try:
        client = TwilioClient(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
        message_body = report[:1500] if len(report) > 1500 else report
        message = client.messages.create(
            from_=TWILIO_WHATSAPP_FROM,
            to=WHATSAPP_TO,
            body=message_body
        )
        print(f"  WhatsApp sent! SID: {message.sid}")
        return True
    except Exception as e:
        print(f"  WhatsApp failed: {e}")
        return False


# ============================================================
# Main Execution (local run)
# ============================================================
stock_df = fetch_market_data(STOCK_SYMBOL, PERIOD, INTERVAL)
stock_df = add_technical_indicators(stock_df)

crypto_df = fetch_market_data(CRYPTO_SYMBOL, PERIOD, INTERVAL)
crypto_df = add_technical_indicators(crypto_df)

stock_df = create_labels(stock_df, horizon=10, threshold=0.02)
stock_df, stock_model, stock_accuracy = train_model(stock_df)

crypto_df = create_labels(crypto_df, horizon=10, threshold=0.03)
crypto_df, crypto_model, crypto_accuracy = train_model(crypto_df)

plot_signals(stock_df, STOCK_SYMBOL, stock_accuracy)
plot_signals(crypto_df, CRYPTO_SYMBOL, crypto_accuracy)

export_to_excel(stock_df, STOCK_SYMBOL, EXPORT_FILENAME)
export_to_excel(crypto_df, CRYPTO_SYMBOL, EXPORT_FILENAME)

stock_summary, stock_latest = generate_signal_summary(stock_df, STOCK_SYMBOL, stock_accuracy)
crypto_summary, crypto_latest = generate_signal_summary(crypto_df, CRYPTO_SYMBOL, crypto_accuracy)

full_report = f"AI Trading Signal Report - {datetime.now().strftime('%Y-%m-%d')}\n\n"
full_report += stock_summary + "\n" + crypto_summary
print(full_report)

if EMAIL_MODE == "aws":
    email_sent = send_email_alert(full_report, EMAIL_SENDER, EMAIL_RECIPIENT, AWS_REGION)
else:
    email_sent = send_email_local(full_report, GMAIL_ADDRESS, EMAIL_RECIPIENT)

whatsapp_sent = send_whatsapp_alert(full_report)

print(f"\nNotification Summary:")
print(f"  Email: {'Sent' if email_sent else 'Failed'}")
print(f"  WhatsApp: {'Sent' if whatsapp_sent else 'Failed'}")
print(f"\nDone! All data exported to {EXPORT_FILENAME}")

Your signal generator now delivers alerts through two channels at once. Next up, you will deploy the entire pipeline to AWS Lambda so it runs daily without any manual intervention.

Deploy to AWS Lambda with Scheduled Execution

Your signal generator works perfectly on your local machine. The problem is you have to remember to run it manually every single day.

In this step, you will deploy the signal generator to AWS Lambda so it runs automatically without you touching a thing. EventBridge Scheduler will trigger the function every weekday after the Indian market closes.

In this step, get ready to:
  • Create a Lambda-compatible version of the signal generator script.
  • Package Python dependencies as a Lambda layer.
  • Deploy the function and schedule it to run daily with EventBridge Scheduler.
Refactor the script for Lambda

The Lambda environment differs from your local machine in a few important ways. There is no display, so matplotlib chart generation will not work. Libraries like yfinance and scikit-learn are not pre-installed. Configuration must come from environment variables instead of hardcoded values.

You will create a new file called lambda_function.py that contains a streamlined version of your signal generator with a lambda_handler entry point.

  • Create a new file called lambda_function.py inside your trading-signal-generator folder.
  • Add the imports and environment variable configuration by copying the following code into it:
import json
import os
import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
import boto3
from botocore.exceptions import ClientError
from twilio.rest import Client as TwilioClient
from datetime import datetime

# Read all configuration from Lambda environment variables
STOCK_SYMBOL = os.environ.get("STOCK_SYMBOL", "RELIANCE.NS")
CRYPTO_SYMBOL = os.environ.get("CRYPTO_SYMBOL", "BTC-INR")
EMAIL_SENDER = os.environ.get("EMAIL_SENDER", "")
EMAIL_RECIPIENT = os.environ.get("EMAIL_RECIPIENT", "")
AWS_REGION = os.environ.get("AWS_REGION", "ap-south-1")
TWILIO_SID = os.environ.get("TWILIO_ACCOUNT_SID", "")
TWILIO_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN", "")
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = os.environ.get("WHATSAPP_TO", "")

What changed from the local version?

There is no import matplotlib because Lambda has no graphical display. The json and os modules are new additions for the Lambda response format and environment variables.

Every configuration value now comes from os.environ.get() instead of being hardcoded. This means you can change symbols, email addresses, or credentials in the Lambda console without redeploying your code.

  • Below the configuration block, add the data pipeline helper functions:
def fetch_market_data(symbol, period, interval):
    # Download OHLCV data from Yahoo Finance
    print(f"Fetching data for {symbol}...")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period, interval=interval)
    df = df[["Open", "High", "Low", "Close", "Volume"]]
    df.index.name = "Date"
    print(f"  Retrieved {len(df)} rows")
    return df


def add_technical_indicators(df):
    # Compute RSI, MACD, SMA, Bollinger Bands, and ADX
    df.ta.rsi(length=14, append=True)
    df.ta.macd(fast=12, slow=26, signal=9, append=True)
    df.ta.sma(length=20, append=True)
    df.ta.sma(length=50, append=True)
    df.ta.bbands(length=20, std=2, append=True)
    df.ta.adx(length=14, append=True)
    df.dropna(inplace=True)
    return df


def create_labels(df, horizon=10, threshold=0.02):
    # Label rows as BUY (1), SELL (-1), or HOLD (0) based on future price movement
    future_avg = df["Close"].shift(-horizon).rolling(window=horizon).mean().shift(-horizon + 1)
    future_returns = (future_avg - df["Close"]) / df["Close"]
    conditions = [future_returns > threshold, future_returns < -threshold]
    df["Signal"] = np.select(conditions, [1, -1], default=0)
    df.dropna(inplace=True)
    df = df[df.index < df.index[-horizon]]
    return df


def train_model(df):
    # Train a Random Forest classifier on the indicator features
    feature_columns = [
        "RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
        "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
        "ADX_14", "DMP_14", "DMN_14"
    ]
    X = df[feature_columns]
    y = df["Signal"]
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False)
    model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42)
    model.fit(X_train, y_train)
    accuracy = accuracy_score(y_test, model.predict(X_test))
    df["Predicted_Signal"] = model.predict(X)
    return df, model, accuracy


def generate_signal_summary(df, symbol, accuracy):
    # Build a text summary of the latest signal for notifications
    latest = df.iloc[-1]
    signal_map = {1: "BUY", -1: "SELL", 0: "HOLD"}
    latest_signal = signal_map.get(latest["Predicted_Signal"], "UNKNOWN")
    recent_buys = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == 1])
    recent_sells = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == -1])
    summary = (
        f"--- {symbol} ---\n"
        f"Date: {latest.name.strftime('%Y-%m-%d')}\n"
        f"Close: {latest['Close']:.2f}\n"
        f"Signal: {latest_signal}\n"
        f"RSI: {latest['RSI_14']:.1f} | ADX: {latest['ADX_14']:.1f}\n"
        f"Accuracy: {accuracy:.1%}\n"
        f"Last 5d: {recent_buys} BUY, {recent_sells} SELL\n"
    )
    return summary, latest_signal

What changed in the helper functions?

These are streamlined versions of the functions you already wrote in signal_generator.py. Verbose print statements and the classification report are removed to keep Lambda logs clean.

The RandomForestClassifier no longer uses n_jobs=-1 because Lambda has limited CPU cores. The default single-core mode is more reliable in that environment.

  • Below the helper functions, add the notification functions:
def send_email_alert(report, sender, recipient, region):
    # Send the signal report via AWS SES
    ses = boto3.client("ses", region_name=region)
    try:
        ses.send_email(
            Source=sender,
            Destination={"ToAddresses": [recipient]},
            Message={
                "Subject": {"Data": f"Trading Signals - {datetime.now().strftime('%Y-%m-%d')}"},
                "Body": {"Text": {"Data": report}}
            }
        )
        return True
    except ClientError as e:
        print(f"Email error: {e.response['Error']['Message']}")
        return False


def send_whatsapp_alert(report):
    # Send signal alert via Twilio WhatsApp sandbox
    try:
        client = TwilioClient(TWILIO_SID, TWILIO_TOKEN)
        client.messages.create(
            from_=TWILIO_WHATSAPP_FROM,
            to=WHATSAPP_TO,
            body=report[:1500]
        )
        return True
    except Exception as e:
        print(f"WhatsApp error: {e}")
        return False

What does this code do?

These are the same email and WhatsApp functions from your local script. The only difference is that send_whatsapp_alert now reads TWILIO_SID and TWILIO_TOKEN from the environment variable config at the top of the file instead of hardcoded values.

  • Finally, add the Lambda handler function at the bottom of the file:
def lambda_handler(event, context):
    """AWS Lambda entry point."""
    print("Starting trading signal generation...")
    results = {}
    report = f"AI Trading Signals - {datetime.now().strftime('%Y-%m-%d')}\n\n"

    # Process each symbol with its own volatility threshold
    for symbol, threshold in [(STOCK_SYMBOL, 0.02), (CRYPTO_SYMBOL, 0.03)]:
        try:
            df = fetch_market_data(symbol, "2y", "1d")
            df = add_technical_indicators(df)
            df = create_labels(df, horizon=10, threshold=threshold)
            df, model, accuracy = train_model(df)
            summary, signal = generate_signal_summary(df, symbol, accuracy)
            report += summary + "\n"
            results[symbol] = {"signal": signal, "accuracy": f"{accuracy:.1%}"}
        except Exception as e:
            print(f"Error processing {symbol}: {e}")
            results[symbol] = {"error": str(e)}

    # Send notifications
    email_ok = send_email_alert(report, EMAIL_SENDER, EMAIL_RECIPIENT, AWS_REGION)
    whatsapp_ok = send_whatsapp_alert(report)

    # Return results as a JSON response
    return {
        "statusCode": 200,
        "body": json.dumps({
            "results": results,
            "notifications": {"email": email_ok, "whatsapp": whatsapp_ok}
        })
    }

What does the handler do?

The lambda_handler function is the entry point that AWS Lambda calls. It receives an event (the trigger data) and a context object (runtime info from Lambda).

The function loops through both symbols, runs the full pipeline for each, sends notifications, and returns a JSON response with the results. Each symbol is wrapped in a try/except so that a failure on one symbol does not block the other.

  • Save lambda_function.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete lambda_function.py for comparison.

import json
import os
import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
import boto3
from botocore.exceptions import ClientError
from twilio.rest import Client as TwilioClient
from datetime import datetime

STOCK_SYMBOL = os.environ.get("STOCK_SYMBOL", "RELIANCE.NS")
CRYPTO_SYMBOL = os.environ.get("CRYPTO_SYMBOL", "BTC-INR")
EMAIL_SENDER = os.environ.get("EMAIL_SENDER", "")
EMAIL_RECIPIENT = os.environ.get("EMAIL_RECIPIENT", "")
AWS_REGION = os.environ.get("AWS_REGION", "ap-south-1")
TWILIO_SID = os.environ.get("TWILIO_ACCOUNT_SID", "")
TWILIO_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN", "")
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = os.environ.get("WHATSAPP_TO", "")


def fetch_market_data(symbol, period, interval):
    print(f"Fetching data for {symbol}...")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period, interval=interval)
    df = df[["Open", "High", "Low", "Close", "Volume"]]
    df.index.name = "Date"
    print(f"  Retrieved {len(df)} rows")
    return df


def add_technical_indicators(df):
    df.ta.rsi(length=14, append=True)
    df.ta.macd(fast=12, slow=26, signal=9, append=True)
    df.ta.sma(length=20, append=True)
    df.ta.sma(length=50, append=True)
    df.ta.bbands(length=20, std=2, append=True)
    df.ta.adx(length=14, append=True)
    df.dropna(inplace=True)
    return df


def create_labels(df, horizon=10, threshold=0.02):
    future_avg = df["Close"].shift(-horizon).rolling(window=horizon).mean().shift(-horizon + 1)
    future_returns = (future_avg - df["Close"]) / df["Close"]
    conditions = [future_returns > threshold, future_returns < -threshold]
    df["Signal"] = np.select(conditions, [1, -1], default=0)
    df.dropna(inplace=True)
    df = df[df.index < df.index[-horizon]]
    return df


def train_model(df):
    feature_columns = [
        "RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
        "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
        "ADX_14", "DMP_14", "DMN_14"
    ]
    X = df[feature_columns]
    y = df["Signal"]
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False)
    model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42)
    model.fit(X_train, y_train)
    accuracy = accuracy_score(y_test, model.predict(X_test))
    df["Predicted_Signal"] = model.predict(X)
    return df, model, accuracy


def generate_signal_summary(df, symbol, accuracy):
    latest = df.iloc[-1]
    signal_map = {1: "BUY", -1: "SELL", 0: "HOLD"}
    latest_signal = signal_map.get(latest["Predicted_Signal"], "UNKNOWN")
    recent_buys = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == 1])
    recent_sells = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == -1])
    summary = (
        f"--- {symbol} ---\n"
        f"Date: {latest.name.strftime('%Y-%m-%d')}\n"
        f"Close: {latest['Close']:.2f}\n"
        f"Signal: {latest_signal}\n"
        f"RSI: {latest['RSI_14']:.1f} | ADX: {latest['ADX_14']:.1f}\n"
        f"Accuracy: {accuracy:.1%}\n"
        f"Last 5d: {recent_buys} BUY, {recent_sells} SELL\n"
    )
    return summary, latest_signal


def send_email_alert(report, sender, recipient, region):
    ses = boto3.client("ses", region_name=region)
    try:
        ses.send_email(
            Source=sender,
            Destination={"ToAddresses": [recipient]},
            Message={
                "Subject": {"Data": f"Trading Signals - {datetime.now().strftime('%Y-%m-%d')}"},
                "Body": {"Text": {"Data": report}}
            }
        )
        return True
    except ClientError as e:
        print(f"Email error: {e.response['Error']['Message']}")
        return False


def send_whatsapp_alert(report):
    try:
        client = TwilioClient(TWILIO_SID, TWILIO_TOKEN)
        client.messages.create(
            from_=TWILIO_WHATSAPP_FROM,
            to=WHATSAPP_TO,
            body=report[:1500]
        )
        return True
    except Exception as e:
        print(f"WhatsApp error: {e}")
        return False


def lambda_handler(event, context):
    print("Starting trading signal generation...")
    results = {}
    report = f"AI Trading Signals - {datetime.now().strftime('%Y-%m-%d')}\n\n"

    for symbol, threshold in [(STOCK_SYMBOL, 0.02), (CRYPTO_SYMBOL, 0.03)]:
        try:
            df = fetch_market_data(symbol, "2y", "1d")
            df = add_technical_indicators(df)
            df = create_labels(df, horizon=10, threshold=threshold)
            df, model, accuracy = train_model(df)
            summary, signal = generate_signal_summary(df, symbol, accuracy)
            report += summary + "\n"
            results[symbol] = {"signal": signal, "accuracy": f"{accuracy:.1%}"}
        except Exception as e:
            print(f"Error processing {symbol}: {e}")
            results[symbol] = {"error": str(e)}

    email_ok = send_email_alert(report, EMAIL_SENDER, EMAIL_RECIPIENT, AWS_REGION)
    whatsapp_ok = send_whatsapp_alert(report)

    return {
        "statusCode": 200,
        "body": json.dumps({
            "results": results,
            "notifications": {"email": email_ok, "whatsapp": whatsapp_ok}
        })
    }

Not sure if the file is correct?

Make sure lambda_function.py is a separate file from signal_generator.py. Both files should exist in your trading-signal-generator folder.

Check that there is no import matplotlib line anywhere in the Lambda file. Lambda cannot render charts.

Help me check my lambda_function.py against the expected code.

Package dependencies as a Lambda layer

Lambda does not include libraries like yfinance, scikit-learn, or pandas-ta-classic by default. You need to bundle these as a Lambda layer, which is a zip archive of dependencies that Lambda attaches to your function at runtime.

Why a Lambda layer?

Lambda layers let you keep your function code small and reusable. The dependencies are uploaded once and can be shared across multiple functions. The layer must contain Linux-compatible packages even if you are building on Windows.

  • Create a python folder and install the dependencies into it by running these commands:
mkdir python
pip install yfinance==1.3.0 pandas-ta-classic==0.4.47 scikit-learn==1.8.0 openpyxl==3.1.5 twilio --platform manylinux2014_x86_64 --only-binary=:all: -t python/

What do these flags do?

  • --platform manylinux2014_x86_64 tells pip to download Linux-compatible packages. Lambda runs on Amazon Linux, so Windows packages will not work.
  • --only-binary=:all: forces pre-built wheels only. This avoids trying to compile C extensions on your machine for a different platform.
  • -t python/ installs into the python/ directory. Lambda expects layer packages inside a top-level python folder.
  • Compress the python folder into a zip file by running:
Compress-Archive -Path .\python -DestinationPath .\layer.zip
  • Confirm layer.zip was created by running:
ls

You should see layer.zip listed alongside your other project files.

  • Upload the layer to AWS Lambda by running:
aws lambda publish-layer-version --layer-name trading-signal-deps --zip-file fileb://layer.zip --compatible-runtimes python3.12 --region ap-south-1
  • You should see a JSON response that includes a LayerVersionArn field.
  • Copy the full LayerVersionArn value and save it somewhere. You will need it in the next substep when creating the Lambda function.

Seeing a zip or upload error?

If Compress-Archive fails, make sure you are running the commands in PowerShell, not Command Prompt. PowerShell is required for this command.

If the layer upload fails with a size error, the unzipped layer must stay under 250 MB. Check that only the required packages are in the python/ folder.

Help me debug my Lambda layer upload error.

Create the Lambda function and schedule it

With the layer uploaded, you can now create the Lambda function, attach the layer, configure environment variables, and set up a daily schedule.

What is an IAM role?

Before creating the Lambda function, you need an IAM execution role. This role gives Lambda permission to run your code, write logs, and send emails via SES. You also need a separate role for EventBridge Scheduler to invoke your Lambda function.

If you do not already have these roles, create them in the IAM console. The Lambda role needs the AWSLambdaBasicExecutionRole and AmazonSESFullAccess policies. The Scheduler role needs the AWSLambdaRole policy.

  • Zip your Lambda function code by running:
Compress-Archive -Path .\lambda_function.py -DestinationPath .\function.zip
  • Create the Lambda function by running this command. Replace the placeholder values with your own AWS account ID, Lambda role name, and the layer ARN you copied earlier:
aws lambda create-function --function-name TradingSignalGenerator --runtime python3.12 --role arn:aws:iam::[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]]:role/[[LAMBDA_ROLE_NAME="your Lambda execution role name"]] --handler lambda_function.lambda_handler --zip-file fileb://function.zip --timeout 300 --memory-size 1024 --layers [[LAYER_VERSION_ARN="your LayerVersionArn from the previous step"]] --region ap-south-1

What do these settings mean?

  • --runtime python3.12 tells Lambda which Python version to use.
  • --timeout 300 gives the function 5 minutes to finish. Fetching data and training the model takes time.
  • --memory-size 1024 allocates 1 GB of RAM. scikit-learn needs enough memory for model training.
  • --handler lambda_function.lambda_handler points Lambda to the lambda_handler function inside lambda_function.py.
  • Set the environment variables for your Lambda function by running this command. Replace the email, Twilio, and WhatsApp placeholder values with your own:
aws lambda update-function-configuration --function-name TradingSignalGenerator --environment "Variables={STOCK_SYMBOL=RELIANCE.NS,CRYPTO_SYMBOL=BTC-INR,EMAIL_SENDER=your-verified-email@example.com,EMAIL_RECIPIENT=your-email@example.com,AWS_REGION=ap-south-1,TWILIO_ACCOUNT_SID=your-sid,TWILIO_AUTH_TOKEN=your-token,WHATSAPP_TO=whatsapp:+91XXXXXXXXXX}" --region ap-south-1

What does this code do?

This sets all the configuration values your Lambda code reads via os.environ.get(). Replace the email addresses with the SES-verified email you set up earlier. Replace the Twilio values with the Account SID, Auth Token, and your WhatsApp number from the Twilio Console.

Function creation failed?

If you see an error about the role not existing, double check the IAM role ARN format. It should look like arn:aws:iam::123456789012:role/YourRoleName with no spaces.

If you see a "Function already exists" error, either delete it first with aws lambda delete-function --function-name TradingSignalGenerator or use aws lambda update-function-code instead.

Help me debug my Lambda function creation error.

Now you need to schedule the function to run automatically every weekday. The Indian stock market closes at 3:30 PM IST. Running at 6:00 PM IST (12:30 UTC) gives enough buffer for the closing data to be available.

  • Create the EventBridge Scheduler schedule by running this command. Replace the account ID and scheduler role name with your own:
aws scheduler create-schedule --name DailyTradingSignals --schedule-expression "cron(30 12 ? * MON-FRI *)" --target "{\"Arn\":\"arn:aws:lambda:ap-south-1:[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]:function:TradingSignalGenerator\",\"RoleArn\":\"arn:aws:iam::[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]:role/[[SCHEDULER_ROLE_NAME="your EventBridge Scheduler role name"]]\"}" --flexible-time-window "{\"Mode\":\"OFF\"}" --region ap-south-1

What does the cron expression mean?

The expression cron(30 12 ? * MON-FRI *) means "at 12:30 UTC, every Monday through Friday." 12:30 UTC is 6:00 PM IST. This runs after the Indian market closes but early enough for you to review the signals the same evening.

The flexible-time-window OFF setting ensures the function runs at exactly the scheduled time with no random delay.

  • Test the function manually by invoking it from the CLI:
aws lambda invoke --function-name TradingSignalGenerator --region ap-south-1 response.json
  • View the response by running:
type response.json

You should see a JSON response with "statusCode": 200 and results for both symbols. Check your email and WhatsApp for the signal alerts.

Invocation failed or returned errors?

If you see a timeout error, the function may need more than 300 seconds. You can increase the timeout with aws lambda update-function-configuration --function-name TradingSignalGenerator --timeout 600.

If the response shows import errors for yfinance or sklearn, the layer may not be attached correctly. Run aws lambda get-function-configuration --function-name TradingSignalGenerator and check that the Layers field contains your layer ARN.

If notification fields show false, verify the environment variables are set correctly with aws lambda get-function-configuration --function-name TradingSignalGenerator.

Help me debug my Lambda invocation error.

Your signal generator is now fully automated in the cloud. Every weekday at 6:00 PM IST, Lambda will pull fresh market data, retrain the model, and send you the latest BUY/SELL signals via email and WhatsApp. Next up, you will set up a local alternative using Windows Task Scheduler for a zero-cloud option.

Set Up Local Automation with Windows Task Scheduler

In the previous step, you deployed your signal generator to AWS Lambda with automated daily execution via EventBridge Scheduler. That cloud approach is powerful, but it does require an active AWS account and verified SES email addresses.

If you prefer a zero-cost, zero-cloud alternative, Windows Task Scheduler can run the same script on your own machine every day. In this step, you will swap AWS SES for Gmail SMTP and schedule the script locally using a batch file.

In this step, get ready to:
  • Add a Gmail SMTP email function as a local alternative to AWS SES.
  • Create a batch file that runs the signal generator script.
  • Schedule daily execution with Windows Task Scheduler.
Generate a Gmail App Password

Python's built-in smtplib module can send emails through Gmail without any AWS dependency. Gmail requires an App Password for third-party scripts instead of your regular account password.

  • Open your browser and go to myaccount.google.com.
  • Click Security in the left sidebar.
  • Under How you sign in to Google, click 2-Step Verification. If it is not enabled, enable it first.
  • Scroll to the bottom and click App passwords.
  • Enter Trading Signals as the app name.
  • Click Create.
  • Copy the 16-character password that appears. You will paste this into your script in a moment.

Why an App Password instead of your regular password?

Gmail blocks sign-in attempts from scripts that use your normal password. An App Password is a one-time generated credential that grants limited access to your Gmail account for a specific app.

Add Gmail imports and configuration

Your script currently sends emails through AWS SES only. You will add a second email path that uses Gmail SMTP, plus a toggle variable that lets you switch between the two.

  • In signal_generator.py, find the existing imports at the top of the file. Add these two new imports after the from datetime import datetime line:
import smtplib
from email.mime.text import MIMEText
  • Scroll down to the Configuration section. Below the WHATSAPP_TO line, add the Gmail configuration and email mode toggle:
# Gmail config (local alternative)
GMAIL_ADDRESS = "your-gmail@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"

# Set to "aws" to use SES, or "gmail" to use Gmail SMTP
EMAIL_MODE = "aws"

What do these variables do?

  • GMAIL_ADDRESS is the Gmail account that sends the signal report.
  • GMAIL_APP_PASSWORD is the 16-character password you generated in the previous substep.
  • EMAIL_MODE lets you switch between AWS SES and Gmail without changing any function calls. Set it to "gmail" to use the local path.
  • Replace your-gmail@gmail.com with your actual Gmail address.
  • Replace your-app-password-here with the 16-character App Password you copied from Google.
Write the Gmail email function
  • In signal_generator.py, scroll down to the send_email_alert function. Add the following new function directly below it:
def send_email_local(report, sender, recipient):
    """Send email via Gmail SMTP (no AWS needed)."""
    print("Sending email via Gmail SMTP...")
    # Build the email message with subject, sender, and recipient
    msg = MIMEText(report)
    msg["Subject"] = f"Trading Signals - {datetime.now().strftime('%Y-%m-%d')}"
    msg["From"] = sender
    msg["To"] = recipient

    try:
        # Connect to Gmail over SSL on port 465
        with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
            server.login(sender, GMAIL_APP_PASSWORD)
            server.sendmail(sender, recipient, msg.as_string())
        print("  Email sent via Gmail!")
        return True
    except Exception as e:
        print(f"  Email failed: {e}")
        return False

What does this function do?

  • MIMEText(report) wraps the plain-text report in a properly formatted email message object.
  • smtplib.SMTP_SSL("smtp.gmail.com", 465) opens a secure SSL connection to Gmail's SMTP server on port 465.
  • server.login() authenticates with your Gmail address and App Password.
  • The function returns True or False so the main execution block can report on success or failure.

Now update the main execution block at the bottom of the file so it uses EMAIL_MODE to decide which email path to take.

  • Scroll to the bottom of signal_generator.py and find this line:
email_sent = send_email_alert(full_report, EMAIL_SENDER, EMAIL_RECIPIENT, AWS_REGION)
  • Replace that single line with the following block that checks EMAIL_MODE:
# Send email (choose mode)
if EMAIL_MODE == "aws":
    email_sent = send_email_alert(full_report, EMAIL_SENDER, EMAIL_RECIPIENT, AWS_REGION)
else:
    email_sent = send_email_local(full_report, GMAIL_ADDRESS, EMAIL_RECIPIENT)
  • At the very end of the file, after the WhatsApp notification summary print statements, add this final line:
print(f"\nDone! All data exported to {EXPORT_FILENAME}")
  • To test the Gmail path, change EMAIL_MODE from "aws" to "gmail" in the configuration section.
  • Save signal_generator.py.
  • Run the script to test by running:
python signal_generator.py
  • Check your Gmail inbox for the trading signal report email. You should see a message with the subject Trading Signals - followed by today's date.

Email failed or authentication error?

  • Make sure 2-Step Verification is turned on for your Google account. App Passwords only work when 2-Step Verification is enabled.
  • Double check that you pasted the full 16-character App Password with no extra spaces.
  • Confirm that EMAIL_MODE is set to "gmail" (not "aws") in the configuration section.

Why is my Gmail SMTP email failing to send?

✔️ Awesome, I've got everything!

Great. Double check you have saved signal_generator.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete signal_generator.py file. Compare yours against it to find any differences.

import yfinance as yf
import pandas as pd
import pandas_ta_classic as ta
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report, accuracy_score
import matplotlib.pyplot as plt
import boto3
from botocore.exceptions import ClientError
from twilio.rest import Client as TwilioClient
from datetime import datetime
import smtplib
from email.mime.text import MIMEText

print("All libraries imported successfully!")
print(f"pandas-ta-classic version: {ta.version}")

# ============================================================
# Configuration
# ============================================================
STOCK_SYMBOL = "RELIANCE.NS"
CRYPTO_SYMBOL = "BTC-INR"
PERIOD = "2y"
INTERVAL = "1d"
EXPORT_FILENAME = "trading_signals.xlsx"

# AWS SES config
EMAIL_SENDER = "your-verified-email@example.com"
EMAIL_RECIPIENT = "your-email@example.com"
AWS_REGION = "ap-south-1"

# Gmail config (local alternative)
GMAIL_ADDRESS = "your-gmail@gmail.com"
GMAIL_APP_PASSWORD = "your-app-password-here"

# Twilio config
TWILIO_ACCOUNT_SID = "your-account-sid-here"
TWILIO_AUTH_TOKEN = "your-auth-token-here"
TWILIO_WHATSAPP_FROM = "whatsapp:+14155238886"
WHATSAPP_TO = "whatsapp:+91XXXXXXXXXX"

# Set to "aws" to use SES, or "gmail" to use Gmail SMTP
EMAIL_MODE = "aws"


def fetch_market_data(symbol, period, interval):
    print(f"Fetching data for {symbol}...")
    ticker = yf.Ticker(symbol)
    df = ticker.history(period=period, interval=interval)
    df = df[["Open", "High", "Low", "Close", "Volume"]]
    df.index.name = "Date"
    print(f"  Retrieved {len(df)} rows from {df.index[0].date()} to {df.index[-1].date()}")
    return df


def add_technical_indicators(df):
    print("Computing technical indicators...")
    df.ta.rsi(length=14, append=True)
    df.ta.macd(fast=12, slow=26, signal=9, append=True)
    df.ta.sma(length=20, append=True)
    df.ta.sma(length=50, append=True)
    df.ta.bbands(length=20, std=2, append=True)
    df.ta.adx(length=14, append=True)
    df.dropna(inplace=True)
    print(f"  Added indicators. {len(df)} rows after removing warm-up NaN values.")
    return df


def create_labels(df, horizon=10, threshold=0.02):
    print(f"Creating labels (horizon={horizon}, threshold={threshold*100}%)...")
    future_avg = df["Close"].shift(-horizon).rolling(window=horizon).mean().shift(-horizon + 1)
    future_returns = (future_avg - df["Close"]) / df["Close"]
    conditions = [future_returns > threshold, future_returns < -threshold]
    choices = [1, -1]
    df["Signal"] = np.select(conditions, choices, default=0)
    df.dropna(inplace=True)
    df = df[df.index < df.index[-horizon]]
    label_counts = df["Signal"].value_counts()
    print(f"  Labels - BUY: {label_counts.get(1, 0)}, SELL: {label_counts.get(-1, 0)}, HOLD: {label_counts.get(0, 0)}")
    return df


def train_model(df):
    print("Training Random Forest model...")
    feature_columns = [
        "RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
        "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
        "ADX_14", "DMP_14", "DMN_14"
    ]
    X = df[feature_columns]
    y = df["Signal"]
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False)
    model = RandomForestClassifier(n_estimators=100, max_depth=10, random_state=42, n_jobs=-1)
    model.fit(X_train, y_train)
    y_pred = model.predict(X_test)
    accuracy = accuracy_score(y_test, y_pred)
    print(f"\n  Model Accuracy: {accuracy:.2%}")
    print("\n  Classification Report:")
    print(classification_report(y_test, y_pred, target_names=["SELL", "HOLD", "BUY"]))
    df["Predicted_Signal"] = model.predict(X)
    importance = pd.Series(model.feature_importances_, index=feature_columns)
    importance = importance.sort_values(ascending=False)
    print("  Top Feature Importances:")
    for feat, imp in importance.head(5).items():
        print(f"    {feat}: {imp:.4f}")
    return df, model, accuracy


def plot_signals(df, symbol, accuracy):
    print(f"Generating signal chart for {symbol}...")
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), gridspec_kw={"height_ratios": [3, 1]})
    ax1.plot(df.index, df["Close"], label="Close Price", color="black", linewidth=1)
    ax1.plot(df.index, df["SMA_20"], label="SMA 20", color="blue", linewidth=0.8, alpha=0.7)
    ax1.plot(df.index, df["SMA_50"], label="SMA 50", color="orange", linewidth=0.8, alpha=0.7)
    buy_signals = df[df["Predicted_Signal"] == 1]
    sell_signals = df[df["Predicted_Signal"] == -1]
    ax1.scatter(buy_signals.index, buy_signals["Close"], marker="^", color="green",
                label=f"BUY ({len(buy_signals)})", s=50, zorder=5)
    ax1.scatter(sell_signals.index, sell_signals["Close"], marker="v", color="red",
                label=f"SELL ({len(sell_signals)})", s=50, zorder=5)
    ax1.set_title(f"{symbol} - AI Trading Signals (Accuracy: {accuracy:.1%})")
    ax1.set_ylabel("Price")
    ax1.legend(loc="upper left")
    ax1.grid(True, alpha=0.3)
    ax2.plot(df.index, df["RSI_14"], color="purple", linewidth=1)
    ax2.axhline(y=70, color="red", linestyle="--", alpha=0.5)
    ax2.axhline(y=30, color="green", linestyle="--", alpha=0.5)
    ax2.fill_between(df.index, 70, 100, alpha=0.1, color="red")
    ax2.fill_between(df.index, 0, 30, alpha=0.1, color="green")
    ax2.set_title("RSI (14)")
    ax2.set_ylabel("RSI")
    ax2.set_ylim(0, 100)
    ax2.grid(True, alpha=0.3)
    plt.tight_layout()
    chart_filename = f"{symbol.replace('.', '_').replace('-', '_')}_signals.png"
    plt.savefig(chart_filename, dpi=150, bbox_inches="tight")
    print(f"  Chart saved as {chart_filename}")


def export_to_excel(df, symbol, filename):
    print(f"Exporting {symbol} data to Excel...")
    raw_data = df[["Open", "High", "Low", "Close", "Volume"]].copy()
    indicator_cols = ["RSI_14", "MACD_12_26_9", "MACDh_12_26_9", "MACDs_12_26_9",
                      "SMA_20", "SMA_50", "BBL_20_2.0", "BBM_20_2.0", "BBU_20_2.0",
                      "ADX_14", "DMP_14", "DMN_14"]
    indicators = df[indicator_cols].copy()
    predictions = df[["Close", "Signal", "Predicted_Signal"]].copy()
    predictions["Signal_Label"] = predictions["Predicted_Signal"].map({1: "BUY", -1: "SELL", 0: "HOLD"})
    signal_summary = predictions[predictions["Predicted_Signal"] != 0].copy()
    signal_summary["Price_at_Signal"] = signal_summary["Close"]
    sheet_prefix = symbol.replace(".", "_").replace("-", "_")
    with pd.ExcelWriter(filename, engine="openpyxl",
                         mode="a" if pd.io.common.file_exists(filename) else "w") as writer:
        raw_data.to_excel(writer, sheet_name=f"{sheet_prefix}_OHLCV")
        indicators.to_excel(writer, sheet_name=f"{sheet_prefix}_Indicators")
        predictions.to_excel(writer, sheet_name=f"{sheet_prefix}_Predictions")
        signal_summary.to_excel(writer, sheet_name=f"{sheet_prefix}_Signals")
    print(f"  Exported 4 sheets for {symbol} to {filename}")


def generate_signal_summary(df, symbol, accuracy):
    latest = df.iloc[-1]
    signal_map = {1: "BUY", -1: "SELL", 0: "HOLD"}
    latest_signal = signal_map.get(latest["Predicted_Signal"], "UNKNOWN")
    recent_buys = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == 1])
    recent_sells = len(df.tail(5)[df.tail(5)["Predicted_Signal"] == -1])
    summary = (
        f"--- {symbol} Signal Report ---\n"
        f"Date: {latest.name.strftime('%Y-%m-%d')}\n"
        f"Close Price: {latest['Close']:.2f}\n"
        f"Latest Signal: {latest_signal}\n"
        f"RSI: {latest['RSI_14']:.1f}\n"
        f"MACD: {latest['MACD_12_26_9']:.4f}\n"
        f"ADX: {latest['ADX_14']:.1f}\n"
        f"Model Accuracy: {accuracy:.1%}\n"
        f"Last 5 days: {recent_buys} BUY, {recent_sells} SELL\n"
    )
    return summary, latest_signal


def send_email_alert(report, sender, recipient, region):
    print("Sending email alert via AWS SES...")
    ses_client = boto3.client("ses", region_name=region)
    try:
        response = ses_client.send_email(
            Source=sender,
            Destination={"ToAddresses": [recipient]},
            Message={
                "Subject": {
                    "Data": f"Trading Signals - {datetime.now().strftime('%Y-%m-%d')}",
                    "Charset": "UTF-8"
                },
                "Body": {"Text": {"Data": report, "Charset": "UTF-8"}}
            }
        )
        print(f"  Email sent! Message ID: {response['MessageId']}")
        return True
    except ClientError as e:
        print(f"  Email failed: {e.response['Error']['Message']}")
        return False


def send_email_local(report, sender, recipient):
    """Send email via Gmail SMTP (no AWS needed)."""
    print("Sending email via Gmail SMTP...")
    msg = MIMEText(report)
    msg["Subject"] = f"Trading Signals - {datetime.now().strftime('%Y-%m-%d')}"
    msg["From"] = sender
    msg["To"] = recipient
    try:
        with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
            server.login(sender, GMAIL_APP_PASSWORD)
            server.sendmail(sender, recipient, msg.as_string())
        print("  Email sent via Gmail!")
        return True
    except Exception as e:
        print(f"  Email failed: {e}")
        return False


def send_whatsapp_alert(report):
    print("Sending WhatsApp alert via Twilio...")
    try:
        client = TwilioClient(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
        message_body = report[:1500] if len(report) > 1500 else report
        message = client.messages.create(
            from_=TWILIO_WHATSAPP_FROM,
            to=WHATSAPP_TO,
            body=message_body
        )
        print(f"  WhatsApp sent! SID: {message.sid}")
        return True
    except Exception as e:
        print(f"  WhatsApp failed: {e}")
        return False


# ============================================================
# Main Execution (local run)
# ============================================================
stock_df = fetch_market_data(STOCK_SYMBOL, PERIOD, INTERVAL)
stock_df = add_technical_indicators(stock_df)

crypto_df = fetch_market_data(CRYPTO_SYMBOL, PERIOD, INTERVAL)
crypto_df = add_technical_indicators(crypto_df)

stock_df = create_labels(stock_df, horizon=10, threshold=0.02)
stock_df, stock_model, stock_accuracy = train_model(stock_df)

crypto_df = create_labels(crypto_df, horizon=10, threshold=0.03)
crypto_df, crypto_model, crypto_accuracy = train_model(crypto_df)

plot_signals(stock_df, STOCK_SYMBOL, stock_accuracy)
plot_signals(crypto_df, CRYPTO_SYMBOL, crypto_accuracy)

export_to_excel(stock_df, STOCK_SYMBOL, EXPORT_FILENAME)
export_to_excel(crypto_df, CRYPTO_SYMBOL, EXPORT_FILENAME)

stock_summary, stock_latest = generate_signal_summary(stock_df, STOCK_SYMBOL, stock_accuracy)
crypto_summary, crypto_latest = generate_signal_summary(crypto_df, CRYPTO_SYMBOL, crypto_accuracy)

full_report = f"AI Trading Signal Report - {datetime.now().strftime('%Y-%m-%d')}\n\n"
full_report += stock_summary + "\n" + crypto_summary
print(full_report)

# Send email (choose mode)
if EMAIL_MODE == "aws":
    email_sent = send_email_alert(full_report, EMAIL_SENDER, EMAIL_RECIPIENT, AWS_REGION)
else:
    email_sent = send_email_local(full_report, GMAIL_ADDRESS, EMAIL_RECIPIENT)

whatsapp_sent = send_whatsapp_alert(full_report)

print(f"\nNotification Summary:")
print(f"  Email: {'Sent' if email_sent else 'Failed'}")
print(f"  WhatsApp: {'Sent' if whatsapp_sent else 'Failed'}")
print(f"\nDone! All data exported to {EXPORT_FILENAME}")

Why Gmail SMTP instead of AWS SES?

AWS SES requires an active AWS account, verified email addresses, and runs in sandbox mode by default. Gmail SMTP works with any Gmail account and Python's built-in libraries.

If you want to keep using AWS SES, leave EMAIL_MODE set to "aws". The Gmail path is purely an alternative for local-only setups.

Create a batch file to run the script

Windows Task Scheduler needs a batch file to know which script to execute. This file navigates to your project folder and runs signal_generator.py.

  • Create a new file called run_signals.bat inside your trading-signal-generator folder.
  • Add the following content to run_signals.bat:
@echo off
cd /d [[PROJECT_PATH="full path to your trading-signal-generator folder, e.g. C:\Users\YourName\Desktop\trading-signal-generator"]]
python signal_generator.py

What does this batch file do?

  • @echo off hides the command output from the terminal window.
  • cd /d changes to the project directory, including switching drives if needed.
  • python signal_generator.py runs the full signal generation pipeline.
  • Save run_signals.bat.
  • Test the batch file by double-clicking it in File Explorer. You should see a terminal window open, the script run, and the signal report printed before the window closes.

Batch file closes instantly or shows an error?

  • Make sure the path in cd /d matches the actual location of your trading-signal-generator folder. Right-click the folder in File Explorer and select Copy as path to get the exact path.
  • If python is not recognized, make sure Python is added to your system PATH. You can test by opening a new terminal and running python --version.
  • To see the error before the window closes, add pause as a new last line in the batch file. This keeps the window open until you press a key.

Why is my batch file failing to run signal_generator.py?

Schedule daily execution with Windows Task Scheduler

Windows Task Scheduler can run your batch file automatically at a set time every day. You will schedule it for 6:00 PM, which is after the Indian stock market closes at 3:30 PM IST.

  • Press Win+R to open the Run dialog.
  • Type taskschd.msc and press Enter. This opens the Task Scheduler window.
  • Click Create Basic Task in the right-hand Actions panel.
  • Enter Daily Trading Signals as the task name.
  • Click Next.
  • Select Daily and click Next.
  • Set the start time to 6:00:00 PM.
  • Click Next.
  • Select Start a program and click Next.
  • Click Browse and navigate to the run_signals.bat file inside your trading-signal-generator folder. Select it and click Open.
  • Click Next.
  • Check the box Open the Properties dialog for this task when I click Finish.
  • Click Finish. The Properties dialog opens.
  • In the Properties dialog, select Run whether user is logged on or not.
  • Click OK. Windows may prompt you for your login password to confirm.

Your task should now appear in the Task Scheduler Library. The script will run automatically at 6:00 PM every day as long as your computer is powered on.

What about the Twilio WhatsApp alerts?

The WhatsApp notification via Twilio works the same way regardless of whether you use AWS Lambda or local scheduling. Your signal_generator.py already includes the send_whatsapp_alert call in the main execution block.

Remember that the Twilio sandbox session expires every 3 days. You will need to re-send the join message to the sandbox number to keep it active.

Task not running at the scheduled time?

  • Your computer must be powered on at the scheduled time. If it is in sleep mode, the task will not fire.
  • Right-click the task in Task Scheduler Library and select Run to test it manually. Check the Last Run Result column for error codes.
  • If the result shows 0x1, the batch file path is incorrect or Python is not in your system PATH.

Why is my Windows Task Scheduler task not running my Python script?

Secret mission

Add Portfolio Simulation with Profit/Loss Tracking

Your signal generator tells you when to buy and sell. But how much money would those signals actually make? Add a portfolio simulator that tracks every trade, calculates cumulative profit and loss, and includes the results in your Excel exports and notification alerts.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Cost warning

This project uses AWS services that stay within the free tier for light usage. AWS Lambda and EventBridge Scheduler are always free for this workload (1M requests/month and 14M invocations/month respectively).

AWS SES covers 3,000 emails/month free for the first 12 months only. After that, it costs $0.10 per 1,000 emails. Twilio sandbox messages use your free trial balance. If you do not plan to continue using these services, choose Pause or Delete below.

Resources you used:

  • AWS Lambda function (TradingSignalGenerator).
  • AWS Lambda layer (trading-signal-deps).
  • EventBridge Scheduler schedule (DailyTradingSignals).
  • AWS SES verified email identity.
  • Twilio WhatsApp sandbox connection.
  • Local project folder (trading-signal-generator) with Python packages, Excel output, and chart images.
  • Windows Task Scheduler task (if you set up the local alternative).

Keep everything running

No action needed. Choose this if you are still actively building or want the daily signal reports to keep arriving.

  • Your EventBridge schedule continues triggering the Lambda function daily at 6:00 PM IST.
  • Monitor your AWS billing dashboard periodically to confirm you stay within free tier limits.
  • Remember to re-send the Twilio sandbox join message every 3 days to keep WhatsApp notifications active.

Pause - I'll come back to this later

Disable the daily schedule so nothing runs while you are away. Your Lambda function and layer stay intact for when you return.

  • Open the AWS Console and navigate to Amazon EventBridge, then Scheduler.
  • Find the DailyTradingSignals schedule and disable it.
  • When you are ready to resume, re-enable the schedule from the same page.
  • If you used Windows Task Scheduler instead, right-click the Daily Trading Signals task and select Disable.

Delete - I don't want to use this again

Remove all AWS resources and local files to start completely fresh.

  • Delete the EventBridge schedule, Lambda function, and Lambda layer by running these commands:
aws scheduler delete-schedule --name DailyTradingSignals --region ap-south-1
aws lambda delete-function --function-name TradingSignalGenerator --region ap-south-1
aws lambda delete-layer-version --layer-name trading-signal-deps --version-number 1 --region ap-south-1
  • Remove your verified email identity from SES by navigating to SES in the AWS Console, selecting Verified identities, and deleting your email address.
  • Delete the local project folder trading-signal-generator from your Desktop.
  • If you used Windows Task Scheduler, open Task Scheduler, right-click the Daily Trading Signals task, and select Delete.

Mission accomplished

That's a Wrap!

Congratulations! You built a fully automated AI-powered trading signal generator with both cloud and local deployment options.

Here's what you learned:

  • Built a Python pipeline that collects historical market data for Indian NSE stocks and crypto pairs, computes six technical indicators (RSI, MACD, SMA, Bollinger Bands, ADX), and trains a Random Forest classifier to generate BUY, SELL, and HOLD signals with Excel export and matplotlib charts.
  • Set up automated notifications via AWS SES email alerts and Twilio WhatsApp messages to deliver daily signal reports straight to your inbox and phone.
  • Deployed the pipeline to AWS Lambda with daily scheduled execution via EventBridge Scheduler, plus a local alternative using Windows Task Scheduler and Gmail SMTP for zero-cloud automation.
  • Secret Mission: Added a portfolio simulation that calculates profit/loss for each trade and includes cumulative returns in both the Excel export and notification messages.

Ready to quiz yourself?