DEV Community

Jose Peinado
Jose Peinado

Posted on

Creating a Stock Price Alert App using AWS SAM and a Telegram Bot

Overview

This application allows users to create alerts for selected stock tickers on the NYSE. When the market price reaches or exceeds the target price, the application sends a notification directly to a Telegram chat via a bot. This way, we cover several serverless services and implement a common app flow to be used as a boilerplate.

The solution uses:
• AWS Lambda (Python) for backend logic:
• Alerts Function: CRUD operations for alerts stored in DynamoDB.
• Price Scanner Function: Scheduled function to query current stock prices via yfinance and send Telegram messages.
• Basic Authorizer: A simple Lambda authorizer to secure API Gateway endpoints.
• Amazon DynamoDB to store alerts.
• Amazon API Gateway to expose REST endpoints.
• AWS SAM for deployment.
• Amazon S3 for hosting the frontend static website.
• Telegram Bot API for push notifications.

Prerequisites

• AWS Account with permissions to deploy Lambda, DynamoDB, API Gateway, SNS, and S3 resources.
• AWS SAM CLI installed and configured.
• Python 3.9 environment.
• Docker (for local SAM testing with container emulation).
• A Telegram Bot created via @botfather with a valid bot token.
• A method to retrieve your Telegram Chat ID (by sending a message to the bot and calling the getUpdates API).

Project Structure

Image description

Github repository

You can find this project on this Github repository:
https://github.com/joseapeinado/stocks-alerts-app

Step 1. Develop the Backend Functions

1.1 Alerts Function (alerts.py)

This function implements REST endpoints to list, create, update, and delete alerts in DynamoDB. It also integrates with yfinance to retrieve the current stock price when listing alerts.

backend/handlers/alerts.py

import os
import json
import uuid
from decimal import Decimal
import boto3
from datetime import datetime
import yfinance as yf

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['ALERTS_TABLE'])
sns = boto3.client('sns')
sns_topic_arn = os.environ['SNS_TOPIC_ARN']

def lambda_handler(event, context):
    try:
        resource = event.get("resource", "")
        http_method = event.get("httpMethod", "")

        if resource == "/alerts":
            if http_method == "GET":
                result = table.scan()
                items = result.get("Items", [])
                for item in items:
                    item["currentPrice"] = str(get_current_price(item.get("ticker", "")))
                return _response(200, items)

            elif http_method == "POST":
                body = json.loads(event["body"])
                alert = {
                    "alertId": str(uuid.uuid4()),
                    "ticker": body["ticker"],
                    "price": str(Decimal(str(body["price"]))),
                    "createdAt": datetime.utcnow().isoformat(),
                    "paused": False  # Default state: active
                }
                table.put_item(Item=alert)
                alert["currentPrice"] = str(get_current_price(alert["ticker"]))
                return _response(201, alert)

            elif http_method == "PUT":
                # Update price and/or paused state.
                body = json.loads(event["body"])
                alert_id = body["alertId"]
                update_expression = []
                expression_attribute_values = {}

                if "price" in body:
                    update_expression.append("price = :p")
                    expression_attribute_values[":p"] = str(Decimal(str(body["price"])))
                if "paused" in body:
                    update_expression.append("paused = :s")
                    expression_attribute_values[":s"] = bool(body["paused"])
                if not update_expression:
                    return _response(400, {"error": "No valid fields to update."})
                update_expr = "set " + ", ".join(update_expression)
                result = table.update_item(
                    Key={"alertId": alert_id},
                    UpdateExpression=update_expr,
                    ExpressionAttributeValues=expression_attribute_values,
                    ReturnValues="ALL_NEW"
                )
                updated_item = result.get("Attributes", {})
                updated_item["currentPrice"] = str(get_current_price(updated_item.get("ticker", "")))
                return _response(200, updated_item)

            elif http_method == "DELETE":
                alert_id = event["queryStringParameters"].get("alertId")
                table.delete_item(Key={"alertId": alert_id})
                return _response(200, {"message": "Alert deleted"})

            else:
                return _response(405, "Method Not Allowed")
        else:
            return _response(404, "Not Found")

    except Exception as e:
        return _response(500, {"error": str(e)})

def get_current_price(ticker):
    try:
        data = yf.Ticker(ticker)
        current_price = data.info.get("regularMarketPrice")
        return Decimal(str(current_price)) if current_price is not None else Decimal("0")
    except Exception:
        return Decimal("0")

def _response(status_code, body):
    return {
        "statusCode": status_code,
        "headers": {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "*"
        },
        "body": json.dumps(body, default=str)
    }
Enter fullscreen mode Exit fullscreen mode

1.2 Price Scanner Function (priceScanner.py)

This function is scheduled to run periodically. It scans the AlertsTable, checks the current stock price using yfinance, and if the price meets or exceeds the target, it sends a message via Telegram. It also skips alerts that are paused.

backend/handlers/priceScanner.py


import os
import json
from decimal import Decimal
import boto3
import yfinance as yf
from datetime import datetime
import logging
import requests

# Configure logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table(os.environ['ALERTS_TABLE'])

# Telegram Bot credentials from environment variables
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN")
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID")

def lambda_handler(event, context):
    result = table.scan()
    alerts = result.get("Items", [])
    triggered_alerts = []
    now = datetime.utcnow()

    for alert in alerts:
        # Skip paused alerts
        if alert.get("paused", False):
            continue

        ticker = alert.get("ticker")
        target_price = Decimal(alert.get("price"))
        current_price = get_current_price(ticker)

        # Trigger if the current price meets or exceeds the target
        if current_price >= target_price:
            message = (
                f"Alert: {ticker} current price ${current_price} meets/exceeds your target of ${target_price}."
            )
            send_telegram_message(message)
            triggered_alerts.append(alert)
            logger.info("Triggered Alert: %s", json.dumps({
                "alertId": alert.get("alertId"),
                "ticker": ticker,
                "targetPrice": str(target_price),
                "currentPrice": str(current_price),
                "triggeredAt": now.isoformat()
            }))

    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": "Price scan completed",
            "triggeredAlerts": triggered_alerts
        }, default=str)
    }

def get_current_price(ticker):
    try:
        data = yf.Ticker(ticker)
        current_price = data.info.get("regularMarketPrice")
        return Decimal(str(current_price)) if current_price is not None else Decimal("0")
    except Exception as e:
        logger.error("Error retrieving price for %s: %s", ticker, e)
        return Decimal("0")

def send_telegram_message(message):
    if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
        logger.error("Telegram bot token or chat id not configured.")
        return

    url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
    payload = {
        "chat_id": TELEGRAM_CHAT_ID,
        "text": message
    }
    try:
        response = requests.get(url, params=payload)
        logger.info("Telegram response: %s", response.text)
    except Exception as e:
        logger.error("Error sending Telegram message: %s", e)
Enter fullscreen mode Exit fullscreen mode

Note: To address a cache warning from yfinance, add the following after importing yfinance:

yf.set_tz_cache_location('/tmp/py-yfinance')
Enter fullscreen mode Exit fullscreen mode

1.3 Basic Authorizer (basicAuthorizer.py)

This minimalistic authorizer validates the incoming Bearer token. In production, store sensitive values securely.

backend/handlers/basicAuthorizer.py

import json
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    logger.info("Received event: %s", json.dumps(event))
    try:
        token = event.get("authorizationToken", "")
        expected_token = "secret-token-123"  # Replace or secure this token as needed

        effect = "Allow" if token == f"Bearer {expected_token}" else "Deny"

        auth_response = {
            "principalId": "user",
            "policyDocument": {
                "Version": "2012-10-17",
                "Statement": [
                    {
                        "Action": "execute-api:Invoke",
                        "Effect": effect,
                        "Resource": event.get("methodArn")
                    }
                ]
            }
        }
        logger.info("Authorization response: %s", json.dumps(auth_response))
        return auth_response
    except Exception as e:
        logger.error("Error in authorizer", exc_info=True)
        raise e
Enter fullscreen mode Exit fullscreen mode

Requirements file

Please create a requirements.txt file to manage Python dependencies, with the following modules:

yfinance
requests
Enter fullscreen mode Exit fullscreen mode

Step 2. Develop the Frontend

2.1 HTML (index.html)

This basic HTML page hosts a form for creating alerts and a table to display alerts with actions to update, delete, or toggle pause.


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Stock Price Alerts</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div class="container">
    <h1>Stock Price Alerts</h1>
    <form id="alertForm">
      <input type="text" id="ticker" placeholder="Ticker" required>
      <span id="currentPriceDisplay"></span>
      <input type="number" id="price" placeholder="Target Price" step="0.01" required>
      <button type="submit">Add Alert</button>
    </form>
    <h2>Your Alerts</h2>
    <table id="alertsTable">
      <thead>
        <tr>
          <th>Ticker</th>
          <th>Target Price</th>
          <th>Current Price</th>
          <th>Created At</th>
          <th>State</th>
          <th>Actions</th>
        </tr>
      </thead>
      <tbody id="alertsTableBody"></tbody>
    </table>
  </div>
  <script src="main.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

2.2 JavaScript (main.js)

The JavaScript handles user interactions: fetching the current price on ticker blur, creating alerts, and updating/deleting/toggling alert state via API calls.


const apiUrl = 'https://<YOUR_API_GATEWAY_ID>.execute-api.<region>.amazonaws.com/Prod/alerts';
const priceUrl = 'https://<YOUR_API_GATEWAY_ID>.execute-api.<region>.amazonaws.com/Prod/price';
const authHeader = 'Bearer secret-token-123';

document.addEventListener('DOMContentLoaded', () => {
  loadAlerts();

  // When the ticker field loses focus, show the current price.
  const tickerInput = document.getElementById('ticker');
  tickerInput.addEventListener('blur', async () => {
    const ticker = tickerInput.value.trim();
    const display = document.getElementById('currentPriceDisplay');
    if (ticker) {
      try {
        const response = await fetch(`${priceUrl}?ticker=${encodeURIComponent(ticker)}`, {
          method: 'GET',
          headers: { 'Authorization': authHeader }
        });
        const data = await response.json();
        if (response.ok && data.currentPrice) {
          display.textContent = `Current Price: $${data.currentPrice}`;
        } else {
          display.textContent = 'Current Price: N/A';
        }
      } catch (err) {
        display.textContent = 'Current Price: Error';
      }
    } else {
      display.textContent = '';
    }
  });
});

document.getElementById('alertForm').addEventListener('submit', async (e) => {
  e.preventDefault();
  const ticker = document.getElementById('ticker').value;
  const price = parseFloat(document.getElementById('price').value);

  const response = await fetch(apiUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': authHeader
    },
    body: JSON.stringify({ ticker, price })
  });
  if (response.ok) {
    loadAlerts();
    e.target.reset();
    document.getElementById('currentPriceDisplay').textContent = '';
  }
});

async function loadAlerts() {
  const response = await fetch(apiUrl, {
    method: 'GET',
    headers: { 'Authorization': authHeader }
  });
  const alerts = await response.json();
  const tableBody = document.getElementById('alertsTableBody');
  tableBody.innerHTML = '';

  alerts.forEach(alert => {
    const tr = document.createElement('tr');

    // Ticker cell
    const tdTicker = document.createElement('td');
    tdTicker.textContent = alert.ticker;
    tr.appendChild(tdTicker);

    // Target Price cell
    const tdPrice = document.createElement('td');
    tdPrice.textContent = alert.price;
    tr.appendChild(tdPrice);

    // Current Price cell
    const tdCurrentPrice = document.createElement('td');
    tdCurrentPrice.textContent = alert.currentPrice || "N/A";
    tr.appendChild(tdCurrentPrice);

    // Created At cell
    const tdCreatedAt = document.createElement('td');
    tdCreatedAt.textContent = alert.createdAt;
    tr.appendChild(tdCreatedAt);

    // State cell (Active or Paused)
    const tdState = document.createElement('td');
    tdState.textContent = alert.paused ? "Paused" : "Active";
    tr.appendChild(tdState);

    // Actions cell with three buttons: Update Price, Delete, Toggle Pause
    const tdActions = document.createElement('td');
    tdActions.innerHTML = `
      <button class="icon-button" title="Update Price" onclick='updateAlert(${JSON.stringify(alert)})'>
        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
          <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" fill="currentColor"/>
        </svg>
      </button>
      <button class="icon-button" title="Delete" onclick='deleteAlert("${alert.alertId}")'>
        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
          <path d="M16 9v10H8V9h8m-1.5-6h-5l-1 1H5v2h14V4h-4.5l-1-1z" fill="currentColor"/>
        </svg>
      </button>
      <button class="icon-button" title="Toggle Pause" onclick='toggleAlertState(${JSON.stringify(alert)})'>
        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
          <path d="M12 2a10 10 0 100 20 10 10 0 000-20zm1 14h-2v-4h2v4zm0-6h-2V7h2v3z" fill="currentColor"/>
        </svg>
      </button>
    `;
    tr.appendChild(tdActions);

    tableBody.appendChild(tr);
  });
}

async function updateAlert(alert) {
  const newPrice = prompt('Enter new price', alert.price);
  if (newPrice) {
    const response = await fetch(apiUrl, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': authHeader
      },
      body: JSON.stringify({
        alertId: alert.alertId,
        ticker: alert.ticker,
        price: parseFloat(newPrice)
      })
    });
    if (response.ok) {
      loadAlerts();
    } else {
      alert('Update failed.');
    }
  }
}

async function deleteAlert(alertId) {
  if (confirm('Are you sure you want to delete this alert?')) {
    const response = await fetch(`${apiUrl}?alertId=${alertId}`, {
      method: 'DELETE',
      headers: { 'Authorization': authHeader }
    });
    if (response.ok) {
      loadAlerts();
    } else {
      alert('Deletion failed.');
    }
  }
}

async function toggleAlertState(alert) {
  const newPaused = !alert.paused;
  const response = await fetch(apiUrl, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': authHeader
    },
    body: JSON.stringify({
      alertId: alert.alertId,
      ticker: alert.ticker,
      price: alert.price,
      paused: newPaused
    })
  });
  if (response.ok) {
    loadAlerts();
  } else {
    alert('Toggle failed.');
  }
}
Enter fullscreen mode Exit fullscreen mode

2.3 CSS (styles.css)

Ensure your icons and table display correctly:


/* Table styling */
table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 1rem;
}

th, td {
  border: 1px solid #ddd;
  padding: 8px;
  text-align: left;
}

th {
  background-color: #f4f4f4;
}

/* Icon button styling */
.icon-button {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px;
  margin: 0 2px;
  color: #000; /* Ensure currentColor is visible */
}

.icon-button svg {
  fill: currentColor;
}

.icon-button:hover svg {
  fill: #0073e6;
}

/* Current price display next to ticker input */
#currentPriceDisplay {
  margin-left: 10px;
  font-weight: bold;
  color: #333;
}
Enter fullscreen mode Exit fullscreen mode

Step 3. SAM Template

Below is the final template.yaml for the application. It provisions all resources (DynamoDB, SNS, Lambda functions, API Gateway, S3 bucket for frontend) and configures environment variables and policies.


AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Minimal Stock Price Alerts WebApp with Python Lambdas, CORS, S3 static website, and Outputs

Parameters:
  Environment:
    Type: String
    Default: Prod
    AllowedValues:
      - Prod
      - Dev
    Description: Environment type

Globals:
  Function:
    Timeout: 10

Resources:
  AlertsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: AlertsTable
      AttributeDefinitions:
        - AttributeName: alertId
          AttributeType: S
      KeySchema:
        - AttributeName: alertId
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST

  SNSTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: StockAlertsTopic

  AlertsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: backend/handlers/
      Handler: alerts.lambda_handler
      Runtime: python3.9
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref AlertsTable
      Environment:
        Variables:
          ALERTS_TABLE: !Ref AlertsTable
          SNS_TOPIC_ARN: !Ref SNSTopic
      Events:
        AlertsAPI:
          Type: Api
          Properties:
            Path: /alerts
            Method: any
            RestApiId: !Ref AlertsApi
        PriceLookup:
          Type: Api
          Properties:
            Path: /price
            Method: GET
            RestApiId: !Ref AlertsApi

  PriceScannerFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: backend/handlers/
      Handler: priceScanner.lambda_handler
      Runtime: python3.9
      Timeout: 30    # Increased timeout for external API calls
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref AlertsTable
        - SNSPublishMessagePolicy:
            TopicName: StockAlertsTopic
      Environment:
        Variables:
          ALERTS_TABLE: !Ref AlertsTable
          SNS_TOPIC_ARN: !GetAtt SNSTopic.TopicArn
          TELEGRAM_CHAT_ID: "REDACTED"
          TELEGRAM_BOT_TOKEN: "REDACTED"
      Events:
        PriceScannerSchedule:
          Type: Schedule
          Properties:
            Schedule: rate(10 minutes)

  EmailSubscription1:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref SNSTopic
      Protocol: email
      Endpoint: "joseapeinado@gmail.com"

  BasicAuthAuthorizerFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: backend/handlers/
      Handler: basicAuthorizer.lambda_handler
      Runtime: python3.9
      MemorySize: 128
      Timeout: 5

  AlertsApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Prod
      Cors:
        AllowOrigin: "'*'"
        AllowHeaders: "'Content-Type,Authorization'"
        AllowMethods: "'OPTIONS,GET,POST,PUT,DELETE'"
      Auth:
        DefaultAuthorizer: BasicAuthorizer
        AddDefaultAuthorizerToCorsPreflight: false
        Authorizers:
          BasicAuthorizer:
            FunctionArn: !GetAtt BasicAuthAuthorizerFunction.Arn
            Identity:
              ReauthorizeEvery: 0
              Headers:
                - Authorization

  StaticSiteBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: stocks-alerts-app-staticsitebucket-12345678
      WebsiteConfiguration:
        IndexDocument: index.html
      PublicAccessBlockConfiguration:
        BlockPublicAcls: false
        BlockPublicPolicy: false
        IgnorePublicAcls: false
        RestrictPublicBuckets: false

  StaticSiteBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref StaticSiteBucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: PublicReadGetObject
            Effect: Allow
            Principal: "*"
            Action: s3:GetObject
            Resource: !Sub "${StaticSiteBucket.Arn}/*"

Outputs:
  ApiUrl:
    Description: "API Gateway endpoint URL for the Prod stage"
    Value: !Sub "https://${AlertsApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/alerts"
  AlertsTableName:
    Description: "DynamoDB Alerts Table Name"
    Value: !Ref AlertsTable
  SNSTopicARN:
    Description: "SNS Topic ARN"
    Value: !Ref SNSTopic
  StaticSiteURL:
    Description: "URL for static website hosted on S3"
    Value: !GetAtt StaticSiteBucket.WebsiteURL

Enter fullscreen mode Exit fullscreen mode

Step 4. Build, Deploy, and Test

  1. Build the Application:
sam build
Building codeuri: /Users/josepeinado/jose/projects/serverless/stocks-alerts-app/backend/handlers runtime: python3.9 architecture: x86_64 functions: AlertsFunction, PriceScannerFunction, BasicAuthAuthorizerFunction                                                                                                                                                                                                                                                                              
 Running PythonPipBuilder:ResolveDependencies                                                                                                                                                                                                                                                                                                                                                                                                                                                      
 Running PythonPipBuilder:CopySource                                                                                                                                                                                                                                                                                                                                                                                                                                                               

Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
Enter fullscreen mode Exit fullscreen mode
  1. Deploy the Application: You can use a configuration file (samconfig.toml): samconfig.toml
version = 0.1
[default.deploy.parameters]
stack_name = "stocks-alerts-app"
resolve_s3 = true
s3_prefix = "stocks-alerts-app"
region = "us-east-1"
confirm_changeset = false
capabilities = "CAPABILITY_IAM"
image_repositories = []
Enter fullscreen mode Exit fullscreen mode
sam deploy 

Enter fullscreen mode Exit fullscreen mode
  1. Deploy the Frontend: Sync the frontend/ folder to your S3 bucket:
aws s3 sync frontend/ s3://stocks-alerts-app-staticsitebucket-12345678 --delete

Enter fullscreen mode Exit fullscreen mode
  1. Testing: • Use your browser to load the static site URL. • Create alerts for specific tickers.

Image description

Image description

• Verify via the Telegram chat that alerts are sent when the price condition is met. At this moment, I chose to trigger the alert when the target price is equal to or above the current price, so please adjust this to avoid getting alerts every time :).

Image description

• Use the table to update, delete, or toggle pause state on alerts.

Rationale & Considerations

• Serverless Architecture:
Leveraging AWS Lambda, API Gateway, and DynamoDB allows for a highly scalable and cost-effective solution. AWS SAM streamlines deployment and resource management.
• Python & yfinance:
Python provides a concise syntax and powerful libraries. The yfinance library lets us retrieve real-time market data without a paid API, though note it’s unofficial.
• Telegram Integration:
Using Telegram Bot API enables real-time push notifications. This is particularly useful for immediate alerting without relying on email (which might be slower or filtered).
• Security:
The Basic Authorizer secures API endpoints. In production, consider using a more robust authentication mechanism and store sensitive tokens securely (e.g., AWS Secrets Manager).
• Extensibility:
The design supports easy extension. For example, additional endpoints, richer alert criteria, or integration with other notification systems can be added with minimal changes.

Top comments (0)