File size: 11,956 Bytes
8a59cab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
"""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']})")