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:
What Each Field Means
- EPS Estimate — The consensus EPS projection from all covering analysts, plus the range (low–high) and the number of forecasts. A tight range means analysts agree. A wide range means uncertainty — and often a bigger move.
- Beat Rate — How often this company has beaten EPS consensus historically. "7/8 = 88%" means it beat in 7 of its last 8 reports. High beat rates are often priced in by the market, but still useful context.
- Avg EPS Surprise — The average percentage the company has beaten or missed by. +4.2% means they beat by an average of 4.2% of the estimate.
- Avg Price Move — How much the stock has moved overnight after past reports (prior close to next open). The split (3 up, 1 down) shows the direction bias. Useful for options sizing.
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:
Knows which tickers on your watchlist report today and tomorrow. This is what determines who appears in the brief.
Pulls current EPS and revenue consensus from yfinance — including analyst count and the low-to-high range.
Reads earnings_history.csv to calculate beat rate and average surprise percentage for each ticker.
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
- Install dependencies:
pip install yfinance pyyaml pandas - Create the data directory:
mkdir -p data - Save
earnings-config.yamlandearnings_brief.pyin the same folder - Test manually:
python earnings_brief.py— you should see the brief printed to the terminal - Open your crontab:
crontab -eand paste the line above - 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:
3-day advance warning before any ticker on your watchlist reports.
Daily consensus and analyst revision tracking. Spot estimate changes before the street does.
Historical beat rate database. Know which companies consistently beat estimates.
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 →