grynn-fplot 0.3.4__tar.gz → 0.3.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/PKG-INFO +20 -9
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/README.md +19 -8
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/cli.py +7 -3
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/core.py +121 -98
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/pyproject.toml +1 -1
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_options.py +10 -7
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/uv.lock +1 -1
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.devcontainer/devcontainer.json +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.github/copilot-instructions.md +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.github/dependabot.yml +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.github/workflows/publish.yml +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.gitignore +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.pre-commit-config.yaml +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.secrets.baseline +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.vscode/settings.json +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/Makefile +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/docs/FILTER_DESIGN.md +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/docs/FILTER_EXAMPLES.sh +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/docs/PERFORMANCE_OPTIMIZATION.md +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/docs/WEB_REFACTOR.md +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/example_usage.sh +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/__init__.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/drawdowns.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/filter_parser.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/index.html +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/plot_option_interactive.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/serve.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/web_api.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_candlestick.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_cli_integration.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_cli_options.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_filter_evaluation.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_filter_parser.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_fplot.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_input_parsing.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_leverage.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_web_api.py +0 -0
- {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_web_interface.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: grynn-fplot
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: CLI tool for plotting financial data and analyzing options
|
|
5
5
|
Project-URL: Homepage, https://github.com/Grynn/grynn_cli_fplot
|
|
6
6
|
Project-URL: Issues, https://github.com/Grynn/grynn_cli_fplot/issues
|
|
@@ -107,16 +107,27 @@ Examples:
|
|
|
107
107
|
- `fplot AAPL --call --filter "dte>10, dte<50"` # 10-50 days to expiry
|
|
108
108
|
- `fplot AAPL --call --filter "dte>1y"` # Options with 1+ year to expiry
|
|
109
109
|
|
|
110
|
-
The
|
|
110
|
+
The output format differs for calls and puts:
|
|
111
|
+
|
|
112
|
+
**Calls** include leverage and efficiency (useful for LEAPs):
|
|
111
113
|
```
|
|
112
114
|
AAPL 225C 35DTE ($5.25, 18.5%, 19.0x, eff:45)
|
|
113
115
|
AAPL 230C 35DTE ($3.10, 25.2%, 32.3x, eff:78)
|
|
114
|
-
AAPL 235C 35DTE ($1.85, 35.1%, 54.1x, eff:92)
|
|
115
116
|
```
|
|
117
|
+
Format: `TICKER STRIKE_C DTE (price, CAGR, leverage, eff:percentile)`
|
|
118
|
+
|
|
119
|
+
**Puts** show price and annualized return:
|
|
120
|
+
```
|
|
121
|
+
AAPL 220P 35DTE ($2.15, 24.8%)
|
|
122
|
+
AAPL 215P 35DTE ($1.40, 15.3%)
|
|
123
|
+
```
|
|
124
|
+
Format: `TICKER STRIKE_P DTE (price, AR)`
|
|
125
|
+
|
|
126
|
+
**Return metrics:**
|
|
127
|
+
- Calls: CAGR to breakeven
|
|
128
|
+
- Puts: AR = premium / capital-at-risk, annualized (capital-at-risk = strike - premium)
|
|
116
129
|
|
|
117
|
-
|
|
118
|
-
- For calls: return_metric is CAGR to breakeven
|
|
119
|
-
- For puts: return_metric is annualized return
|
|
130
|
+
**Leverage & Efficiency (calls only):**
|
|
120
131
|
- Leverage: Implied leverage (Ω = Δ × S/O) where Δ is Black-Scholes delta, S is spot price, O is option price
|
|
121
132
|
- Delta calculated using actual implied volatility from Yahoo Finance
|
|
122
133
|
- Shows "N/A" if implied volatility is not available
|
|
@@ -151,11 +162,11 @@ The `--filter` option supports complex filter expressions with logical operators
|
|
|
151
162
|
- `dte`: Days to expiry
|
|
152
163
|
- `volume`: Option volume
|
|
153
164
|
- `price`: Last price
|
|
154
|
-
- `return`, `ret`, `ar`: Return metric (CAGR for calls,
|
|
165
|
+
- `return`, `ret`, `ar`: Return metric (CAGR for calls, AR for puts) - all aliases work
|
|
155
166
|
- `strike_pct`, `sp`: Strike percentage above/below spot (positive = above spot, negative = below spot)
|
|
156
167
|
- `lt_days`: Days since last trade (useful for filtering stale options)
|
|
157
|
-
- `leverage`, `lev`: Implied leverage (Ω = Δ × S/O, using Black-Scholes delta)
|
|
158
|
-
- `efficiency`, `eff`: Efficiency percentile (leverage/CAGR ratio, 0-100 scale)
|
|
168
|
+
- `leverage`, `lev`: Implied leverage, calls only (Ω = Δ × S/O, using Black-Scholes delta)
|
|
169
|
+
- `efficiency`, `eff`: Efficiency percentile, calls only (leverage/CAGR ratio, 0-100 scale)
|
|
159
170
|
|
|
160
171
|
- **Examples:**
|
|
161
172
|
- `--filter "dte>300"` - Options with more than 300 days to expiry
|
|
@@ -68,16 +68,27 @@ Examples:
|
|
|
68
68
|
- `fplot AAPL --call --filter "dte>10, dte<50"` # 10-50 days to expiry
|
|
69
69
|
- `fplot AAPL --call --filter "dte>1y"` # Options with 1+ year to expiry
|
|
70
70
|
|
|
71
|
-
The
|
|
71
|
+
The output format differs for calls and puts:
|
|
72
|
+
|
|
73
|
+
**Calls** include leverage and efficiency (useful for LEAPs):
|
|
72
74
|
```
|
|
73
75
|
AAPL 225C 35DTE ($5.25, 18.5%, 19.0x, eff:45)
|
|
74
76
|
AAPL 230C 35DTE ($3.10, 25.2%, 32.3x, eff:78)
|
|
75
|
-
AAPL 235C 35DTE ($1.85, 35.1%, 54.1x, eff:92)
|
|
76
77
|
```
|
|
78
|
+
Format: `TICKER STRIKE_C DTE (price, CAGR, leverage, eff:percentile)`
|
|
79
|
+
|
|
80
|
+
**Puts** show price and annualized return:
|
|
81
|
+
```
|
|
82
|
+
AAPL 220P 35DTE ($2.15, 24.8%)
|
|
83
|
+
AAPL 215P 35DTE ($1.40, 15.3%)
|
|
84
|
+
```
|
|
85
|
+
Format: `TICKER STRIKE_P DTE (price, AR)`
|
|
86
|
+
|
|
87
|
+
**Return metrics:**
|
|
88
|
+
- Calls: CAGR to breakeven
|
|
89
|
+
- Puts: AR = premium / capital-at-risk, annualized (capital-at-risk = strike - premium)
|
|
77
90
|
|
|
78
|
-
|
|
79
|
-
- For calls: return_metric is CAGR to breakeven
|
|
80
|
-
- For puts: return_metric is annualized return
|
|
91
|
+
**Leverage & Efficiency (calls only):**
|
|
81
92
|
- Leverage: Implied leverage (Ω = Δ × S/O) where Δ is Black-Scholes delta, S is spot price, O is option price
|
|
82
93
|
- Delta calculated using actual implied volatility from Yahoo Finance
|
|
83
94
|
- Shows "N/A" if implied volatility is not available
|
|
@@ -112,11 +123,11 @@ The `--filter` option supports complex filter expressions with logical operators
|
|
|
112
123
|
- `dte`: Days to expiry
|
|
113
124
|
- `volume`: Option volume
|
|
114
125
|
- `price`: Last price
|
|
115
|
-
- `return`, `ret`, `ar`: Return metric (CAGR for calls,
|
|
126
|
+
- `return`, `ret`, `ar`: Return metric (CAGR for calls, AR for puts) - all aliases work
|
|
116
127
|
- `strike_pct`, `sp`: Strike percentage above/below spot (positive = above spot, negative = below spot)
|
|
117
128
|
- `lt_days`: Days since last trade (useful for filtering stale options)
|
|
118
|
-
- `leverage`, `lev`: Implied leverage (Ω = Δ × S/O, using Black-Scholes delta)
|
|
119
|
-
- `efficiency`, `eff`: Efficiency percentile (leverage/CAGR ratio, 0-100 scale)
|
|
129
|
+
- `leverage`, `lev`: Implied leverage, calls only (Ω = Δ × S/O, using Black-Scholes delta)
|
|
130
|
+
- `efficiency`, `eff`: Efficiency percentile, calls only (leverage/CAGR ratio, 0-100 scale)
|
|
120
131
|
|
|
121
132
|
- **Examples:**
|
|
122
133
|
- `--filter "dte>300"` - Options with more than 300 days to expiry
|
|
@@ -82,9 +82,13 @@ def display_plot(
|
|
|
82
82
|
When --call or --put flags are used, lists available options contracts
|
|
83
83
|
in a format suitable for filtering with tools like fzf.
|
|
84
84
|
|
|
85
|
-
Output format:
|
|
86
|
-
-
|
|
87
|
-
-
|
|
85
|
+
Output format:
|
|
86
|
+
- Calls: TICKER STRIKE_C DTE (price, CAGR, leverage, eff:percentile)
|
|
87
|
+
- Puts: TICKER STRIKE_P DTE (price, AR)
|
|
88
|
+
|
|
89
|
+
Return metrics:
|
|
90
|
+
- Calls: CAGR to breakeven
|
|
91
|
+
- Puts: AR = premium / capital-at-risk, annualized (capital-at-risk = strike - premium)
|
|
88
92
|
|
|
89
93
|
Examples:
|
|
90
94
|
\b
|
|
@@ -16,7 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
def parse_ticker_input(ticker_input: Union[str, List[str]]) -> List[str]:
|
|
18
18
|
"""Parse ticker input supporting multiple formats.
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
Supports:
|
|
21
21
|
- Single ticker: "AAPL"
|
|
22
22
|
- Comma-separated: "AAPL,TSLA" or "AAPL, TSLA"
|
|
@@ -24,41 +24,41 @@ def parse_ticker_input(ticker_input: Union[str, List[str]]) -> List[str]:
|
|
|
24
24
|
- Division operations: "AAPL/XLK"
|
|
25
25
|
- Mixed: ["AAPL", "AAPL/XLK", "TW.L"]
|
|
26
26
|
- Quoted strings with spaces/commas: "ABC, DEF" (preserved as single token by shell)
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
Args:
|
|
29
29
|
ticker_input: Either a single string or list of strings from CLI arguments
|
|
30
|
-
|
|
30
|
+
|
|
31
31
|
Returns:
|
|
32
32
|
List of ticker symbols or expressions (e.g., ["AAPL", "TSLA", "AAPL/XLK"])
|
|
33
33
|
"""
|
|
34
34
|
if ticker_input is None:
|
|
35
35
|
return []
|
|
36
|
-
|
|
36
|
+
|
|
37
37
|
# If it's already a list (from multiple CLI arguments), process each element
|
|
38
38
|
if isinstance(ticker_input, list):
|
|
39
39
|
tickers = []
|
|
40
40
|
for item in ticker_input:
|
|
41
41
|
# Each item might still contain commas or be a complex expression
|
|
42
|
-
if
|
|
42
|
+
if "," in item:
|
|
43
43
|
# Split by comma and add each part
|
|
44
|
-
tickers.extend([t.strip() for t in item.split(
|
|
44
|
+
tickers.extend([t.strip() for t in item.split(",") if t.strip()])
|
|
45
45
|
else:
|
|
46
46
|
# Keep as-is (might be a division expression or simple ticker)
|
|
47
47
|
item = item.strip()
|
|
48
48
|
if item:
|
|
49
49
|
tickers.append(item)
|
|
50
50
|
return tickers
|
|
51
|
-
|
|
51
|
+
|
|
52
52
|
# If it's a single string, split by commas
|
|
53
53
|
if isinstance(ticker_input, str):
|
|
54
54
|
ticker_input = ticker_input.strip()
|
|
55
55
|
if not ticker_input:
|
|
56
56
|
return []
|
|
57
|
-
|
|
57
|
+
|
|
58
58
|
# Split by comma and strip whitespace
|
|
59
|
-
tickers = [t.strip() for t in ticker_input.split(
|
|
59
|
+
tickers = [t.strip() for t in ticker_input.split(",") if t.strip()]
|
|
60
60
|
return tickers
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
return []
|
|
63
63
|
|
|
64
64
|
|
|
@@ -132,45 +132,45 @@ def parse_interval(interval="1d"):
|
|
|
132
132
|
|
|
133
133
|
def download_ohlcv_data(ticker, since, interval="1d"):
|
|
134
134
|
"""Download OHLCV (Open, High, Low, Close, Volume) data from Yahoo Finance
|
|
135
|
-
|
|
135
|
+
|
|
136
136
|
Args:
|
|
137
137
|
ticker: Single ticker symbol (e.g., "AAPL")
|
|
138
138
|
since: Start date (datetime object or None for max)
|
|
139
139
|
interval: Data interval (e.g., "1d", "1wk", "1mo")
|
|
140
|
-
|
|
140
|
+
|
|
141
141
|
Returns:
|
|
142
142
|
DataFrame with columns: Open, High, Low, Close, Volume
|
|
143
143
|
Index is DatetimeIndex
|
|
144
144
|
"""
|
|
145
145
|
if yfinance is None:
|
|
146
146
|
raise ImportError("yfinance package is required for ticker data functionality")
|
|
147
|
-
|
|
147
|
+
|
|
148
148
|
interval = parse_interval(interval)
|
|
149
|
-
|
|
149
|
+
|
|
150
150
|
# Only pass start parameter if since is not None
|
|
151
151
|
kwargs = {"interval": interval, "auto_adjust": False}
|
|
152
152
|
if since is not None:
|
|
153
153
|
kwargs["start"] = since
|
|
154
|
-
|
|
154
|
+
|
|
155
155
|
# Download OHLCV data for the ticker
|
|
156
156
|
ticker_obj = yfinance.Ticker(ticker)
|
|
157
157
|
df = ticker_obj.history(**kwargs)
|
|
158
|
-
|
|
158
|
+
|
|
159
159
|
# Return only the OHLCV columns we need
|
|
160
160
|
# yfinance returns: Open, High, Low, Close, Volume (and sometimes Dividends, Stock Splits)
|
|
161
|
-
ohlcv_columns = [
|
|
161
|
+
ohlcv_columns = ["Open", "High", "Low", "Close", "Volume"]
|
|
162
162
|
return df[ohlcv_columns]
|
|
163
163
|
|
|
164
164
|
|
|
165
165
|
def download_ticker_data(ticker, since, interval="1d"):
|
|
166
166
|
"""Download data from Yahoo Finance
|
|
167
|
-
|
|
167
|
+
|
|
168
168
|
Supports:
|
|
169
169
|
- Single ticker: "AAPL"
|
|
170
170
|
- Comma-separated: "AAPL,TSLA"
|
|
171
171
|
- List of tickers: ["AAPL", "TSLA"]
|
|
172
172
|
- Division operations: "AAPL/XLK" or ["AAPL/XLK"]
|
|
173
|
-
|
|
173
|
+
|
|
174
174
|
Division operations create a new column with the ratio of the two tickers.
|
|
175
175
|
"""
|
|
176
176
|
if yfinance is None:
|
|
@@ -178,30 +178,30 @@ def download_ticker_data(ticker, since, interval="1d"):
|
|
|
178
178
|
|
|
179
179
|
# Parse ticker input
|
|
180
180
|
tickers = parse_ticker_input(ticker)
|
|
181
|
-
|
|
181
|
+
|
|
182
182
|
# Separate division expressions from regular tickers
|
|
183
183
|
division_expressions = []
|
|
184
184
|
regular_tickers = []
|
|
185
|
-
|
|
185
|
+
|
|
186
186
|
for t in tickers:
|
|
187
|
-
if
|
|
187
|
+
if "/" in t:
|
|
188
188
|
division_expressions.append(t)
|
|
189
189
|
# Extract the component tickers from the division expression
|
|
190
|
-
parts = t.split(
|
|
190
|
+
parts = t.split("/")
|
|
191
191
|
for part in parts:
|
|
192
192
|
part = part.strip()
|
|
193
193
|
if part:
|
|
194
194
|
regular_tickers.append(part)
|
|
195
195
|
else:
|
|
196
196
|
regular_tickers.append(t)
|
|
197
|
-
|
|
197
|
+
|
|
198
198
|
# Remove duplicates while preserving order
|
|
199
199
|
regular_tickers = list(dict.fromkeys(regular_tickers))
|
|
200
|
-
|
|
200
|
+
|
|
201
201
|
# Add SPY if only one regular ticker (not counting division expressions)
|
|
202
202
|
if len(regular_tickers) == 1 and not division_expressions:
|
|
203
203
|
regular_tickers.append("SPY")
|
|
204
|
-
|
|
204
|
+
|
|
205
205
|
interval = parse_interval(interval)
|
|
206
206
|
|
|
207
207
|
# Only pass start parameter if since is not None
|
|
@@ -212,21 +212,21 @@ def download_ticker_data(ticker, since, interval="1d"):
|
|
|
212
212
|
# Download data for regular tickers
|
|
213
213
|
df = yfinance.download(regular_tickers, **kwargs)["Adj Close"]
|
|
214
214
|
assert isinstance(df, pd.DataFrame), f"Expected DataFrame from yfinance.download for {regular_tickers}"
|
|
215
|
-
|
|
215
|
+
|
|
216
216
|
# Process division expressions
|
|
217
217
|
for expr in division_expressions:
|
|
218
|
-
parts = expr.split(
|
|
218
|
+
parts = expr.split("/")
|
|
219
219
|
if len(parts) == 2:
|
|
220
220
|
numerator = parts[0].strip()
|
|
221
221
|
denominator = parts[1].strip()
|
|
222
|
-
|
|
222
|
+
|
|
223
223
|
if numerator in df.columns and denominator in df.columns:
|
|
224
224
|
# Create new column with the division result
|
|
225
225
|
df[expr] = df[numerator] / df[denominator]
|
|
226
226
|
else:
|
|
227
227
|
# If one of the tickers is missing, log a warning but continue
|
|
228
228
|
print(f"Warning: Could not create division column '{expr}' - missing ticker data")
|
|
229
|
-
|
|
229
|
+
|
|
230
230
|
return df
|
|
231
231
|
|
|
232
232
|
|
|
@@ -444,28 +444,34 @@ def calculate_cagr_to_breakeven(spot_price: float, strike: float, option_price:
|
|
|
444
444
|
return cagr
|
|
445
445
|
|
|
446
446
|
|
|
447
|
-
def calculate_put_annualized_return(
|
|
447
|
+
def calculate_put_annualized_return(strike_price: float, option_price: float, dte: int) -> float:
|
|
448
448
|
"""Calculate annualized return for put options
|
|
449
449
|
|
|
450
|
-
Formula:
|
|
450
|
+
Formula: premium / capital_at_risk * 365 / dte
|
|
451
|
+
where capital_at_risk = strike - premium
|
|
451
452
|
"""
|
|
452
453
|
if dte <= 0 or option_price <= 0:
|
|
453
454
|
return 0.0
|
|
454
455
|
|
|
455
|
-
|
|
456
|
-
if
|
|
456
|
+
capital_at_risk = strike_price - option_price
|
|
457
|
+
if capital_at_risk <= 0:
|
|
457
458
|
return 0.0
|
|
458
459
|
|
|
459
|
-
return (option_price /
|
|
460
|
+
return (option_price / capital_at_risk) * 365 / dte
|
|
460
461
|
|
|
461
462
|
|
|
462
|
-
def calculate_black_scholes_delta(
|
|
463
|
-
|
|
464
|
-
|
|
463
|
+
def calculate_black_scholes_delta(
|
|
464
|
+
spot_price: float,
|
|
465
|
+
strike: float,
|
|
466
|
+
time_to_expiry: float,
|
|
467
|
+
risk_free_rate: float = 0.05,
|
|
468
|
+
volatility: float = 0.30,
|
|
469
|
+
option_type: str = "call",
|
|
470
|
+
) -> float:
|
|
465
471
|
"""Calculate Black-Scholes delta for an option
|
|
466
|
-
|
|
472
|
+
|
|
467
473
|
Delta measures the rate of change of option price with respect to stock price.
|
|
468
|
-
|
|
474
|
+
|
|
469
475
|
Args:
|
|
470
476
|
spot_price: Current stock price (S)
|
|
471
477
|
strike: Strike price (K)
|
|
@@ -473,20 +479,21 @@ def calculate_black_scholes_delta(spot_price: float, strike: float, time_to_expi
|
|
|
473
479
|
risk_free_rate: Risk-free interest rate (r), default 5%
|
|
474
480
|
volatility: Implied volatility (σ), default 30%
|
|
475
481
|
option_type: 'call' or 'calls' or 'put' or 'puts'
|
|
476
|
-
|
|
482
|
+
|
|
477
483
|
Returns:
|
|
478
484
|
Delta value (0 to 1 for calls, -1 to 0 for puts)
|
|
479
485
|
"""
|
|
480
486
|
from scipy.stats import norm
|
|
481
487
|
from math import log, sqrt
|
|
482
|
-
|
|
488
|
+
|
|
483
489
|
if spot_price <= 0 or strike <= 0 or time_to_expiry <= 0:
|
|
484
490
|
return 0.0
|
|
485
|
-
|
|
491
|
+
|
|
486
492
|
# Calculate d1 from Black-Scholes formula
|
|
487
|
-
d1 = (log(spot_price / strike) + (risk_free_rate + 0.5 * volatility
|
|
488
|
-
|
|
489
|
-
|
|
493
|
+
d1 = (log(spot_price / strike) + (risk_free_rate + 0.5 * volatility**2) * time_to_expiry) / (
|
|
494
|
+
volatility * sqrt(time_to_expiry)
|
|
495
|
+
)
|
|
496
|
+
|
|
490
497
|
# Delta for call: N(d1)
|
|
491
498
|
# Delta for put: N(d1) - 1
|
|
492
499
|
if option_type.lower() in ["call", "calls"]:
|
|
@@ -495,9 +502,15 @@ def calculate_black_scholes_delta(spot_price: float, strike: float, time_to_expi
|
|
|
495
502
|
return norm.cdf(d1) - 1
|
|
496
503
|
|
|
497
504
|
|
|
498
|
-
def calculate_implied_leverage(
|
|
499
|
-
|
|
500
|
-
|
|
505
|
+
def calculate_implied_leverage(
|
|
506
|
+
spot_price: float,
|
|
507
|
+
option_price: float,
|
|
508
|
+
strike: float,
|
|
509
|
+
time_to_expiry: float,
|
|
510
|
+
option_type: str = "call",
|
|
511
|
+
risk_free_rate: float = 0.05,
|
|
512
|
+
volatility: float = 0.30,
|
|
513
|
+
) -> float:
|
|
501
514
|
"""Calculate implied leverage (Omega) for an option
|
|
502
515
|
|
|
503
516
|
Formula: Ω = Δ × (S / O)
|
|
@@ -530,12 +543,11 @@ def calculate_implied_leverage(spot_price: float, option_price: float, strike: f
|
|
|
530
543
|
return 0.0
|
|
531
544
|
|
|
532
545
|
# Calculate delta using Black-Scholes
|
|
533
|
-
delta = calculate_black_scholes_delta(spot_price, strike, time_to_expiry,
|
|
534
|
-
|
|
535
|
-
|
|
546
|
+
delta = calculate_black_scholes_delta(spot_price, strike, time_to_expiry, risk_free_rate, volatility, option_type)
|
|
547
|
+
|
|
536
548
|
# Omega = Delta × (S / O)
|
|
537
549
|
leverage = abs(delta) * (spot_price / option_price)
|
|
538
|
-
|
|
550
|
+
|
|
539
551
|
return leverage
|
|
540
552
|
|
|
541
553
|
|
|
@@ -659,7 +671,7 @@ def format_options_for_display(
|
|
|
659
671
|
filter_ast: Parsed filter AST for advanced filtering (optional)
|
|
660
672
|
"""
|
|
661
673
|
from scipy import stats
|
|
662
|
-
|
|
674
|
+
|
|
663
675
|
options_data = fetch_options_data(ticker)
|
|
664
676
|
|
|
665
677
|
if not options_data:
|
|
@@ -717,7 +729,7 @@ def format_options_for_display(
|
|
|
717
729
|
return_metric = calculate_cagr_to_breakeven(spot_price, strike, last_price, dte)
|
|
718
730
|
return_str = f"{return_metric:.2%}"
|
|
719
731
|
elif option_type == "puts" and last_price > 0:
|
|
720
|
-
return_metric = calculate_put_annualized_return(
|
|
732
|
+
return_metric = calculate_put_annualized_return(strike, last_price, dte)
|
|
721
733
|
return_str = f"{return_metric:.2%}"
|
|
722
734
|
else:
|
|
723
735
|
# No valid price for calculation - display N/A and set metric to None
|
|
@@ -735,8 +747,7 @@ def format_options_for_display(
|
|
|
735
747
|
if implied_vol and implied_vol > 0:
|
|
736
748
|
# Use actual market implied volatility
|
|
737
749
|
leverage = calculate_implied_leverage(
|
|
738
|
-
spot_price, last_price, strike, time_to_expiry_years, option_type,
|
|
739
|
-
volatility=implied_vol
|
|
750
|
+
spot_price, last_price, strike, time_to_expiry_years, option_type, volatility=implied_vol
|
|
740
751
|
)
|
|
741
752
|
# If no implied volatility available, leverage remains None (will display as N/A)
|
|
742
753
|
|
|
@@ -752,32 +763,34 @@ def format_options_for_display(
|
|
|
752
763
|
raw_efficiency = leverage / return_metric
|
|
753
764
|
|
|
754
765
|
# Store option data for first pass
|
|
755
|
-
raw_options.append(
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
766
|
+
raw_options.append(
|
|
767
|
+
{
|
|
768
|
+
"ticker": ticker,
|
|
769
|
+
"strike": strike,
|
|
770
|
+
"dte": dte,
|
|
771
|
+
"volume": volume,
|
|
772
|
+
"price": last_price,
|
|
773
|
+
"return_metric": return_metric,
|
|
774
|
+
"return_str": return_str,
|
|
775
|
+
"leverage": leverage,
|
|
776
|
+
"strike_pct": strike_pct,
|
|
777
|
+
"lt_days": lt_days,
|
|
778
|
+
"raw_efficiency": raw_efficiency,
|
|
779
|
+
"option_type": option_type,
|
|
780
|
+
}
|
|
781
|
+
)
|
|
769
782
|
|
|
770
783
|
# Second pass: calculate efficiency percentiles
|
|
771
784
|
valid_efficiencies = [opt["raw_efficiency"] for opt in raw_options if opt["raw_efficiency"] is not None]
|
|
772
|
-
|
|
785
|
+
|
|
773
786
|
# Build final formatted options with efficiency percentiles
|
|
774
787
|
formatted_options = []
|
|
775
788
|
for opt in raw_options:
|
|
776
789
|
# Calculate efficiency percentile (0-100)
|
|
777
790
|
efficiency = None
|
|
778
791
|
if opt["raw_efficiency"] is not None and len(valid_efficiencies) > 0:
|
|
779
|
-
efficiency = stats.percentileofscore(valid_efficiencies, opt["raw_efficiency"], kind=
|
|
780
|
-
|
|
792
|
+
efficiency = stats.percentileofscore(valid_efficiencies, opt["raw_efficiency"], kind="rank")
|
|
793
|
+
|
|
781
794
|
# Create option data dict for filtering
|
|
782
795
|
# Field aliases: ret/ar for return, sp for strike_pct, lt_days for last trade days, lev for leverage, eff for efficiency
|
|
783
796
|
option_data = {
|
|
@@ -795,33 +808,41 @@ def format_options_for_display(
|
|
|
795
808
|
"efficiency": efficiency,
|
|
796
809
|
"eff": efficiency,
|
|
797
810
|
}
|
|
798
|
-
|
|
811
|
+
|
|
799
812
|
# Apply filter_ast if provided
|
|
800
813
|
if filter_ast and not evaluate_filter(filter_ast, option_data):
|
|
801
814
|
continue
|
|
802
|
-
|
|
815
|
+
|
|
803
816
|
# Format display string
|
|
804
817
|
option_type_letter = "C" if opt["option_type"] == "calls" else "P"
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
818
|
+
|
|
819
|
+
if opt["option_type"] == "calls":
|
|
820
|
+
leverage_str = f"{opt['leverage']:.1f}x" if opt["leverage"] and opt["leverage"] > 0 else "N/A"
|
|
821
|
+
efficiency_str = f"{efficiency:.0f}" if efficiency is not None else "N/A"
|
|
822
|
+
formatted_option = (
|
|
823
|
+
f"{opt['ticker'].upper()} {opt['strike']:.0f}{option_type_letter} {opt['dte']}DTE "
|
|
824
|
+
f"(${opt['price']:.2f}, {opt['return_str']}, {leverage_str}, eff:{efficiency_str})"
|
|
825
|
+
)
|
|
826
|
+
else:
|
|
827
|
+
formatted_option = (
|
|
828
|
+
f"{opt['ticker'].upper()} {opt['strike']:.0f}{option_type_letter} {opt['dte']}DTE "
|
|
829
|
+
f"(${opt['price']:.2f}, {opt['return_str']})"
|
|
830
|
+
)
|
|
831
|
+
|
|
813
832
|
# Store for sorting
|
|
814
|
-
formatted_options.append(
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
833
|
+
formatted_options.append(
|
|
834
|
+
{
|
|
835
|
+
"display": formatted_option,
|
|
836
|
+
"strike": opt["strike"],
|
|
837
|
+
"dte": opt["dte"],
|
|
838
|
+
"volume": opt["volume"],
|
|
839
|
+
"price": opt["price"],
|
|
840
|
+
"return_metric": opt["return_metric"],
|
|
841
|
+
"leverage": opt["leverage"],
|
|
842
|
+
"efficiency": efficiency,
|
|
843
|
+
}
|
|
844
|
+
)
|
|
845
|
+
|
|
825
846
|
# Sort based on the specified criteria
|
|
826
847
|
if sort_by == "strike":
|
|
827
848
|
formatted_options.sort(key=lambda x: x["strike"])
|
|
@@ -833,12 +854,14 @@ def format_options_for_display(
|
|
|
833
854
|
# For calls: ascending (smallest to largest return)
|
|
834
855
|
# For puts: descending (largest to smallest return)
|
|
835
856
|
if option_type == "calls":
|
|
836
|
-
formatted_options.sort(key=lambda x: x["return_metric"] if x["return_metric"] is not None else float(
|
|
857
|
+
formatted_options.sort(key=lambda x: x["return_metric"] if x["return_metric"] is not None else float("inf"))
|
|
837
858
|
else: # puts
|
|
838
|
-
formatted_options.sort(
|
|
859
|
+
formatted_options.sort(
|
|
860
|
+
key=lambda x: x["return_metric"] if x["return_metric"] is not None else float("-inf"), reverse=True
|
|
861
|
+
)
|
|
839
862
|
elif sort_by == "efficiency":
|
|
840
863
|
# Sort by efficiency percentile (highest first)
|
|
841
864
|
formatted_options.sort(key=lambda x: x["efficiency"] if x["efficiency"] is not None else -1, reverse=True)
|
|
842
|
-
|
|
865
|
+
|
|
843
866
|
# Return just the display strings
|
|
844
867
|
return [option["display"] for option in formatted_options]
|
|
@@ -42,9 +42,10 @@ class TestOptionsCore(unittest.TestCase):
|
|
|
42
42
|
def test_format_options_for_display_with_data(self, mock_spot_price, mock_fetch):
|
|
43
43
|
"""Test format_options_for_display with mock data"""
|
|
44
44
|
from datetime import datetime, timedelta
|
|
45
|
+
|
|
45
46
|
# Use a future date that won't expire
|
|
46
47
|
future_date = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d")
|
|
47
|
-
|
|
48
|
+
|
|
48
49
|
mock_spot_price.return_value = 150.0
|
|
49
50
|
mock_data = {
|
|
50
51
|
"expiry_dates": [future_date],
|
|
@@ -110,17 +111,19 @@ class TestOptionsCore(unittest.TestCase):
|
|
|
110
111
|
|
|
111
112
|
def test_calculate_put_annualized_return(self):
|
|
112
113
|
"""Test annualized return calculation for puts"""
|
|
113
|
-
|
|
114
|
+
strike_price = 145.0
|
|
114
115
|
option_price = 3.0
|
|
115
116
|
dte = 30
|
|
116
117
|
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
# premium / (strike - premium) * 365 / dte = 3 / 142 * 365 / 30 = 0.2570...
|
|
119
|
+
annual_return = calculate_put_annualized_return(strike_price, option_price, dte)
|
|
120
|
+
expected = (3.0 / (145.0 - 3.0)) * 365 / 30
|
|
121
|
+
self.assertAlmostEqual(annual_return, expected, places=6)
|
|
119
122
|
|
|
120
123
|
# Test edge cases
|
|
121
|
-
self.assertEqual(calculate_put_annualized_return(
|
|
122
|
-
self.assertEqual(calculate_put_annualized_return(
|
|
123
|
-
self.assertEqual(calculate_put_annualized_return(
|
|
124
|
+
self.assertEqual(calculate_put_annualized_return(145, 0, 30), 0.0)
|
|
125
|
+
self.assertEqual(calculate_put_annualized_return(145, 5, 0), 0.0)
|
|
126
|
+
self.assertEqual(calculate_put_annualized_return(145, 200, 30), 0.0) # premium > strike
|
|
124
127
|
|
|
125
128
|
|
|
126
129
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|