Building an Automated NASDAQ Stock Scanner: From Setup to Deployment

Overview

This guide outlines the complete process for creating an automated stock scanner that runs after market close, scans all NASDAQ-listed stocks, identifies those meeting selected technical criteria, and delivers results via email. The solution is implemented in Python and deployed using GitHub Actions for fully scheduled, unattended operation.

Core Functionality

The scanner provides the following main features:

  1. Symbol Acquisition

    Retrieves the current list of NASDAQ-listed stock symbols from the official NASDAQ website

  2. Scanning Criteria (The following indicators are for example only and can be adjusted according to the actual trading strategy)

    • Volume Surge: Current day dollar volume > 2 x the average dollar volume of the previous 60 trading days
    • MA Golden Cross: MA5 (day) crosses above MA10, with average dollar volume over the past 10 trading days > US$50 million
  3. Result Presentation

    Sorts qualifying stocks by current day dollar volume (descending) and displays results clearly in email format

  4. Email Notification

    Automatically sends scan results to a designated email address

  5. Scheduled Automation

    Hosts the script on GitHub and uses GitHub Actions for regular, hands-free execution

Technology Stack

  • Language: Python
  • Data manipulation: pandas, numpy
  • HTTP requests: requests
  • Market data: yfinance
  • Email: smtplib
  • CI/CD & hosting: GitHub + GitHub Actions

Implementation Details

Script Structure
  1. Email Configuration (sensitive values should be stored in environment variables)

    1
    2
    3
    4
    5
    6
    7
    8
    SMTP_SERVER = "smtp.gmail.com"
    SMTP_PORT = 587
    SENDER_EMAIL = os.getenv('EMAIL_NAME')
    SENDER_PASSWORD = os.getenv('EMAIL_PASSWORD')
    RECIPIENT_EMAIL = os.getenv('RECIPIENT_EMAIL')

    if not all([SENDER_EMAIL, SENDER_PASSWORD, RECIPIENT_EMAIL]):
    raise ValueError("Required email environment variables are missing")
  2. Fetch NASDAQ Symbol List

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    def get_nasdaq_symbols():
    """
    Download and parse nasdaqlisted.txt from NASDAQ official website to obtain the current stock symbol list
    """
    url = "https://www.nasdaqtrader.com/dynamic/symdir/nasdaqlisted.txt"
    try:
    response = requests.get(url)
    response.raise_for_status()
    df = pd.read_csv(io.StringIO(response.text), sep="|")
    symbols = df['Symbol'].dropna().tolist()
    print(f"Successfully retrieved {len(symbols)} NASDAQ symbols")
    return symbols
    except requests.exceptions.RequestException as e:
    print(f"Failed to download symbol list: {e}")
    return []
    except pd.errors.EmptyDataError:
    print("Downloaded file is empty or has incorrect format")
    return []
    except Exception as e:
    print(f"Error parsing symbol list: {e}")
    return []
  3. Data acquisition and analysis

    This is the core part of the script, responsible for obtaining the historical data of a single stock, calculating the moving average and trading volume, and determining whether the preset conditions are met.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    def get_stock_data(ticker):
    """
    Fetch historical data for the specified ticker and calculate indicators required for screening
    """
    import yfinance as yf

    stock = yf.Ticker(ticker)
    # Get at least 70 days of data to ensure enough history for MA10 and 60-day average volume
    hist_data = stock.history(period="70d")

    if hist_data.empty or len(hist_data) < 11:
    print(f"Warning: {ticker} has insufficient historical data, skipping")
    return None, None, None, None, None, None

    # Calculate moving averages
    hist_data['MA5'] = hist_data['Close'].rolling(window=5).mean()
    hist_data['MA10'] = hist_data['Close'].rolling(window=10).mean()

    # Calculate daily dollar volume (Dollar Volume = Close × Volume)
    hist_data['DollarVolume'] = hist_data['Close'] * hist_data['Volume']

    # ── Condition 1: Volume Surge ──
    current_dollar_volume = hist_data['DollarVolume'].iloc[-1]
    past_60_dollar_volumes = hist_data['DollarVolume'].iloc[-61:-1]

    if len(past_60_dollar_volumes) < 60:
    print(f"Warning: {ticker} has fewer than 60 prior trading days, skipping volume surge check")
    dollar_vol_condition_met = False
    avg_dollar_vol_60 = None
    ratio = None
    else:
    avg_dollar_vol_60 = past_60_dollar_volumes.mean()
    # Strict check to prevent division by zero or NaN
    if pd.isna(avg_dollar_vol_60) or avg_dollar_vol_60 == 0:
    print(f"Warning: {ticker} has invalid 60-day average dollar volume ({avg_dollar_vol_60}), skipping volume check")
    dollar_vol_condition_met = False
    ratio = None
    return dollar_vol_condition_met, avg_dollar_vol_60, None, current_dollar_volume, None, ratio
    else:
    ratio = current_dollar_volume / avg_dollar_vol_60
    dollar_vol_condition_met = ratio > 2

    # ── Condition 2: MA Golden Cross + High Volume ──
    current_ma5 = hist_data['MA5'].iloc[-1]
    current_ma10 = hist_data['MA10'].iloc[-1]
    prev_ma5 = hist_data['MA5'].iloc[-2]
    prev_ma10 = hist_data['MA10'].iloc[-2]

    ma_condition_met = (current_ma5 > current_ma10) and (prev_ma5 <= prev_ma10)

    # Check 10-day average dollar volume requirement
    past_10_dollar_volumes = hist_data['DollarVolume'].iloc[-11:-1]
    if len(past_10_dollar_volumes) < 10:
    print(f"Warning: {ticker} has fewer than 10 prior trading days, skipping MA volume condition")
    avg_dollar_vol_10 = None
    high_dollar_vol_condition_met = False
    else:
    avg_dollar_vol_10 = past_10_dollar_volumes.mean()
    if pd.isna(avg_dollar_vol_10) or avg_dollar_vol_10 == 0:
    print(f"Warning: {ticker} has invalid 10-day average dollar volume ({avg_dollar_vol_10}), skipping")
    high_dollar_vol_condition_met = False
    else:
    high_dollar_vol_condition_met = avg_dollar_vol_10 > 50_000_000

    ma_and_volume_condition_met = ma_condition_met and high_dollar_vol_condition_met

    return (dollar_vol_condition_met, avg_dollar_vol_60,
    ma_and_volume_condition_met, current_dollar_volume,
    avg_dollar_vol_10, ratio)
  4. Scanning and result organization

    Traverse the stock list, call get_stock_data for analysis, and collect the results that meet the conditions.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    def scan_stocks(stock_list):
    """
    Iterate through the stock list and collect symbols that meet the screening conditions
    """
    volume_results = []
    ma_results = []

    for ticker in stock_list:
    try:
    vol_met, avg_vol_60, ma_met, current_vol, avg_dollar_vol_10, ratio = get_stock_data(ticker)

    if vol_met is not None and avg_vol_60 is not None and ratio is not None:
    volume_results.append({
    'Symbol': ticker,
    'Current Dollar Volume': f"${current_vol:,.2f}",
    '60-Day Avg Dollar Volume': f"${avg_vol_60:,.2f}",
    'Ratio (Current / 60-Day Avg)': f"{ratio:.2f}x"
    })
    print(f"Found volume surge stock: {ticker}")

    if ma_met is not None and avg_dollar_vol_10 is not None:
    ma_results.append({
    'Symbol': ticker,
    'Avg Dollar Volume (10-day)': f"${avg_dollar_vol_10:,.2f}"
    })
    print(f"Found MA golden cross + high volume stock: {ticker}")

    except Exception as e:
    print(f"Error processing {ticker}: {e}")
    continue

    # Convert to DataFrame and sort by volume descending
    df_volumes = pd.DataFrame(volume_results)
    if not df_volumes.empty:
    df_volumes['sort_key'] = df_volumes['Current Dollar Volume'].str.replace(r'[\$,]', '', regex=True).astype(float)
    df_volumes = df_volumes.sort_values('sort_key', ascending=False)\
    .drop('sort_key', axis=1)\
    .reset_index(drop=True)

    df_mas = pd.DataFrame(ma_results)
    if not df_mas.empty:
    df_mas['sort_key'] = df_mas['Avg Dollar Volume (10-day)'].str.replace(r'[\$,]', '', regex=True).astype(float)
    df_mas = df_mas.sort_values('sort_key', ascending=False)\
    .drop('sort_key', axis=1)\
    .reset_index(drop=True)

    return df_volumes, df_mas
  5. HTML Email Generation & Sending

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    def send_email(df_volume_results, df_ma_results):
    """
    Format the scan results as HTML and send via email
    """
    html_content = f"<h2>NASDAQ After-Hours Scan Results - {pd.Timestamp.now().strftime('%Y-%m-%d')}</h2>"

    if df_volume_results.empty:
    html_content += "<h3>1. Volume Surge Stocks</h3><p>No stocks found with today's volume > 2× 60-day average</p>"
    else:
    html_content += "<h3>1. Volume Surge Stocks (sorted by current day dollar volume)</h3>"
    html_content += df_volume_results.to_html(index=False, table_id="volume_results")

    if df_ma_results.empty:
    html_content += "<h3>2. MA5 Crosses Above MA10 + 10-day Avg > $50M</h3><p>No matching stocks today</p>"
    else:
    html_content += "<h3>2. MA5 Crosses Above MA10 + 10-day Avg > $50M (sorted by 10-day avg volume)</h3>"
    html_content += df_ma_results.to_html(index=False, table_id="ma_results")

    message = MIMEMultipart("alternative")
    message["Subject"] = f"NASDAQ Scan Report - {pd.Timestamp.now().strftime('%Y-%m-%d')}"
    message["From"] = SENDER_EMAIL
    message["To"] = RECIPIENT_EMAIL

    part = MIMEText(html_content, "html")
    message.attach(part)

    try:
    with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT) as server:
    server.login(SENDER_EMAIL, SENDER_PASSWORD)
    server.sendmail(SENDER_EMAIL, RECIPIENT_EMAIL, message.as_string())
    print("Email sent successfully")
    except Exception as e:
    print(f"Email sending failed: {e}")
  6. Main Entry Point

    Connect all the steps.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    def main():
    """
    Main execution entry point - coordinates the entire scanning process
    """
    print("Downloading NASDAQ symbol list...")
    stock_symbols = get_nasdaq_symbols()

    if not stock_symbols:
    print("Failed to obtain stock symbol list. Terminating execution.")
    return

    print(f"Starting scan of {len(stock_symbols)} NASDAQ symbols...")
    results_df_volumes, results_df_mas = scan_stocks(stock_symbols)

    print("Scan completed. Preparing and sending email report...")
    send_email(results_df_volumes, results_df_mas)
    print("Script execution finished.")

    if __name__ == "__main__":
    main()
GitHub Repository & Secrets Setup
  1. Create Repository

    Upload the main script (e.g. stock_scanner.py) to the GitHub repository.

  2. Configure Repository Secrets

    To securely store email credentials:

    • Go to repository → Settings → Secrets and variables → Actions
    • Create an environment named e.g. scan_env
    • Add secrets under this environment: EMAIL_NAME, EMAIL_PASSWORD, RECIPIENT_EMAIL
    secrets-config
  3. GitHub Actions Workflow

    Create file .github/workflows/stock_scan.yml:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    name: Run Stock Scanner

    on:
    schedule:
    # 6:00 AM Beijing time Tue-Sat (UTC 22:00 Mon-Fri)
    - cron: '0 22 * * 1-5'
    workflow_dispatch: # Allow manual trigger

    jobs:
    run-script:
    runs-on: ubuntu-latest
    environment: scan_env # Use the environment you created

    steps:
    - name: Checkout repository
    uses: actions/checkout@v4

    - name: Set up Python
    uses: actions/setup-python@v5
    with:
    python-version: '3.x'

    - name: Install dependencies
    run: |
    python -m pip install --upgrade pip
    pip install -r requirements.txt

    - name: Run Python script
    env:
    EMAIL_PASSWORD: ${{ secrets.EMAIL_PASSWORD }}
    run: |
    python stock_scanner.py

    And create requirements.txt:

    1
    2
    3
    4
    pandas
    requests
    yfinance
    numpy
  4. Deployment & Execution

    Push all files (stock_scanner.py, .github/workflows/stock_scan.yml, requirements.txt) to GitHub.

    Go to the “Actions” tab in your repository — you should see the “Run Stock Scanner” workflow. It will run automatically according to the schedule, or you can trigger it manually with the “Run workflow” button.

    run-action