File size: 8,163 Bytes
b684a94
e0cbbe9
 
b684a94
 
 
 
 
 
c36abda
e0cbbe9
 
 
 
b684a94
e0cbbe9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b684a94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0cbbe9
 
b684a94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0cbbe9
b684a94
 
e0cbbe9
 
 
b684a94
e0cbbe9
b684a94
e0cbbe9
a566369
b684a94
e0cbbe9
 
 
 
 
b684a94
e0cbbe9
b684a94
 
e0cbbe9
 
 
b684a94
e0cbbe9
b684a94
e0cbbe9
 
 
 
b684a94
 
 
 
 
 
 
 
 
 
 
485727f
b684a94
 
 
 
 
 
485727f
b684a94
 
 
e0cbbe9
485727f
b684a94
 
 
 
 
 
 
3bede3b
c36abda
b684a94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e0cbbe9
b684a94
 
 
 
 
 
 
 
485727f
 
b684a94
 
 
 
a566369
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
from datetime import datetime, timedelta
from functools import lru_cache
import time

import gradio as gr
import numpy as np
import pandas as pd
import yfinance as yf

APP_VERSION = "0.1.1"
RETRY_ATTEMPTS = 3
RETRY_DELAY_SECONDS = 1.5
option_chain_cache: dict[tuple[str, str], tuple[object, str]] = {}
price_cache: dict[str, tuple[float, str]] = {}


@lru_cache(maxsize=128)
def get_ticker(symb: str) -> yf.Ticker:
    return yf.Ticker(symb)


@lru_cache(maxsize=128)
def get_expirations(symb: str) -> tuple[str, ...]:
    return tuple(get_ticker(symb).options)


def get_option_chain_with_retry(ticker: yf.Ticker, target_time: str):
    last_error = None
    for attempt in range(RETRY_ATTEMPTS):
        try:
            return ticker.option_chain(target_time)
        except Exception as exc:
            last_error = exc
            if attempt < RETRY_ATTEMPTS - 1:
                time.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
    raise last_error


def get_option_chain(symb: str, target_time: str, force_refresh: bool = False):
    cache_key = (symb, target_time)
    if not force_refresh and cache_key in option_chain_cache:
        return option_chain_cache[cache_key]

    ticker = get_ticker(symb)
    opt_chain = get_option_chain_with_retry(ticker, target_time)
    downloaded_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    option_chain_cache[cache_key] = (opt_chain, downloaded_at)
    return opt_chain, downloaded_at


def get_current_price(symb: str, force_refresh: bool = False) -> tuple[float, str]:
    if not force_refresh and symb in price_cache:
        return price_cache[symb]

    ticker = get_ticker(symb)
    current_price = ticker.fast_info["lastPrice"]
    downloaded_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    price_cache[symb] = (current_price, downloaded_at)
    return current_price, downloaded_at


def conf_int_ind(
    symb: str, target_time: str | None = None, force_refresh: bool = False
) -> tuple[pd.DataFrame, list[str]]:
    ticker = get_ticker(symb)
    expirations = get_expirations(symb)
    if not expirations:
        raise ValueError(f"No option expirations found for {symb}.")

    df_time = pd.to_datetime(expirations)
    if target_time is None:
        target_time_dt = df_time[0]
    else:
        target_time_dt = pd.to_datetime(target_time)
        valid_times = df_time[df_time <= target_time_dt]
        if valid_times.empty:
            raise ValueError(
                f"No expiration on or before {target_time_dt.strftime('%Y-%m-%d')} for {symb}."
            )
        target_time_dt = valid_times[-1]

    target_time_r = target_time_dt.strftime("%Y-%m-%d")
    time_now = datetime.now().strftime("%Y-%m-%d")
    t_days = (pd.to_datetime(target_time_r) - pd.to_datetime(time_now)).days + 1

    opt_chain, option_downloaded_at = get_option_chain(symb, target_time_r, force_refresh)
    current_price, price_downloaded_at = get_current_price(symb, force_refresh)

    lop = opt_chain.puts.strike[~opt_chain.puts.inTheMoney].iloc[-1]
    hip = opt_chain.puts.strike[opt_chain.puts.inTheMoney].iloc[0]
    iv1p = opt_chain.puts.loc[opt_chain.puts.strike == lop, "impliedVolatility"].item()
    iv2p = opt_chain.puts.loc[opt_chain.puts.strike == hip, "impliedVolatility"].item()
    ivp = (iv1p + iv2p) / 2
    sdp = ivp * np.sqrt(t_days / 365)

    loca = opt_chain.calls.strike[opt_chain.calls.inTheMoney].iloc[-1]
    hica = opt_chain.calls.strike[~opt_chain.calls.inTheMoney].iloc[0]
    iv1c = opt_chain.calls.loc[opt_chain.calls.strike == loca, "impliedVolatility"].item()
    iv2c = opt_chain.calls.loc[opt_chain.calls.strike == hica, "impliedVolatility"].item()
    ivc = (iv1c + iv2c) / 2
    sdc = ivc * np.sqrt(t_days / 365)

    row = {
        "symbol": [ticker.info["symbol"]],
        "days": [f"{target_time_r}: ({t_days}) days"],
        "-2.5%(p)": [current_price * (1 - 2 * sdp)],
        "-6.5%(p)": [current_price * (1 - 1.5 * sdp)],
        "-16.5%(p)": [current_price * (1 - 1 * sdp)],
        "current": [current_price],
        "+16.5%(c)": [current_price * (1 + 1 * sdc)],
        "+6.5%(c)": [current_price * (1 + 1.5 * sdc)],
        "+2.5%(c)": [current_price * (1 + 2 * sdc)],
    }

    return pd.DataFrame(row).set_index("symbol"), [option_downloaded_at, price_downloaded_at]


def conf_int_duo(
    symb: str, target_time: str | None = None, force_refresh: bool = False
) -> tuple[pd.DataFrame, list[str]]:
    if target_time is None:
        expirations = get_expirations(symb)
        df_time = pd.to_datetime(expirations)
        recent_mask = (df_time - datetime.now() <= timedelta(days=14)) & (df_time >= datetime.now())
        dlist = list(df_time[recent_mask].strftime("%Y-%m-%d"))
        if not dlist:
            return conf_int_ind(symb, None, force_refresh)
        results = [conf_int_ind(symb, d, force_refresh) for d in dlist]
        frames = [result[0] for result in results]
        timestamps = [stamp for result in results for stamp in result[1]]
        return pd.concat(frames), timestamps

    return conf_int_ind(symb, target_time, force_refresh)


def conf_int(
    symblist: str | list[str], target_time: str | None = None, force_refresh: bool = False
) -> tuple[pd.DataFrame, list[str]]:
    if isinstance(symblist, str):
        return conf_int_duo(symblist, target_time, force_refresh)
    if isinstance(symblist, list):
        results = [conf_int_duo(symb, target_time, force_refresh) for symb in symblist]
        frames = [result[0] for result in results]
        timestamps = [stamp for result in results for stamp in result[1]]
        return pd.concat(frames), timestamps
    raise TypeError("symblist must be a string or a list of strings.")


def parse_symbols(symbs_text: str) -> list[str]:
    symbs = [item.strip().upper() for item in symbs_text.replace("\n", ",").split(",")]
    symbs = [item for item in symbs if item]
    if not symbs:
        raise gr.Error("Provide at least one symbol.")
    return symbs


def run_app(symbs_text: str, disable_target_time: bool, target_time: str):
    symbs = parse_symbols(symbs_text)
    resolved_target_time = None if disable_target_time else (target_time or None)
    if not disable_target_time and resolved_target_time is None:
        raise gr.Error("Provide target_time in YYYY-MM-DD format, or disable it.")

    try:
        df, download_timestamps = conf_int(symbs, resolved_target_time, True)
    except Exception as exc:
        raise gr.Error(str(exc)) from exc

    latest_download = max(download_timestamps) if download_timestamps else "Unknown"
    return df.reset_index(), f"Last data download: {latest_download}"


def toggle_target_time(disable_target_time: bool):
    return gr.update(interactive=not disable_target_time, value="" if disable_target_time else None)


with gr.Blocks(title="Options Confidence Interval") as demo:
    gr.Markdown("## Options Confidence Interval")
    gr.Markdown(f"Version: `{APP_VERSION}`")
    gr.Markdown("Enter one or more symbols. Use a comma or a new line to separate multiple symbols.")

    with gr.Row():
        symbs_input = gr.Textbox(
            label="symbs",
            lines=3,
            value="SOXL, SPY",
            placeholder="SOXL, SPY",
        )
        with gr.Column():
            disable_target_time_input = gr.Checkbox(
                label="Disable target_time to include all expiration dates within 14 days",
                value=True,
            )
            target_time_input = gr.Textbox(
                label="target_time",
                placeholder="YYYY-MM-DD",
                interactive=False,
            )

    submit_btn = gr.Button("Run")
    output_df = gr.Dataframe(label="Result")
    refresh_timestamp = gr.Textbox(label="Data download timestamp", interactive=False)

    disable_target_time_input.change(
        toggle_target_time,
        inputs=disable_target_time_input,
        outputs=target_time_input,
    )
    submit_btn.click(
        run_app,
        inputs=[symbs_input, disable_target_time_input, target_time_input],
        outputs=[output_df, refresh_timestamp],
    )


if __name__ == "__main__":
    demo.launch(ssr_mode=False, share=True)