Premchan369 commited on
Commit
34072a0
·
verified ·
1 Parent(s): 8a59cab

Add options flow analysis with put/call ratio, IV skew, unusual volume, max pain from real yfinance chains

Browse files
Files changed (1) hide show
  1. options_flow.py +420 -0
options_flow.py ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Options Flow v1.0 — Real Options Chain Intelligence
2
+ Analyzes put/call ratio, implied volatility skew, unusual volume,
3
+ open interest patterns, and max pain from yfinance options data.
4
+ Falls back to heuristic estimates when chain unavailable.
5
+ """
6
+ import yfinance as yf
7
+ import numpy as np
8
+ import pandas as pd
9
+ from datetime import datetime, timedelta
10
+ from typing import Dict, Optional, Tuple, List
11
+
12
+
13
+ class OptionsFlow:
14
+ """Options market microstructure intelligence for alpha generation."""
15
+
16
+ def __init__(self):
17
+ self._cache = {}
18
+
19
+ def _fetch_chain(self, ticker: str, days_out: int = 30) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame], Optional[str]]:
20
+ """Fetch options chain from yfinance. Returns (calls_df, puts_df, expiry_str).
21
+ Returns None if data unavailable.
22
+ """
23
+ cache_key = f"{ticker}_{days_out}"
24
+ if cache_key in self._cache:
25
+ return self._cache[cache_key]
26
+
27
+ try:
28
+ t = yf.Ticker(ticker)
29
+ expiries = t.options
30
+ if not expiries or len(expiries) == 0:
31
+ return None, None, None
32
+
33
+ # Find first expiration >= days_out
34
+ target = (datetime.now() + timedelta(days=days_out)).strftime('%Y-%m-%d')
35
+ selected = None
36
+ for e in expiries:
37
+ if e >= target:
38
+ selected = e
39
+ break
40
+ if selected is None:
41
+ selected = expiries[0]
42
+
43
+ chain = t.option_chain(selected)
44
+ calls = chain.calls
45
+ puts = chain.puts
46
+
47
+ self._cache[cache_key] = (calls, puts, selected)
48
+ return calls, puts, selected
49
+ except Exception as e:
50
+ return None, None, None
51
+
52
+ def put_call_ratio(self, ticker: str, days_out: int = 30,
53
+ oi_weighted: bool = True) -> Dict:
54
+ """Compute put/call ratio from options chain.
55
+
56
+ oi_weighted: Use open interest instead of volume for longer-term positioning.
57
+ """
58
+ calls, puts, expiry = self._fetch_chain(ticker, days_out)
59
+ if calls is None or puts is None:
60
+ return self._heuristic_pcr(ticker)
61
+
62
+ if oi_weighted:
63
+ call_val = calls['openInterest'].sum() if 'openInterest' in calls else calls['volume'].sum()
64
+ put_val = puts['openInterest'].sum() if 'openInterest' in puts else puts['volume'].sum()
65
+ else:
66
+ call_val = calls['volume'].sum()
67
+ put_val = puts['volume'].sum()
68
+
69
+ pcr = put_val / (call_val + 1e-10)
70
+
71
+ # Interpretation
72
+ sentiment = 'neutral'
73
+ if pcr < 0.5: sentiment = 'extreme_bullish'
74
+ elif pcr < 0.7: sentiment = 'bullish'
75
+ elif pcr > 1.5: sentiment = 'extreme_bearish'
76
+ elif pcr > 1.0: sentiment = 'bearish'
77
+
78
+ # Score 0-100: low PCR = bullish, high = bearish
79
+ score = max(0, min(100, 100 - pcr * 40))
80
+
81
+ return {
82
+ 'pcr': round(float(pcr), 3),
83
+ 'sentiment': sentiment,
84
+ 'score': round(float(score), 1),
85
+ 'call_value': int(call_val),
86
+ 'put_value': int(put_val),
87
+ 'expiry': expiry,
88
+ 'source': 'chain',
89
+ 'weighted_by': 'open_interest' if oi_weighted else 'volume',
90
+ }
91
+
92
+ def _heuristic_pcr(self, ticker: str) -> Dict:
93
+ """Estimate PCR when options chain unavailable."""
94
+ # Base on sector and recent price action
95
+ try:
96
+ df = yf.Ticker(ticker).history(period='1mo')
97
+ ret_20d = df['Close'].pct_change(20).iloc[-1]
98
+ # Rising stocks tend to have lower PCR (bullish)
99
+ base_pcr = 0.7 - ret_20d * 5 # Rough heuristic
100
+ base_pcr = max(0.3, min(2.0, base_pcr))
101
+
102
+ score = max(0, min(100, 100 - base_pcr * 40))
103
+ sentiment = 'neutral'
104
+ if base_pcr < 0.5: sentiment = 'bullish'
105
+ elif base_pcr > 1.0: sentiment = 'bearish'
106
+
107
+ return {
108
+ 'pcr': round(float(base_pcr), 3),
109
+ 'sentiment': sentiment,
110
+ 'score': round(float(score), 1),
111
+ 'source': 'heuristic',
112
+ 'note': 'Options chain unavailable — estimated from price action',
113
+ }
114
+ except:
115
+ return {
116
+ 'pcr': 0.70,
117
+ 'sentiment': 'neutral',
118
+ 'score': 50.0,
119
+ 'source': 'default',
120
+ 'note': 'Options data unavailable — default neutral',
121
+ }
122
+
123
+ def iv_skew(self, ticker: str, days_out: int = 30) -> Dict:
124
+ """Analyze implied volatility skew from options chain.
125
+
126
+ Steep put skew (puts expensive) = fear premium.
127
+ Flat skew = complacency.
128
+ Reverse skew (calls expensive) = extreme bullishness.
129
+ """
130
+ calls, puts, expiry = self._fetch_chain(ticker, days_out)
131
+ if calls is None or puts is None:
132
+ return {
133
+ 'skew': None,
134
+ 'score': 50.0,
135
+ 'interpretation': 'No options data available',
136
+ 'source': 'unavailable',
137
+ }
138
+
139
+ try:
140
+ # Get current price for ATM reference
141
+ price = yf.Ticker(ticker).history(period='1d')['Close'].iloc[-1]
142
+
143
+ # Find ATM options
144
+ atm_call = calls.iloc[(calls['strike'] - price).abs().argsort()[:1]]
145
+ atm_put = puts.iloc[(puts['strike'] - price).abs().argsort()[:1]]
146
+
147
+ # Find 5% OTM put and call
148
+ otm_put_strike = price * 0.95
149
+ otm_put = puts[puts['strike'] <= otm_put_strike].iloc[-1:] if len(puts[puts['strike'] <= otm_put_strike]) > 0 else puts.iloc[:1]
150
+
151
+ otm_call_strike = price * 1.05
152
+ otm_call = calls[calls['strike'] >= otm_call_strike].iloc[:1] if len(calls[calls['strike'] >= otm_call_strike]) > 0 else calls.iloc[-1:]
153
+
154
+ # Calculate skew
155
+ atm_iv = float(atm_call['impliedVolatility'].iloc[0]) if 'impliedVolatility' in atm_call else 0.30
156
+ otm_put_iv = float(otm_put['impliedVolatility'].iloc[0]) if 'impliedVolatility' in otm_put else atm_iv
157
+ otm_call_iv = float(otm_call['impliedVolatility'].iloc[0]) if 'impliedVolatility' in otm_call else atm_iv
158
+
159
+ # Put skew = OTM put IV / ATM IV - 1
160
+ put_skew = (otm_put_iv / (atm_iv + 1e-10)) - 1
161
+ # Call skew = OTM call IV / ATM IV - 1
162
+ call_skew = (otm_call_iv / (atm_iv + 1e-10)) - 1
163
+
164
+ # Net skew: positive = puts expensive (fear), negative = calls expensive (greed)
165
+ net_skew = put_skew - call_skew
166
+
167
+ # Score: fear = bearish signal for longs, but can be contrarian
168
+ if net_skew > 0.3:
169
+ sentiment = 'extreme_fear'
170
+ score = 15 # Contrarian: everyone hedging = potential bottom
171
+ elif net_skew > 0.15:
172
+ sentiment = 'fear'
173
+ score = 30
174
+ elif net_skew > 0.05:
175
+ sentiment = 'mild_fear'
176
+ score = 45
177
+ elif net_skew < -0.15:
178
+ sentiment = 'extreme_greed'
179
+ score = 85
180
+ elif net_skew < -0.05:
181
+ sentiment = 'greed'
182
+ score = 70
183
+ else:
184
+ sentiment = 'neutral'
185
+ score = 50
186
+
187
+ return {
188
+ 'skew': round(float(net_skew), 4),
189
+ 'atm_iv': round(float(atm_iv), 4),
190
+ 'otm_put_iv': round(float(otm_put_iv), 4),
191
+ 'otm_call_iv': round(float(otm_call_iv), 4),
192
+ 'sentiment': sentiment,
193
+ 'score': float(score),
194
+ 'source': 'chain',
195
+ 'expiry': expiry,
196
+ }
197
+ except Exception:
198
+ return {
199
+ 'skew': None,
200
+ 'score': 50.0,
201
+ 'interpretation': 'Error computing skew',
202
+ 'source': 'error',
203
+ }
204
+
205
+ def unusual_volume(self, ticker: str, days_out: int = 30,
206
+ threshold_mult: float = 2.0) -> Dict:
207
+ """Detect unusual options volume vs historical average."""
208
+ calls, puts, expiry = self._fetch_chain(ticker, days_out)
209
+ if calls is None or puts is None:
210
+ return {
211
+ 'is_unusual': False,
212
+ 'score': 50.0,
213
+ 'source': 'unavailable',
214
+ }
215
+
216
+ try:
217
+ total_volume = calls['volume'].sum() + puts['volume'].sum()
218
+ # Estimate average volume from available expiries
219
+ t = yf.Ticker(ticker)
220
+ all_volumes = []
221
+ for e in t.options[:3]: # Check first 3 expiries
222
+ try:
223
+ chain = t.option_chain(e)
224
+ vol = chain.calls['volume'].sum() + chain.puts['volume'].sum()
225
+ all_volumes.append(vol)
226
+ except:
227
+ continue
228
+
229
+ avg_volume = np.mean(all_volumes) if all_volumes else total_volume
230
+ ratio = total_volume / (avg_volume + 1e-10)
231
+
232
+ is_unusual = ratio > threshold_mult
233
+ if ratio > 5: score = 90
234
+ elif ratio > 3: score = 75
235
+ elif ratio > 2: score = 60
236
+ elif ratio > 1.5: score = 55
237
+ else: score = 50
238
+
239
+ return {
240
+ 'is_unusual': bool(is_unusual),
241
+ 'volume_ratio': round(float(ratio), 2),
242
+ 'total_volume': int(total_volume),
243
+ 'avg_volume': int(avg_volume),
244
+ 'score': float(score),
245
+ 'source': 'chain',
246
+ 'expiry': expiry,
247
+ }
248
+ except Exception:
249
+ return {
250
+ 'is_unusual': False,
251
+ 'score': 50.0,
252
+ 'source': 'unavailable',
253
+ }
254
+
255
+ def max_pain(self, ticker: str, days_out: int = 30) -> Dict:
256
+ """Calculate options max pain — price where option holders lose most.
257
+ Tends to act as a magnet near expiration.
258
+ """
259
+ calls, puts, expiry = self._fetch_chain(ticker, days_out)
260
+ if calls is None or puts is None:
261
+ return {
262
+ 'max_pain': None,
263
+ 'score': 50.0,
264
+ 'source': 'unavailable',
265
+ }
266
+
267
+ try:
268
+ price = yf.Ticker(ticker).history(period='1d')['Close'].iloc[-1]
269
+
270
+ # Combine all strikes
271
+ all_strikes = sorted(set(calls['strike'].tolist() + puts['strike'].tolist()))
272
+
273
+ pain_values = []
274
+ for strike in all_strikes:
275
+ # Call pain = (strike - S) * OI for ITM calls
276
+ itm_calls = calls[calls['strike'] <= strike]
277
+ call_pain = ((strike - itm_calls['strike']) * itm_calls['openInterest']).sum()
278
+
279
+ # Put pain = (S - strike) * OI for ITM puts
280
+ itm_puts = puts[puts['strike'] >= strike]
281
+ put_pain = ((itm_puts['strike'] - strike) * itm_puts['openInterest']).sum()
282
+
283
+ total_pain = call_pain + put_pain
284
+ pain_values.append((strike, total_pain))
285
+
286
+ if not pain_values:
287
+ return {'max_pain': None, 'score': 50.0, 'source': 'unavailable'}
288
+
289
+ pain_df = pd.DataFrame(pain_values, columns=['strike', 'pain'])
290
+ max_pain_strike = pain_df.loc[pain_df['pain'].idxmin(), 'strike']
291
+
292
+ # Score based on distance to max pain
293
+ distance_pct = abs(price - max_pain_strike) / (price + 1e-10)
294
+ if distance_pct < 0.02:
295
+ score = 50 # Near max pain = balanced
296
+ elif price < max_pain_strike:
297
+ score = 60 # Below max pain = potential upside
298
+ else:
299
+ score = 40 # Above max pain = potential downside
300
+
301
+ return {
302
+ 'max_pain': round(float(max_pain_strike), 2),
303
+ 'current_price': round(float(price), 2),
304
+ 'distance_pct': round(float(distance_pct) * 100, 2),
305
+ 'score': float(score),
306
+ 'source': 'chain',
307
+ 'expiry': expiry,
308
+ }
309
+ except Exception:
310
+ return {
311
+ 'max_pain': None,
312
+ 'score': 50.0,
313
+ 'source': 'error',
314
+ }
315
+
316
+ def gamma_exposure(self, ticker: str, days_out: int = 30) -> Dict:
317
+ """Estimate aggregate gamma exposure from options chain.
318
+ Positive gamma = MM hedging stabilizes price.
319
+ Negative gamma = MM hedging amplifies moves.
320
+ """
321
+ calls, puts, expiry = self._fetch_chain(ticker, days_out)
322
+ if calls is None or puts is None:
323
+ return {
324
+ 'gamma_sign': 'unknown',
325
+ 'score': 50.0,
326
+ 'source': 'unavailable',
327
+ }
328
+
329
+ try:
330
+ price = yf.Ticker(ticker).history(period='1d')['Close'].iloc[-1]
331
+
332
+ # Simplified gamma estimate using gamma * OI * sign
333
+ # Positive gamma when calls OI > puts OI near ATM
334
+ atm_range = price * 0.05
335
+ near_calls = calls[abs(calls['strike'] - price) < atm_range]
336
+ near_puts = puts[abs(puts['strike'] - price) < atm_range]
337
+
338
+ call_oi = near_calls['openInterest'].sum() if 'openInterest' in near_calls else near_calls['volume'].sum()
339
+ put_oi = near_puts['openInterest'].sum() if 'openInterest' in near_puts else near_puts['volume'].sum()
340
+
341
+ # Net gamma: calls positive, puts negative for long holders
342
+ net = call_oi - put_oi
343
+ total = call_oi + put_oi + 1e-10
344
+ gamma_ratio = net / total
345
+
346
+ if gamma_ratio > 0.3:
347
+ gamma_sign = 'strong_positive'
348
+ score = 70 # Stabilizing
349
+ elif gamma_ratio > 0.1:
350
+ gamma_sign = 'positive'
351
+ score = 60
352
+ elif gamma_ratio < -0.3:
353
+ gamma_sign = 'strong_negative'
354
+ score = 30 # Destabilizing
355
+ elif gamma_ratio < -0.1:
356
+ gamma_sign = 'negative'
357
+ score = 40
358
+ else:
359
+ gamma_sign = 'neutral'
360
+ score = 50
361
+
362
+ return {
363
+ 'gamma_ratio': round(float(gamma_ratio), 3),
364
+ 'gamma_sign': gamma_sign,
365
+ 'score': float(score),
366
+ 'call_oi': int(call_oi),
367
+ 'put_oi': int(put_oi),
368
+ 'source': 'chain',
369
+ 'expiry': expiry,
370
+ }
371
+ except Exception:
372
+ return {
373
+ 'gamma_sign': 'unknown',
374
+ 'score': 50.0,
375
+ 'source': 'error',
376
+ }
377
+
378
+ def full_analysis(self, ticker: str) -> Dict:
379
+ """Complete options flow intelligence."""
380
+ pcr = self.put_call_ratio(ticker)
381
+ skew = self.iv_skew(ticker)
382
+ volume = self.unusual_volume(ticker)
383
+ pain = self.max_pain(ticker)
384
+ gamma = self.gamma_exposure(ticker)
385
+
386
+ # Composite options score
387
+ scores = [pcr.get('score', 50), skew.get('score', 50),
388
+ volume.get('score', 50), pain.get('score', 50),
389
+ gamma.get('score', 50)]
390
+ weights = [0.30, 0.25, 0.20, 0.15, 0.10]
391
+ composite = np.average(scores, weights=weights)
392
+
393
+ return {
394
+ 'ticker': ticker,
395
+ 'composite_score': round(float(composite), 1),
396
+ 'interpretation': (
397
+ 'Options flow strongly bullish' if composite > 75 else
398
+ 'Options flow bullish' if composite > 60 else
399
+ 'Options flow neutral' if composite > 40 else
400
+ 'Options flow bearish' if composite > 25 else
401
+ 'Options flow strongly bearish'
402
+ ),
403
+ 'put_call_ratio': pcr,
404
+ 'iv_skew': skew,
405
+ 'unusual_volume': volume,
406
+ 'max_pain': pain,
407
+ 'gamma': gamma,
408
+ 'timestamp': datetime.now().isoformat(),
409
+ }
410
+
411
+
412
+ if __name__ == '__main__':
413
+ flow = OptionsFlow()
414
+ result = flow.full_analysis('AAPL')
415
+ print(f"Options Composite: {result['composite_score']:.1f}/100")
416
+ print(f"Interpretation: {result['interpretation']}")
417
+ print(f"PCR: {result['put_call_ratio'].get('pcr', 'N/A')}")
418
+ print(f"Skew: {result['iv_skew'].get('skew', 'N/A')}")
419
+ print(f"Unusual Volume: {result['unusual_volume'].get('is_unusual', False)}")
420
+ print(f"Max Pain: {result['max_pain'].get('max_pain', 'N/A')}")