alphaforge-quant-system / earnings_model.py
Premchan369's picture
Add earnings model with calendar detection, implied move, post-earnings drift analysis
8a59cab verified
"""Earnings Model v1.0 β€” Earnings Event Intelligence
Detects earnings proximity, estimates implied move from options,
analyzes post-earnings drift patterns, and adjusts position sizing.
Based on: Ball & Brown (1968), Frazzini & Lamont (2007) PEAD
"""
import yfinance as yf
import numpy as np
import pandas as pd
from datetime import datetime, timedelta
from typing import Dict, Optional, Tuple
# Typical quarterly earnings windows by month (approximate)
EARNINGS_CALENDAR = {
# Q1 (Jan-Mar): reports Apr-May
'Q1': {'months': [4, 5], 'label': 'Q1'},
# Q2 (Apr-Jun): reports Jul-Aug
'Q2': {'months': [7, 8], 'label': 'Q2'},
# Q3 (Jul-Sep): reports Oct-Nov
'Q3': {'months': [10, 11], 'label': 'Q3'},
# Q4 (Oct-Dec): reports Jan-Feb
'Q4': {'months': [1, 2], 'label': 'Q4'},
}
# Sector-specific typical implied moves (based on historical options data)
SECTOR_IMPLIED_MOVES = {
'Technology': 0.045,
'Healthcare': 0.040,
'Financials': 0.030,
'Energy': 0.055,
'Consumer Discretionary': 0.050,
'Consumer Staples': 0.025,
'Industrials': 0.035,
'Communication': 0.045,
'Utilities': 0.020,
'Materials': 0.045,
'Real Estate': 0.030,
'default': 0.040,
}
class EarningsModel:
"""Earnings event intelligence for quant trading."""
def __init__(self):
self._cache = {}
def estimate_earnings_date(self, ticker: str) -> Optional[datetime.date]:
"""Estimate next earnings date from yfinance calendar."""
try:
t = yf.Ticker(ticker)
cal = t.calendar
if cal is not None and not cal.empty:
# yfinance calendar returns next earnings date
if hasattr(cal, 'index') and 'Earnings Date' in cal.index:
date_str = cal.loc['Earnings Date'].values[0]
if isinstance(date_str, str):
return datetime.strptime(date_str, '%Y-%m-%d').date()
elif 'Earnings Date' in cal.columns:
date_str = cal['Earnings Date'].iloc[0]
if isinstance(date_str, str):
return datetime.strptime(date_str, '%Y-%m-%d').date()
# Fallback: estimate from current month
today = datetime.now()
for q, data in EARNINGS_CALENDAR.items():
if today.month in data['months']:
# Next likely report window: mid-month
return datetime(today.year, today.month, 15).date()
return None
except Exception:
return None
def days_to_earnings(self, ticker: str) -> Tuple[Optional[int], Optional[datetime.date]]:
"""Return (days_until, date)."""
ed = self.estimate_earnings_date(ticker)
if ed is None:
return None, None
today = datetime.now().date()
delta = (ed - today).days
return delta, ed
def implied_move(self, ticker: str, default_move: float = None) -> Dict:
"""Estimate implied earnings move from options chain if available.
Fallback to sector average or default.
"""
move = default_move or 0.04 # 4% default
source = 'default'
try:
t = yf.Ticker(ticker)
# Try to get implied move from near-the-money straddle
# Find the closest expiration before/after estimated earnings
expiry = t.options
if expiry and len(expiry) > 0:
# Get first expiration
opt = t.option_chain(expiry[0])
calls = opt.calls
puts = opt.puts
if len(calls) > 0 and len(puts) > 0:
# ATM strike
spot = calls['strike'].iloc[len(calls)//2]
atm_call = calls[calls['strike'].sub(spot).abs().idxmin()]
atm_put = puts[puts['strike'].sub(spot).abs().idxmin()]
# Straddle price
call_p = atm_call['lastPrice'] if 'lastPrice' in atm_call else atm_call['ask']
put_p = atm_put['lastPrice'] if 'lastPrice' in atm_put else atm_put['ask']
straddle = float(call_p) + float(put_p)
# Implied move as % of stock price
current_price = yf.Ticker(ticker).history(period='1d')['Close'].iloc[-1]
move = straddle / current_price
source = 'options_chain'
# Annualize: if 1 month until expiry
days = 30 # approximate
move_annual = move * np.sqrt(365 / max(1, days))
return {
'implied_move_pct': round(move * 100, 2),
'annualized_pct': round(move_annual * 100, 2),
'straddle_price': round(float(straddle), 2),
'source': source,
'expiry': expiry[0],
'atm_strike': float(spot),
}
except Exception as e:
pass
# Try sector-based
try:
info = yf.Ticker(ticker).info
sector = info.get('sector', '')
for sector_name, sector_move in SECTOR_IMPLIED_MOVES.items():
if sector_name.lower() in sector.lower():
move = sector_move
source = f'sector_{sector_name}'
break
except:
pass
return {
'implied_move_pct': round(move * 100, 2),
'annualized_pct': round(move * np.sqrt(12) * 100, 2),
'source': source,
}
def historical_pead(self, ticker: str, lookback_years: int = 3) -> Dict:
"""Post-Earnings Announcement Drift analysis.
Measures how much stock drifts in direction of surprise after earnings.
Returns drift score: positive = bullish drift tendency, negative = bearish.
"""
try:
# Fetch enough history
df = yf.Ticker(ticker).history(period=f"{lookback_years}y")
if len(df) < 100:
return {'drift_score': 0, 'confidence': 0, 'n_events': 0}
# Detect earnings dates (volume spikes on quarterly frequency)
df['volume_z'] = (df['Volume'] - df['Volume'].rolling(20).mean()) / df['Volume'].rolling(20).std()
# Find volume spike days
spike_days = df[df['volume_z'] > 2.5].index
if len(spike_days) < 4:
return {'drift_score': 0, 'confidence': 0, 'n_events': 0}
# Analyze returns post-spike
drifts = []
for spike in spike_days:
try:
idx = df.index.get_loc(spike)
if idx + 5 >= len(df): continue
# Day +1 to +5 return
post_ret = (df['Close'].iloc[idx+5] / df['Close'].iloc[idx]) - 1
# Day 0 overnight surprise (gap)
overnight_gap = (df['Open'].iloc[idx] / df['Close'].iloc[idx-1]) - 1 if idx > 0 else 0
# PEAD: continuation of surprise
drift = np.sign(overnight_gap) * post_ret
drifts.append(drift)
except:
continue
if len(drifts) < 3:
return {'drift_score': 0, 'confidence': 0, 'n_events': len(spike_days)}
drift_score = np.mean(drifts)
confidence = min(1.0, len(drifts) / 12) # More events = more confidence
return {
'drift_score': round(float(drift_score), 4),
'confidence': round(float(confidence), 2),
'n_events': len(drifts),
'avg_overnight_gap': round(float(np.mean([d for d in drifts])), 4) if drifts else 0,
'interpretation': (
'Positive PEAD: earnings surprises tend to continue' if drift_score > 0.02 else
'Negative PEAD: post-earnings reversals typical' if drift_score < -0.02 else
'Weak PEAD pattern'
),
}
except Exception:
return {'drift_score': 0, 'confidence': 0, 'n_events': 0}
def earnings_position_size(self, base_size: float, days_to_earnings: int,
implied_move: float = 0.04) -> Dict:
"""Adjust position size for earnings proximity.
Strategy:
- D-30 to D-7: Full size (can position for earnings)
- D-7 to D-3: Reduce to 50% (avoid theta decay, lock profits)
- D-3 to D-1: Reduce to 25% (extreme event risk)
- D-Day: 0% (do not hold into earnings)
- D+1 to D+5: Can re-enter at 50% (capture PEAD)
"""
if days_to_earnings is None:
return {
'adjusted_size': base_size,
'reduction': 0.0,
'strategy': 'No earnings date detected β€” normal sizing',
}
if days_to_earnings > 7:
adj = base_size
strategy = 'Pre-earnings positioning window β€” full size'
elif days_to_earnings > 3:
adj = base_size * 0.5
strategy = 'Reduce to 50% β€” earnings week risk building'
elif days_to_earnings > 0:
adj = base_size * 0.25
strategy = 'Reduce to 25% β€” imminent earnings, extreme theta'
elif days_to_earnings == 0:
adj = 0.0
strategy = 'DO NOT HOLD INTO EARNINGS β€” day-of closure'
elif days_to_earnings >= -1:
adj = base_size * 0.5
strategy = 'Post-earnings PEAD window β€” 50% re-entry'
elif days_to_earnings >= -5:
adj = base_size * 0.5
strategy = 'PEAD continuation β€” 50% size'
else:
adj = base_size
strategy = 'Post-earnings quiet period β€” normal sizing'
# Adjust for implied move magnitude
if implied_move > 0.08: # > 8% expected move
adj *= 0.7
strategy += ' | High implied move (>8%), further reduced'
elif implied_move > 0.06:
adj *= 0.85
strategy += ' | Elevated implied move, slight reduction'
return {
'days_to_earnings': days_to_earnings,
'implied_move_pct': round(implied_move * 100, 2),
'base_size': round(base_size, 4),
'adjusted_size': round(adj, 4),
'reduction': round(1 - (adj / (base_size + 1e-10)), 2),
'strategy': strategy,
}
def full_analysis(self, ticker: str, base_size: float = 1.0) -> Dict:
"""Complete earnings intelligence for a ticker."""
days, ed = self.days_to_earnings(ticker)
implied = self.implied_move(ticker)
pead = self.historical_pead(ticker)
sizing = self.earnings_position_size(
base_size, days,
implied.get('implied_move_pct', 4) / 100
)
return {
'ticker': ticker,
'estimated_earnings_date': ed.strftime('%Y-%m-%d') if ed else 'unknown',
'days_to_earnings': days,
'implied_move': implied,
'pead_analysis': pead,
'position_sizing': sizing,
'recommendation': sizing['strategy'],
}
if __name__ == '__main__':
model = EarningsModel()
result = model.full_analysis('AAPL', base_size=1.0)
print(f"Estimated Earnings: {result['estimated_earnings_date']}")
print(f"Days Until: {result['days_to_earnings']}")
print(f"Implied Move: {result['implied_move'].get('implied_move_pct', 'N/A')}%")
print(f"PEAD Score: {result['pead_analysis'].get('drift_score', 0):.4f}")
print(f"Position Sizing: {result['position_sizing']['adjusted_size']*100:.0f}% ({result['position_sizing']['strategy']})")