Skip to main content
⚠️
Alerts notify you when a signal condition triggers — they are not buy or sell instructions. Always apply your own judgment. Not financial advice.
Technical Analysis · Part 3 of 5

Signal Alerts

Stop watching charts and start watching your inbox. OpenClaw monitors your watchlist around the clock — and only contacts you when something actually happens.

Email Alerts
Cooldown Logic
Daily Digest Option
Config-Driven

Why Alerts Beat Manual Monitoring

Let's be honest: manually checking charts every few hours is exhausting and inconsistent. You miss things. You catch things at the wrong time. After a while, you get fatigued and stop looking altogether.

OpenClaw flips this around. You define your rules once, and OpenClaw does the watching. It checks your watchlist on a schedule, and only sends you an alert when a real signal condition fires. No signal, no email — your inbox stays quiet.

Real Example
Let's say you've been watching NVDA for a potential RSI pullback below 30. Without alerts, you'd need to remember to check it every day — and even then, you might miss the exact moment it triggers.

With OpenClaw, you set rsi_oversold: 30 in your config and forget about it. When NVDA's RSI drops to 28, you get an email. That's it. You didn't have to lift a finger.

This is the power of automation. Your rules run 24/7. Your inbox stays relevant. You get back hours of manual work each week.

The Two Alert Modes

OpenClaw offers two different ways to receive alerts, depending on your trading style and how often your watchlist triggers signals.

Real-Time Alerts

An email fires the moment a signal triggers.

Best For

  • Fast-moving signals like MACD crossovers
  • RSI extremes that fade quickly
  • Day traders and active watchers

Trade-off

  • If 5 stocks hit RSI oversold the same day, you get 5 emails
  • Can feel noisy and interrupt your day

Daily Digest

All signals from the day roll into one email, sent after market close.

Best For

  • Less urgent signals
  • Anyone who finds individual emails noisy
  • Swing traders and longer-term strategies

Why It Works

  • One calm email per day, easy to review
  • Time to think before you act

Most users prefer the daily digest. It's one email per day, neatly summarizing everything that triggered. You get the benefits of automation without the inbox spam.

To switch modes, set daily_digest: true (digest mode) or daily_digest: false (real-time) in your ta-config.yaml.

Configuring Alerts in ta-config.yaml

All alert behavior is controlled by a single YAML file. Here's a complete example:

# ta-config.yaml — alert settings

watchlist:
  tickers:
    - AAPL
    - MSFT
    - NVDA
    - TSLA
    - SPY

signals:
  rsi_oversold:    30     # Send alert when RSI drops below this
  rsi_overbought:  70     # Send alert when RSI rises above this
  golden_cross:    true   # Alert when 50-day SMA crosses above 200-day
  death_cross:     true   # Alert when 50-day SMA crosses below 200-day
  macd_cross:      true   # Alert on MACD line/signal crossover
  bb_squeeze:      true   # Alert when Bollinger Bands compress

alerts:
  email: "you@example.com"       # Where to send alerts
  cooldown_hours: 24             # Don't re-alert the same signal for 24 hours
  daily_digest: true             # true = one daily summary | false = individual alerts
  send_time: "16:30"             # When to send the digest (24h format, your timezone)

  # Email server settings (use your own SMTP or a service like SendGrid)
  smtp_host: "localhost"         # or "smtp.gmail.com" for Gmail
  smtp_port: 587

Understanding Each Setting

Signals
rsi_oversold and rsi_overbought are thresholds. When RSI drops below oversold or rises above overbought, an alert fires.

golden_cross, death_cross, macd_cross, bb_squeeze are boolean flags. Set them to true to enable, false to disable.
The Cooldown: Preventing Alert Fatigue
The cooldown_hours setting is critical. Without it, the same signal could fire every single day while a stock stays in oversold territory.

A 24-hour cooldown means: once an alert fires for a given signal and ticker, you won't get alerted for that combination again until 24 hours pass. Then the alert is "live" again.

This is how OpenClaw prevents alert fatigue. You get notified once when something changes, not every time the condition persists.
SMTP Settings
OpenClaw uses SMTP to send emails. You can use:
Local SMTP: If you have Postfix running locally, use localhost:25
Gmail: Use smtp.gmail.com:587 with an app password
SendGrid: A free service for up to 100 emails/day — just generate an API key and configure it

The Alert Script: ta_alerts.py

Now let's look at the Python code that powers alerts. This script runs on a schedule (usually daily after market close), checks your entire watchlist, fires alerts for triggered signals, and manages cooldown logic.

# ta_alerts.py — OpenClaw Signal Alert System
# Monitors your watchlist and emails you when signal conditions trigger

import yaml
import yfinance as yf
import pandas_ta as ta
import smtplib
import time
import json
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta

# ── Load your config ──────────────────────────────────────────────
def load_config(path: str = "ta-config.yaml") -> dict:
    """Load configuration from YAML file."""
    with open(path) as f:
        return yaml.safe_load(f)

cfg     = load_config()
TICKERS = cfg["watchlist"]["tickers"]
SIGNALS = cfg["signals"]
ALERTS  = cfg["alerts"]

print(f"🦞 OpenClaw Alerts | Watching {len(TICKERS)} tickers")
print(f"   Mode: {'Daily Digest' if ALERTS.get('daily_digest') else 'Real-Time'}")
print(f"   Cooldown: {ALERTS['cooldown_hours']} hours")

# ── Cooldown tracker — prevents duplicate alerts ──────────────────
# Stored in a local file so it survives script restarts
COOLDOWN_FILE = ".alert_cooldowns.json"

def load_cooldowns() -> dict:
    """Load the cooldown record from disk."""
    if os.path.exists(COOLDOWN_FILE):
        with open(COOLDOWN_FILE) as f:
            return json.load(f)
    return {}

def save_cooldowns(cooldowns: dict) -> None:
    """Save cooldown record to disk."""
    with open(COOLDOWN_FILE, "w") as f:
        json.dump(cooldowns, f)

def is_on_cooldown(ticker: str, signal: str, cooldown_hours: int) -> bool:
    """
    Returns True if this ticker+signal was already alerted recently.
    Prevents the same signal from alerting multiple days in a row.
    """
    cooldowns = load_cooldowns()
    key = f"{ticker}_{signal}"
    if key not in cooldowns:
        return False
    last_alert = datetime.fromisoformat(cooldowns[key])
    return datetime.now() - last_alert < timedelta(hours=cooldown_hours)

def mark_alerted(ticker: str, signal: str) -> None:
    """Record the current time as the last alert for this ticker+signal."""
    cooldowns = load_cooldowns()
    cooldowns[f"{ticker}_{signal}"] = datetime.now().isoformat()
    save_cooldowns(cooldowns)

# ── Check one ticker for all signal conditions ────────────────────
def check_ticker(ticker: str) -> list:
    """
    Returns a list of triggered signals for this ticker.
    An empty list means nothing fired — no alert needed.
    """
    try:
        # Download 1 year of daily data
        df = yf.download(ticker, period="1y", interval="1d", progress=False)
        if df.empty or len(df) < 210:
            return []

        # Normalize column names
        df.columns = [c.lower() for c in df.columns]
        df = df[["open","high","low","close","volume"]].dropna()

        # Calculate technical indicators
        df["sma50"]  = ta.sma(df["close"], 50)
        df["sma200"] = ta.sma(df["close"], 200)
        df["rsi"]    = ta.rsi(df["close"], 14)

        macd = ta.macd(df["close"], 12, 26, 9)
        df["macd"]     = macd[[c for c in macd.columns if c.startswith("MACD_")][0]]
        df["macd_sig"] = macd[[c for c in macd.columns if c.startswith("MACDs_")][0]]

        bb = ta.bbands(df["close"], 20, 2)
        df["bb_bw"] = bb[[c for c in bb.columns if "BBB" in c][0]]
        df = df.dropna()

        if len(df) < 2:
            return []

        # Get current and previous bar
        cur  = df.iloc[-1]
        prev = df.iloc[-2]
        fired = []

        # ── RSI checks
        if SIGNALS.get("rsi_oversold") and cur["rsi"] < SIGNALS["rsi_oversold"]:
            fired.append(("RSI_OVERSOLD",
                f"RSI hit {cur['rsi']:.1f} — below your oversold threshold of {SIGNALS['rsi_oversold']}"))

        if SIGNALS.get("rsi_overbought") and cur["rsi"] > SIGNALS["rsi_overbought"]:
            fired.append(("RSI_OVERBOUGHT",
                f"RSI hit {cur['rsi']:.1f} — above your overbought threshold of {SIGNALS['rsi_overbought']}"))

        # ── Moving Average Crosses
        if SIGNALS.get("golden_cross"):
            if cur["sma50"] > cur["sma200"] and prev["sma50"] <= prev["sma200"]:
                fired.append(("GOLDEN_CROSS",
                    f"50-day SMA ({cur['sma50']:.2f}) just crossed above 200-day SMA ({cur['sma200']:.2f})"))

        if SIGNALS.get("death_cross"):
            if cur["sma50"] < cur["sma200"] and prev["sma50"] >= prev["sma200"]:
                fired.append(("DEATH_CROSS",
                    f"50-day SMA ({cur['sma50']:.2f}) just crossed below 200-day SMA ({cur['sma200']:.2f})"))

        # ── MACD Crossover
        if SIGNALS.get("macd_cross"):
            if cur["macd"] > cur["macd_sig"] and prev["macd"] <= prev["macd_sig"]:
                fired.append(("MACD_BULL_CROSS",
                    f"MACD line crossed above Signal line — momentum turning bullish"))

            if cur["macd"] < cur["macd_sig"] and prev["macd"] >= prev["macd_sig"]:
                fired.append(("MACD_BEAR_CROSS",
                    f"MACD line crossed below Signal line — momentum turning bearish"))

        # ── Bollinger Squeeze
        if SIGNALS.get("bb_squeeze"):
            if cur["bb_bw"] < df["bb_bw"].quantile(0.20):
                fired.append(("BB_SQUEEZE",
                    f"Bollinger bandwidth at historic low — volatility compression, watch for breakout"))

        # ── Filter out signals on cooldown
        filtered = []
        for signal_name, description in fired:
            if not is_on_cooldown(ticker, signal_name, ALERTS["cooldown_hours"]):
                filtered.append((signal_name, description, cur["close"]))
                mark_alerted(ticker, signal_name)

        return filtered

    except Exception as e:
        # Silently skip tickers with errors (delisted, no data, etc.)
        return []

# ── Send an email alert ───────────────────────────────────────────
def send_email(subject: str, body: str) -> None:
    """Send an email using SMTP settings from ta-config.yaml."""
    msg = MIMEMultipart()
    msg["Subject"] = subject
    msg["From"]    = f"OpenClaw Alerts <{ALERTS.get('smtp_from', 'openclaw@localhost')}>"
    msg["To"]      = ALERTS["email"]
    msg.attach(MIMEText(body, "plain"))

    # Connect to SMTP server and send
    with smtplib.SMTP(ALERTS.get("smtp_host", "localhost"),
                      ALERTS.get("smtp_port", 587)) as s:
        s.ehlo()
        if ALERTS.get("smtp_tls", False):
            s.starttls()
        if ALERTS.get("smtp_user"):
            s.login(ALERTS["smtp_user"], ALERTS["smtp_password"])
        s.send_message(msg)

    print(f"✓ Email sent: {subject}")

# ── Main run — check all tickers ─────────────────────────────────
all_signals = []

for ticker in TICKERS:
    signals = check_ticker(ticker)
    if signals:
        for sig_name, description, price in signals:
            print(f"  🚨 {ticker}: {sig_name}")
            all_signals.append({
                "ticker": ticker, "signal": sig_name,
                "description": description, "price": price,
                "time": datetime.now().strftime("%H:%M")
            })

            # Real-time mode: send immediately
            if not ALERTS.get("daily_digest"):
                send_email(
                    subject=f"[OpenClaw] {ticker}: {sig_name}",
                    body=(
                        f"🦞 OpenClaw TA Alert\n"
                        f"{'─'*40}\n"
                        f"Ticker:  {ticker}\n"
                        f"Signal:  {sig_name}\n"
                        f"Price:   ${price:,.2f}\n"
                        f"Detail:  {description}\n"
                        f"Time:    {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
                        f"{'─'*40}\n"
                        f"Not financial advice. | learnopenclaw.org"
                    )
                )
    time.sleep(0.5)  # Rate limit to avoid overwhelming API

# ── Daily digest mode: send one summary ──────────────────────────
if ALERTS.get("daily_digest") and all_signals:
    lines = [
        f"🦞 OpenClaw TA Daily Digest — {datetime.now().strftime('%Y-%m-%d')}",
        f"{'─'*45}",
        f"{len(all_signals)} signal(s) across your watchlist:\n"
    ]

    for sig in all_signals:
        lines.append(f"  {sig['ticker']:<8} {sig['signal']:<20} @ ${sig['price']:,.2f}")
        lines.append(f"           {sig['description']}\n")

    lines += [
        f"{'─'*45}",
        "Not financial advice. | learnopenclaw.org"
    ]

    send_email(
        subject=f"[OpenClaw] Daily TA Digest — {len(all_signals)} signals — {datetime.now().strftime('%b %d')}",
        body="\n".join(lines)
    )
elif not all_signals:
    print("✓ No signals today — no email sent")

What This Script Does, Line by Line

Load Config
The script reads ta-config.yaml, extracting your tickers, signal thresholds, email address, and SMTP settings. Everything you need is right there.
Cooldown Tracker
A local JSON file (.alert_cooldowns.json) remembers when each signal last fired. Before sending an alert, the script checks: "Did I already alert this ticker + signal combo in the last 24 hours?" If yes, skip it.
Check Ticker
For each ticker in your watchlist:
1. Download 1 year of daily data using yfinance
2. Calculate all technical indicators (SMA, RSI, MACD, Bollinger Bands)
3. Compare current bar to previous bar to detect crossovers and extremes
4. Return a list of triggered signals (empty if nothing fired)
Send Email
In real-time mode, each fired signal gets its own email. In daily digest mode, all signals from the day are collected into a single email and sent after market close.

What a Real Alert Email Looks Like

Here's an example of an individual real-time alert (in daily digest mode, you'd get all of these compiled into one email):

Notice the structure: ticker, signal type, current price, and a plain-English description of what triggered. You get everything you need to decide what to do next.

Scheduling Alerts to Run Automatically

The script doesn't need to run manually. Set up a cron job (on Linux/Mac) or a scheduled task (on Windows) to run it on a regular schedule. Most traders run it once per day, after market close.

Linux / Mac (Crontab)

# Run every day at 4:15pm Mon–Fri (after US market close)
# Add this line to your crontab: crontab -e
15 16 * * 1-5 cd /path/to/openclaw && python3 ta_alerts.py >> alerts.log 2>&1
Cron Breakdown
15 16 = 4:15 PM in 24-hour time
* * 1-5 = Every day, Monday through Friday (1=Monday, 5=Friday)
>> alerts.log 2>&1 = Save output to a log file for debugging

Windows (Task Scheduler)

Open Task Scheduler, create a new task, set the trigger to "Daily" at 4:15 PM, and set the action to run python3 ta_alerts.py in your OpenClaw directory.

Once this is set up, alerts run on their own every market day. You'll only hear from OpenClaw when something in your watchlist actually triggers a signal. No babysitting required.

Alert Types: Quick Reference

Here's a summary of every signal type OpenClaw can alert on:

Signal When It Fires What It Means
RSI_OVERSOLD RSI drops below your threshold (default: 30) Stock has been selling off hard — potential bounce zone
RSI_OVERBOUGHT RSI rises above your threshold (default: 70) Stock has been rallying hard — extended, watch for pause
GOLDEN_CROSS 50-day SMA crosses above 200-day SMA Long-term trend may be turning bullish
DEATH_CROSS 50-day SMA crosses below 200-day SMA Long-term trend may be turning bearish
MACD_BULL_CROSS MACD line crosses above Signal line Short-term momentum shifting upward
MACD_BEAR_CROSS MACD line crosses below Signal line Short-term momentum shifting downward
BB_SQUEEZE Bollinger bandwidth at 20th percentile low Volatility compressing — expect a breakout soon