QuantumLearner commited on
Commit
fb57400
·
verified ·
1 Parent(s): 8d05fb4

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +460 -0
app.py ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import yfinance as yf
3
+ import numpy as np
4
+ import pandas as pd
5
+ from datetime import datetime
6
+ from scipy.optimize import brentq
7
+ from scipy.stats import norm, gaussian_kde
8
+ from scipy.interpolate import splrep, BSpline
9
+ from scipy.integrate import simps
10
+ import plotly.graph_objects as go
11
+ from plotly.subplots import make_subplots
12
+
13
+ st.set_page_config(layout="wide", page_title="Forward-Looking Probability")
14
+
15
+ st.markdown("# Forward-Looking Probability Distribution")
16
+ st.write(
17
+ "This application analyzes the implied probability distribution of a stock's future price using call option data. "
18
+ "It calculates implied volatilities via the Black-Scholes model, derives a risk-neutral probability density function using "
19
+ "the Breeden-Litzenberger formula, and then smooths the result with Kernel Density Estimation (KDE). "
20
+ "A unified strike grid is used for the 3D surface, while 2D analysis focuses on individual expiration dates."
21
+ )
22
+
23
+ with st.expander("How It Works", expanded=False):
24
+ st.write("The analysis is based on the Black-Scholes model for European call options:")
25
+ st.latex(r"C(S,K,T,r,\sigma)=S\Phi(d_1)-Ke^{-rT}\Phi(d_2)")
26
+ st.latex(r"d_1=\frac{\ln\left(\frac{S}{K}\right)+(r+0.5\sigma^2)T}{\sigma\sqrt{T}}")
27
+ st.latex(r"d_2=d_1-\sigma\sqrt{T}")
28
+ st.write("The risk-neutral probability density function (PDF) is derived using the Breeden-Litzenberger formula:")
29
+ st.latex(r"\text{PDF}(K)=e^{rT}\frac{\partial^2C}{\partial K^2}")
30
+ st.write("The resulting PDF is then smoothed using Kernel Density Estimation (KDE).")
31
+
32
+ # =============================================================================
33
+ # SIDEBAR - General Settings
34
+ # =============================================================================
35
+ with st.sidebar.expander("General Settings", expanded=True):
36
+ ticker_input = st.text_input("Ticker Symbol", value="NVDA")
37
+ lower_pct = st.number_input(
38
+ "Price Decrease Percentage", value=10, min_value=1, max_value=100, step=1,
39
+ help="For 2D plots: lower threshold = current price * (1 - percentage/100)"
40
+ )
41
+ upper_pct = st.number_input(
42
+ "Price Increase Percentage", value=10, min_value=1, max_value=100, step=1,
43
+ help="For 2D plots: upper threshold = current price * (1 + percentage/100)"
44
+ )
45
+
46
+ # =============================================================================
47
+ # SIDEBAR - Advanced Settings
48
+ # =============================================================================
49
+ with st.sidebar.expander("Advanced Settings", expanded=True):
50
+ risk_free = st.number_input(
51
+ "Risk-Free Rate", value=0.04, step=0.01, format="%.2f",
52
+ help="The annualized risk-free rate used in option pricing."
53
+ )
54
+ min_volume = st.number_input(
55
+ "Minimum Volume", value=20, step=1,
56
+ help="Minimum trading volume required for an option to be considered liquid."
57
+ )
58
+ max_spread_ratio = st.number_input(
59
+ "Max Spread Ratio", value=0.2, step=0.01, format="%.2f",
60
+ help="Maximum acceptable ratio of bid-ask spread to ask price. Options exceeding this will be excluded."
61
+ )
62
+
63
+ # =============================================================================
64
+ # Run Analysis Button (placed outside the expanders)
65
+ # =============================================================================
66
+ run_analysis = st.sidebar.button("Run Analysis")
67
+
68
+ # =============================================================================
69
+ # HELPER FUNCTIONS
70
+ # =============================================================================
71
+ def call_bs_price(S, K, T, r, sigma):
72
+ if T <= 0:
73
+ return max(S - K, 0)
74
+ d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
75
+ d2 = d1 - sigma * np.sqrt(T)
76
+ return S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
77
+
78
+ def implied_vol_call(price, S, K, T, r):
79
+ if T <= 0:
80
+ return np.nan
81
+ def f(iv):
82
+ return call_bs_price(S, K, T, r, iv) - price
83
+ try:
84
+ return brentq(f, 1e-9, 5.0)
85
+ except:
86
+ return np.nan
87
+
88
+ def build_pdf(K_grid, iv_spline_tck, S, T, r):
89
+ iv_vals = BSpline(*iv_spline_tck)(K_grid)
90
+ call_prices = [call_bs_price(S, K, T, r, iv) for K, iv in zip(K_grid, iv_vals)]
91
+ dC_dK = np.gradient(call_prices, K_grid)
92
+ d2C_dK2 = np.gradient(dC_dK, K_grid)
93
+ pdf_raw = np.exp(r * T) * d2C_dK2
94
+ return np.clip(pdf_raw, 0, None)
95
+
96
+ def build_cdf(K_grid, pdf_vals):
97
+ cdf_vals = []
98
+ running = 0.0
99
+ for i in range(len(K_grid)):
100
+ if i == 0:
101
+ cdf_vals.append(0.0)
102
+ else:
103
+ area = simps(pdf_vals[i-1:i+1], K_grid[i-1:i+1])
104
+ running += area
105
+ cdf_vals.append(running)
106
+ cdf_vals = np.array(cdf_vals)
107
+ if cdf_vals[-1] > 0:
108
+ cdf_vals /= cdf_vals[-1]
109
+ return cdf_vals
110
+
111
+ # Function to filter illiquid options
112
+ def filter_liquid_options(df, min_volume=20, max_spread_ratio=0.2):
113
+ spread = df["ask"] - df["bid"]
114
+ return df[(spread / df["ask"] < max_spread_ratio) & (df["bid"] > 0) & (df["volume"] >= min_volume)]
115
+
116
+ # =============================================================================
117
+ # 3D ANALYSIS FUNCTION (CALLS ONLY)
118
+ # =============================================================================
119
+ def compute_3d_pdf(data_ticker, current_price, r, min_volume, max_spread_ratio):
120
+ all_expirations = data_ticker.options
121
+ valid_expiries = []
122
+ days_list = []
123
+ calls_data_dict = {}
124
+
125
+ # First pass: collect calls data from valid expiries.
126
+ for exp_date in all_expirations:
127
+ try:
128
+ expiry_dt = datetime.strptime(exp_date, "%Y-%m-%d")
129
+ except:
130
+ continue
131
+ days_forward = (expiry_dt - datetime.now()).days
132
+ if days_forward < 1:
133
+ continue
134
+ try:
135
+ chain = data_ticker.option_chain(exp_date)
136
+ except Exception:
137
+ continue
138
+ calls_df = chain.calls[['strike', 'lastPrice', 'bid', 'ask', 'volume']].dropna().copy()
139
+ calls_df = filter_liquid_options(calls_df, min_volume, max_spread_ratio)
140
+ calls_df = calls_df[calls_df['lastPrice'] > 0].sort_values('strike')
141
+ if calls_df.empty:
142
+ continue
143
+ valid_expiries.append(exp_date)
144
+ days_list.append(days_forward)
145
+ calls_data_dict[exp_date] = calls_df
146
+
147
+ if not valid_expiries:
148
+ raise ValueError("No valid expiries with call data.")
149
+
150
+ K_grid_3d = np.linspace(current_price * 0.25, current_price * 3, 300)
151
+ pdf_list = []
152
+
153
+ # Second pass: compute smoothed PDF for each expiry.
154
+ for exp_date in valid_expiries:
155
+ expiry_dt = datetime.strptime(exp_date, "%Y-%m-%d")
156
+ T_val = (expiry_dt - datetime.now()).days / 365.0
157
+ calls_df = calls_data_dict[exp_date]
158
+ iv_vals = []
159
+ for _, row in calls_df.iterrows():
160
+ vol = implied_vol_call(row['lastPrice'], current_price, row['strike'], T_val, r)
161
+ iv_vals.append(vol)
162
+ calls_df['iv'] = iv_vals
163
+ calls_df.dropna(subset=['iv'], inplace=True)
164
+ if calls_df.empty:
165
+ pdf_list.append(np.zeros_like(K_grid_3d))
166
+ continue
167
+ strikes = calls_df['strike'].values
168
+ ivs = calls_df['iv'].values
169
+ try:
170
+ iv_spline_tck = splrep(strikes, ivs, s=10, k=3)
171
+ except Exception:
172
+ pdf_list.append(np.zeros_like(K_grid_3d))
173
+ continue
174
+ pdf_raw = build_pdf(K_grid_3d, iv_spline_tck, current_price, T_val, r)
175
+ try:
176
+ kde = gaussian_kde(K_grid_3d, weights=pdf_raw)
177
+ pdf_smooth = kde(K_grid_3d)
178
+ area = np.trapz(pdf_smooth, K_grid_3d)
179
+ if area > 0:
180
+ pdf_smooth /= area
181
+ except Exception:
182
+ pdf_smooth = pdf_raw
183
+ pdf_list.append(pdf_smooth)
184
+
185
+ pdf_matrix = np.array(pdf_list)
186
+ days_array = np.array(days_list)
187
+
188
+ TT, KK = np.meshgrid(days_array, K_grid_3d, indexing='ij')
189
+
190
+ fig = go.Figure(data=[go.Surface(
191
+ x=KK,
192
+ y=TT,
193
+ z=pdf_matrix,
194
+ colorscale='Viridis',
195
+ opacity=0.8
196
+ )])
197
+ fig.update_layout(
198
+ scene=dict(
199
+ xaxis_title='Strike',
200
+ yaxis_title='Days to Expiry',
201
+ zaxis_title='PDF'
202
+ ),
203
+ title="3D Smoothed Implied PDF Across Expiries",
204
+ width=900,
205
+ height=700
206
+ )
207
+ fig.add_annotation(
208
+ x=0.98, y=0.98, xref="paper", yref="paper",
209
+ text=f"Current Price: {current_price:.2f}",
210
+ showarrow=False,
211
+ align="right",
212
+ font=dict(size=12),
213
+ bordercolor="black",
214
+ borderwidth=1,
215
+ bgcolor="white",
216
+ opacity=0.8
217
+ )
218
+ return fig, valid_expiries
219
+
220
+ # =============================================================================
221
+ # 2D ANALYSIS FUNCTION (CALLS ONLY)
222
+ # =============================================================================
223
+ def compute_2d_pdf(exp_date, data_ticker, current_price, r, lower_pct, upper_pct, min_volume, max_spread_ratio):
224
+ try:
225
+ expiry_dt = datetime.strptime(exp_date, "%Y-%m-%d")
226
+ except:
227
+ return None
228
+ days_forward = (expiry_dt - datetime.now()).days
229
+ if days_forward < 1:
230
+ return None
231
+ T_val = days_forward / 365.0
232
+ try:
233
+ chain = data_ticker.option_chain(exp_date)
234
+ except:
235
+ return None
236
+ calls_df = chain.calls[['strike', 'lastPrice', 'bid', 'ask', 'volume']].dropna().copy()
237
+ calls_df = filter_liquid_options(calls_df, min_volume, max_spread_ratio)
238
+ calls_df = calls_df[calls_df['lastPrice'] > 0].sort_values('strike')
239
+ if calls_df.empty:
240
+ return None
241
+
242
+ iv_list = []
243
+ for _, row in calls_df.iterrows():
244
+ vol = implied_vol_call(row['lastPrice'], current_price, row['strike'], T_val, r)
245
+ iv_list.append(vol)
246
+ calls_df['iv'] = iv_list
247
+ calls_df.dropna(subset=['iv'], inplace=True)
248
+ if calls_df.empty:
249
+ return None
250
+
251
+ strikes = calls_df['strike'].values
252
+ ivs = calls_df['iv'].values
253
+ try:
254
+ iv_spline_tck = splrep(strikes, ivs, s=10, k=3)
255
+ except:
256
+ return None
257
+
258
+ K_min = strikes.min()
259
+ K_max = strikes.max()
260
+ K_grid_2d = np.linspace(K_min, K_max, 300)
261
+ pdf_raw = build_pdf(K_grid_2d, iv_spline_tck, current_price, T_val, r)
262
+ try:
263
+ kde = gaussian_kde(K_grid_2d, weights=pdf_raw)
264
+ pdf_smooth = kde(K_grid_2d)
265
+ area = np.trapz(pdf_smooth, K_grid_2d)
266
+ if area > 0:
267
+ pdf_smooth /= area
268
+ except:
269
+ pdf_smooth = pdf_raw
270
+
271
+ cdf = build_cdf(K_grid_2d, pdf_smooth)
272
+
273
+ lower_thresh = current_price * (1 - lower_pct / 100)
274
+ upper_thresh = current_price * (1 + upper_pct / 100)
275
+ mask_below = K_grid_2d < lower_thresh
276
+ mask_between = (K_grid_2d >= lower_thresh) & (K_grid_2d <= upper_thresh)
277
+ mask_above = K_grid_2d > upper_thresh
278
+ p_below = np.trapz(pdf_smooth[mask_below], K_grid_2d[mask_below])
279
+ p_between = np.trapz(pdf_smooth[mask_between], K_grid_2d[mask_between])
280
+ p_above = np.trapz(pdf_smooth[mask_above], K_grid_2d[mask_above])
281
+
282
+ fig_pdf_cdf = make_subplots(rows=1, cols=2, subplot_titles=("Smoothed PDF", "Smoothed CDF"))
283
+ fig_pdf_cdf.add_trace(go.Scatter(
284
+ x=K_grid_2d, y=pdf_smooth, mode='lines', name='PDF', line=dict(color='blue')
285
+ ), row=1, col=1)
286
+ fig_pdf_cdf.add_vline(x=current_price, line=dict(color='red', dash='dash'), row=1, col=1)
287
+ fig_pdf_cdf.update_xaxes(title_text="Strike", row=1, col=1)
288
+ fig_pdf_cdf.update_yaxes(title_text="PDF", row=1, col=1)
289
+ fig_pdf_cdf.add_trace(go.Scatter(
290
+ x=K_grid_2d, y=cdf, mode='lines', name='CDF', line=dict(color='blue')
291
+ ), row=1, col=2)
292
+ fig_pdf_cdf.add_vline(x=current_price, line=dict(color='red', dash='dash'), row=1, col=2)
293
+ fig_pdf_cdf.update_xaxes(title_text="Strike", row=1, col=2)
294
+ fig_pdf_cdf.update_yaxes(title_text="CDF", row=1, col=2)
295
+ fig_pdf_cdf.update_layout(title_text="2D Analysis: PDF and CDF")
296
+ fig_pdf_cdf.add_annotation(
297
+ x=0.98, y=0.98, xref="paper", yref="paper",
298
+ text=f"Current Price: {current_price:.2f}",
299
+ showarrow=False,
300
+ align="right",
301
+ font=dict(size=12),
302
+ bordercolor="white",
303
+ borderwidth=1,
304
+ opacity=0.8
305
+ )
306
+
307
+ fig_threshold = go.Figure()
308
+ fig_threshold.add_trace(go.Scatter(
309
+ x=K_grid_2d, y=pdf_smooth, mode='lines', name='PDF', line=dict(color='blue')
310
+ ))
311
+ fig_threshold.add_vline(
312
+ x=lower_thresh,
313
+ line=dict(color='orange', dash='dash'),
314
+ annotation_text=f'Lower: {lower_thresh:.2f}',
315
+ annotation_position="top left",
316
+ annotation_xshift=10,
317
+ annotation_yshift=-10
318
+ )
319
+ fig_threshold.add_vline(
320
+ x=upper_thresh,
321
+ line=dict(color='purple', dash='dash'),
322
+ annotation_text=f'Upper: {upper_thresh:.2f}',
323
+ annotation_position="top right",
324
+ annotation_xshift=-10,
325
+ annotation_yshift=-10
326
+ )
327
+ fig_threshold.add_vline(
328
+ x=current_price,
329
+ line=dict(color='red', dash='dash'),
330
+ annotation_text=f'Current: {current_price:.2f}',
331
+ annotation_position="bottom right",
332
+ annotation_xshift=-10,
333
+ annotation_yshift=10
334
+ )
335
+ fig_threshold.add_trace(go.Scatter(
336
+ x=K_grid_2d[mask_below], y=pdf_smooth[mask_below], mode='lines',
337
+ fill='tozeroy', line=dict(color='lightblue'), showlegend=False
338
+ ))
339
+ fig_threshold.add_trace(go.Scatter(
340
+ x=K_grid_2d[mask_between], y=pdf_smooth[mask_between], mode='lines',
341
+ fill='tozeroy', line=dict(color='lightgrey'), showlegend=False
342
+ ))
343
+ fig_threshold.add_trace(go.Scatter(
344
+ x=K_grid_2d[mask_above], y=pdf_smooth[mask_above], mode='lines',
345
+ fill='tozeroy', line=dict(color='lightcoral'), showlegend=False
346
+ ))
347
+ fig_threshold.update_layout(
348
+ title="Threshold Probability Plot",
349
+ xaxis_title="Strike",
350
+ yaxis_title="PDF"
351
+ )
352
+ annotation_text = (
353
+ f"Probability below {lower_thresh:.2f} is {p_below:.2%}<br>"
354
+ f"Probability between {lower_thresh:.2f} and {upper_thresh:.2f} is {p_between:.2%}<br>"
355
+ f"Probability above {upper_thresh:.2f} is {p_above:.2%}"
356
+ )
357
+ fig_threshold.add_annotation(
358
+ x=0.75, y=0.5, xref="paper", yref="paper",
359
+ text=annotation_text,
360
+ showarrow=False,
361
+ align="left",
362
+ font=dict(size=12),
363
+ bordercolor="white",
364
+ borderwidth=1,
365
+ opacity=0.8
366
+ )
367
+ fig_threshold.add_annotation(
368
+ x=0.98, y=0.98, xref="paper", yref="paper",
369
+ text=f"Current Price: {current_price:.2f}",
370
+ showarrow=False,
371
+ align="right",
372
+ font=dict(size=12),
373
+ bordercolor="black",
374
+ borderwidth=1,
375
+ bgcolor="white",
376
+ opacity=0.8
377
+ )
378
+
379
+ result = {
380
+ "K_grid_2d": K_grid_2d,
381
+ "pdf_smooth": pdf_smooth,
382
+ "cdf": cdf,
383
+ "lower_thresh": lower_thresh,
384
+ "upper_thresh": upper_thresh,
385
+ "p_below": p_below,
386
+ "p_between": p_between,
387
+ "p_above": p_above,
388
+ "fig_pdf_cdf": fig_pdf_cdf,
389
+ "fig_threshold": fig_threshold,
390
+ "days_to_exp": days_forward
391
+ }
392
+ return result
393
+
394
+ # =============================================================================
395
+ # MAIN RUN (only run when the button is clicked)
396
+ # =============================================================================
397
+ if run_analysis:
398
+ with st.spinner("Running analysis, please wait..."):
399
+ try:
400
+ data_ticker = yf.Ticker(ticker_input)
401
+ hist_data = data_ticker.history(period="1d")
402
+ if hist_data.empty:
403
+ st.error("No price data found.")
404
+ st.stop()
405
+ current_price = hist_data["Close"].iloc[-1]
406
+ except Exception as e:
407
+ st.error(f"Error fetching data: {e}")
408
+ st.stop()
409
+
410
+ st.write(f"Current Price: {round(current_price, 2)}")
411
+ r = risk_free
412
+
413
+ try:
414
+ fig3d, valid_expiries_3d = compute_3d_pdf(data_ticker, current_price, r, min_volume, max_spread_ratio)
415
+ except Exception as e:
416
+ st.error(f"3D analysis error: {e}")
417
+ st.stop()
418
+
419
+ results_2d = {}
420
+ for exp_date in data_ticker.options:
421
+ res = compute_2d_pdf(exp_date, data_ticker, current_price, r, lower_pct, upper_pct, min_volume, max_spread_ratio)
422
+ if res is not None:
423
+ results_2d[exp_date] = res
424
+
425
+ if not results_2d:
426
+ st.error("No valid expirations for 2D analysis.")
427
+ st.stop()
428
+
429
+ st.session_state.analysis_data = {
430
+ "current_price": current_price,
431
+ "expirations": list(results_2d.keys()),
432
+ "results": results_2d,
433
+ "fig3d": fig3d
434
+ }
435
+
436
+ # =============================================================================
437
+ # DISPLAY RESULTS (if analysis data exists)
438
+ # =============================================================================
439
+ if "analysis_data" in st.session_state:
440
+ ad = st.session_state.analysis_data
441
+ st.write(f"**Current Price:** {round(ad['current_price'], 2)}")
442
+ st.markdown("## 3D Probability Surface")
443
+ st.plotly_chart(ad["fig3d"], use_container_width=True)
444
+ st.markdown("## 2D Plots for Selected Expiration Date")
445
+ chosen = st.selectbox("Choose expiration date:", options=ad["expirations"])
446
+ res2d = ad["results"][chosen]
447
+ st.plotly_chart(res2d["fig_pdf_cdf"], use_container_width=True)
448
+ st.plotly_chart(res2d["fig_threshold"], use_container_width=True)
449
+ st.write("The 2D plots use calls data only. The 3D surface uses a unified strike grid.")
450
+ else:
451
+ st.info("Click 'Run Analysis' to start.")
452
+
453
+
454
+ hide_streamlit_style = """
455
+ <style>
456
+ #MainMenu {visibility: hidden;}
457
+ footer {visibility: hidden;}
458
+ </style>
459
+ """
460
+ st.markdown(hide_streamlit_style, unsafe_allow_html=True)