134 lines
4.9 KiB
Python
134 lines
4.9 KiB
Python
"""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 7–30 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: 7–14 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)
|