TradingAgents/tradingagents/dataflows/discovery/scanners/earnings_beat.py

134 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Post-Earnings Announcement Drift (PEAD) scanner.
Surfaces stocks that recently reported significant EPS beats, capturing
the well-documented post-earnings drift effect: beaten stocks tend to
continue drifting upward for 730 days after the announcement.
Research basis: docs/iterations/research/2026-04-14-pead-earnings-beat.md
Key insight: PEAD edge is strongest for small-to-mid caps with >10% EPS
surprise (Bernard & Thomas 1989; QuantPedia 15% annualized, 1987-2004).
Hold window: 714 days (primary drift window; effect plateaus ~day 9).
"""
from datetime import datetime, timedelta
from typing import Any, Dict, List
from tradingagents.dataflows.discovery.scanner_registry import SCANNER_REGISTRY, BaseScanner
from tradingagents.dataflows.discovery.utils import Priority
from tradingagents.utils.logger import get_logger
logger = get_logger(__name__)
class EarningsBeatScanner(BaseScanner):
"""Scan for recent EPS beats to capture post-earnings drift (PEAD)."""
name = "earnings_beat"
pipeline = "events"
strategy = "pead_drift"
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
self.lookback_days = self.scanner_config.get("lookback_days", 14)
self.min_surprise_pct = self.scanner_config.get("min_surprise_pct", 5.0)
def scan(self, state: Dict[str, Any]) -> List[Dict[str, Any]]:
if not self.is_enabled():
return []
logger.info(
f"📈 Scanning earnings beats (past {self.lookback_days}d, "
f">={self.min_surprise_pct}% surprise)..."
)
try:
from tradingagents.dataflows.finnhub_api import get_earnings_calendar
to_date = datetime.now().strftime("%Y-%m-%d")
from_date = (datetime.now() - timedelta(days=self.lookback_days)).strftime("%Y-%m-%d")
earnings = get_earnings_calendar(
from_date=from_date,
to_date=to_date,
return_structured=True,
)
if not earnings:
logger.info("No recent earnings data found")
return []
today = datetime.now().date()
candidates = []
for event in earnings:
ticker = event.get("symbol", "").upper().strip()
if not ticker:
continue
eps_actual = event.get("epsActual")
eps_estimate = event.get("epsEstimate")
earnings_date_str = event.get("date", "")
# Need both actual and estimate to compute surprise
if eps_actual is None or eps_estimate is None:
continue
# Avoid division by zero; skip stub/loss estimates near zero
if eps_estimate == 0:
continue
surprise_pct = ((eps_actual - eps_estimate) / abs(eps_estimate)) * 100
if surprise_pct < self.min_surprise_pct:
continue
# Days since announcement
try:
earnings_date = datetime.strptime(earnings_date_str, "%Y-%m-%d").date()
days_ago = (today - earnings_date).days
except (ValueError, TypeError):
days_ago = None
# Priority by surprise magnitude
if surprise_pct >= 20:
priority = Priority.CRITICAL.value
elif surprise_pct >= 10:
priority = Priority.HIGH.value
else:
priority = Priority.MEDIUM.value
days_ago_str = f"{days_ago}d ago" if days_ago is not None else "recently"
context = (
f"Earnings beat {days_ago_str}: actual ${eps_actual:.2f} vs "
f"est ${eps_estimate:.2f} (+{surprise_pct:.1f}% surprise) "
f"— PEAD drift window open"
)
candidates.append(
{
"ticker": ticker,
"source": self.name,
"context": context,
"priority": priority,
"strategy": self.strategy,
"eps_surprise_pct": surprise_pct,
"eps_actual": eps_actual,
"eps_estimate": eps_estimate,
"days_since_earnings": days_ago,
}
)
# Sort by surprise magnitude (largest beats first)
candidates.sort(key=lambda x: x.get("eps_surprise_pct", 0), reverse=True)
candidates = candidates[: self.limit]
logger.info(f"Earnings beats (PEAD): {len(candidates)} candidates")
return candidates
except Exception as e:
logger.warning(f"⚠️ Earnings beat scanner failed: {e}")
return []
SCANNER_REGISTRY.register(EarningsBeatScanner)