Skip to content
Earnings Automation · Part 5 of 5

Pre-Market Earnings Brief

One email before the open on every earnings day. OpenClaw pulls together the calendar, estimates, historical beat rate, and average price reaction into a single automated brief — so you're prepared before the market opens.

Daily Email Beat Rate EPS Consensus Avg Move Cron Scheduler
⚠️ Earnings data is for research monitoring only. Not financial advice.

What the Brief Delivers

The earnings brief is the capstone of Parts 1–4. It's a single pre-market email that shows you everything you need to know about earnings happening today and in the next few days. Here's what a real output looks like:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📋 OPENCLAW EARNINGS BRIEF — Wednesday Apr 30 2026 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ TODAY'S REPORTS (your watchlist) ──────────────────────────────────────── MSFT → Reports TODAY (after close) EPS Estimate: $3.22 (range: $3.10–$3.38, 38 analysts) Beat Rate: 7/8 = 88% Avg EPS Surprise: +4.2% Avg Price Move: ±4.1% (3 up, 1 down) REPORTING TOMORROW ──────────────────────────────────────── AAPL → Reports TOMORROW (after close, May 1) EPS Estimate: $1.61 Beat Rate: 8/8 = 100% Avg Price Move: ±3.0% (5 up, 1 down) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ OpenClaw · learnopenclaw.org · Not financial advice ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

What Each Field Means

Timing note: The brief is sent at 6:30 AM, three hours before the 9:30 AM open. That gives you time to review your positions, look up the relevant filings or transcripts, and decide if you want to adjust anything before the report.

How It Combines All 5 Parts

The earnings brief is the payoff of the entire pipeline. Every field in the brief comes from one of the four earlier scripts:

Part 1: Calendar

Knows which tickers on your watchlist report today and tomorrow. This is what determines who appears in the brief.

Part 2: Estimates

Pulls current EPS and revenue consensus from yfinance — including analyst count and the low-to-high range.

Part 3: Surprise Tracker

Reads earnings_history.csv to calculate beat rate and average surprise percentage for each ticker.

Part 4: Price Reaction

Reads price_reactions.csv to calculate average move magnitude and the up/down split.

When the brief script runs at 6:30 AM, it fetches today's date, checks which tickers report soon, pulls live estimates, and looks up historical context from the CSVs. All in a few seconds — no manual work.

Full Configuration

Save this as earnings-config.yaml in your OpenClaw working directory. This single file drives all 5 scripts in the series:

watchlist:
  tickers: [AAPL, MSFT, NVDA, AMZN, GOOGL]

calendar:
  lookahead_days: 5
  alert_days_before: 3
  market_hours_only: false   # include pre/after-market reports

estimates:
  track_revisions: true
  revision_window_days: 30
  flag_revision_pct: 5.0
  sources: ["yfinance"]

surprise:
  log_history: true
  flag_threshold: 5.0
  csv_path: "data/earnings_history.csv"
  include_revenue_surprise: true

reaction:
  capture_window: "open_next_day"
  log_to_csv: true
  csv_path: "data/price_reactions.csv"
  compare_implied_move: true

alerts:
  email: "you@example.com"
  daily_digest: true
  send_time: "06:30"

scheduler:
  earnings_brief:
    script: earnings_brief.py
    schedule: "30 6 * * 1-5"

Update tickers with your watchlist and email with your address. Everything else works out of the box.

The Complete Script

Save this as earnings_brief.py. It reads the config, checks upcoming earnings, pulls live estimates, loads your historical CSVs, and formats the brief. The email-sending code is included but commented out — uncomment and configure once you've tested the output.

"""
earnings_brief.py — OpenClaw Pre-Market Earnings Brief

The capstone script. Runs every weekday at 6:30 AM.
Checks for earnings today/tomorrow on your watchlist,
pulls live estimates from yfinance, loads historical
beat rate + price reaction data, and sends the brief.

Requires:
  data/earnings_history.csv   (built by earnings_surprise.py)
  data/price_reactions.csv    (built by earnings_reaction.py)
  earnings-config.yaml
"""

import yaml
import yfinance as yf
import pandas as pd
from datetime import datetime, timedelta
import smtplib
from email.mime.text import MIMEText
import os

# ── Load config ────────────────────────────────────────────────────────────
with open("earnings-config.yaml") as f:
    cfg = yaml.safe_load(f)

tickers      = cfg["watchlist"]["tickers"]
alert_window = cfg["calendar"]["alert_days_before"]
alert_email  = cfg["alerts"]["email"]

today = datetime.today().date()

# ── Load historical data (built by earlier scripts) ────────────────────────
def load_csv(path):
    """Load a CSV if it exists, otherwise return empty DataFrame."""
    if os.path.exists(path):
        return pd.read_csv(path)
    return pd.DataFrame()

surprise_df = load_csv(cfg["surprise"]["csv_path"])
reaction_df = load_csv(cfg["reaction"]["csv_path"])

# ── Helper: historical beat rate ──────────────────────────────────────────
def get_beat_rate(ticker):
    """Calculate beat rate and avg surprise from surprise CSV."""
    if surprise_df.empty:
        return "No history yet", None
    rows = surprise_df[surprise_df["Ticker"] == ticker]
    if rows.empty:
        return "No history yet", None
    beats = (rows["Result"] == "BEAT").sum()
    total = len(rows)
    rate  = round(beats / total * 100) if total > 0 else 0
    avg   = round(rows["Surprise %"].mean(), 1)
    return f"{beats}/{total} = {rate}%", avg

# ── Helper: average price move ────────────────────────────────────────────
def get_avg_move(ticker):
    """Calculate avg overnight move from reaction CSV."""
    if reaction_df.empty:
        return None, None, None
    rows = reaction_df[reaction_df["Ticker"] == ticker]
    if rows.empty:
        return None, None, None
    avg_move = round(rows["Move %"].abs().mean(), 1)
    up_ct    = (rows["Direction"] == "UP").sum()
    dn_ct    = (rows["Direction"] == "DOWN").sum()
    return avg_move, up_ct, dn_ct

# ── Check upcoming earnings ────────────────────────────────────────────────
today_reports = []
soon_reports  = []

print(f"\nChecking {len(tickers)} tickers for upcoming earnings...\n")

for ticker in tickers:
    try:
        stock = yf.Ticker(ticker)
        cal   = stock.earnings_dates
        if cal is None or cal.empty:
            continue

        upcoming = cal[cal.index.date >= today].head(1)
        if upcoming.empty:
            continue

        report_date = upcoming.index[0].date()
        days_away   = (report_date - today).days

        if days_away > alert_window:
            continue  # outside alert window

        # Fetch live estimates
        est      = stock.earnings_estimate
        eps_est  = eps_low = eps_high = analysts = "N/A"
        if est is not None and not est.empty:
            row      = est.iloc[0]
            eps_est  = row.get("avg",              "N/A")
            eps_low  = row.get("low",              "N/A")
            eps_high = row.get("high",             "N/A")
            analysts = row.get("numberOfAnalysts", "N/A")

        beat_str, avg_surprise = get_beat_rate(ticker)
        avg_move, up_ct, dn_ct = get_avg_move(ticker)

        entry = {
            "ticker":       ticker,
            "date":         report_date,
            "days_away":    days_away,
            "eps_est":      eps_est,
            "eps_low":      eps_low,
            "eps_high":     eps_high,
            "analysts":     analysts,
            "beat_str":     beat_str,
            "avg_surprise": avg_surprise,
            "avg_move":     avg_move,
            "up_ct":        up_ct,
            "dn_ct":        dn_ct,
        }

        if days_away == 0:
            today_reports.append(entry)
        else:
            soon_reports.append(entry)

    except Exception as e:
        print(f"  {ticker}: skipped ({e})")

# ── Format the brief ──────────────────────────────────────────────────────
SEP = "=" * 52

def format_entry(e):
    timing = "TODAY (after close)" if e["days_away"] == 0 \
             else f"in {e['days_away']} day(s)  —  {e['date']}"
    lines = [
        f"  {e['ticker']}  →  Reports {timing}",
        f"     EPS Estimate:     ${e['eps_est']}  "
        f"(range: ${e['eps_low']}–${e['eps_high']}, {e['analysts']} analysts)",
        f"     Beat Rate:        {e['beat_str']}",
    ]
    if e["avg_surprise"] is not None:
        lines.append(f"     Avg EPS Surprise: +{e['avg_surprise']}%")
    if e["avg_move"] is not None:
        lines.append(
            f"     Avg Price Move:   ±{e['avg_move']}%  "
            f"({e['up_ct']} up, {e['dn_ct']} down)"
        )
    return "\n".join(lines)

brief  = f"{SEP}\n"
brief += f"  📋 OPENCLAW EARNINGS BRIEF — {today.strftime('%A %b %d %Y')}\n"
brief += f"{SEP}\n\n"

if today_reports:
    brief += "  TODAY'S REPORTS (your watchlist)\n"
    brief += "  " + "─" * 40 + "\n"
    for e in today_reports:
        brief += format_entry(e) + "\n\n"

if soon_reports:
    brief += "  REPORTING SOON\n"
    brief += "  " + "─" * 40 + "\n"
    for e in soon_reports:
        brief += format_entry(e) + "\n\n"

if not today_reports and not soon_reports:
    brief += "  ✅ No earnings on your watchlist in the next few days.\n\n"

brief += f"{SEP}\n"
brief += "  OpenClaw · learnopenclaw.org · Not financial advice\n"
brief += f"{SEP}\n"

# ── Print to terminal ─────────────────────────────────────────────────────
print(brief)

# ── Send email ────────────────────────────────────────────────────────────
if today_reports or soon_reports:
    msg            = MIMEText(brief)
    msg["Subject"] = f"OpenClaw Earnings Brief — {today.strftime('%b %d')}"
    msg["From"]    = alert_email
    msg["To"]      = alert_email

    # Configure Gmail App Password and uncomment to enable:
    # with smtplib.SMTP("smtp.gmail.com", 587) as server:
    #     server.starttls()
    #     server.login("your@gmail.com", "your_app_password")
    #     server.send_message(msg)
    #     print(f"📧 Brief sent to {alert_email}")

    print(f"📧 Brief ready to send to: {alert_email}")
    print("   Uncomment the SMTP block above to enable email delivery.")

Setting Up the Daily Schedule

Add one line to your crontab and the brief arrives every weekday morning:

30 6 * * 1-5 cd /path/to/openclaw && python earnings_brief.py

Step-by-step setup

  1. Install dependencies: pip install yfinance pyyaml pandas
  2. Create the data directory: mkdir -p data
  3. Save earnings-config.yaml and earnings_brief.py in the same folder
  4. Test manually: python earnings_brief.py — you should see the brief printed to the terminal
  5. Open your crontab: crontab -e and paste the line above
  6. Verify it was added: crontab -l

That's it. The brief will run automatically every weekday at 6:30 AM. The first few weeks it will show estimates only — beat rate and average move sections fill in as Parts 3 and 4 accumulate data.

✨ Series Complete — What You've Built

You now have a production-grade earnings automation pipeline. Here's the full stack:

Part 1: Calendar Alerts

3-day advance warning before any ticker on your watchlist reports.

Part 2: Estimate Tracker

Daily consensus and analyst revision tracking. Spot estimate changes before the street does.

Part 3: Surprise Logger

Historical beat rate database. Know which companies consistently beat estimates.

Part 4: Price Reaction Logger

Average move magnitude dataset. Know how much a stock typically moves on earnings.

Part 5 ties all four together into one pre-market email — context, history, and expectations, fully automated.

Pairs well with these Terminal series

FAQ

How long until the brief is actually useful?

The calendar and estimates work from day one. The beat rate and average move sections need 2–3 months of running Parts 3 and 4 (roughly 8–12 earnings cycles per stock) to have meaningful data. After that, the historical context in the brief is genuinely useful.

Does this work for any stock?

Any stock covered by yfinance — which includes most US-listed stocks. It works best for large-caps with 20+ analyst estimates. Small-cap stocks may not have earnings date data or consensus estimates. Always test your ticker first: import yfinance as yf; print(yf.Ticker("TICK").earnings_dates)

Can I use Gmail to send the emails?

Yes. Gmail blocks regular password login in scripts, but supports App Passwords for automation. Enable 2-factor authentication on your Google account, then go to Security → App passwords and generate one for "Mail". Use that password in the SMTP block — not your regular Gmail password. Google's App Password guide →