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.
Files changed (38) hide show
  1. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/PKG-INFO +20 -9
  2. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/README.md +19 -8
  3. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/cli.py +7 -3
  4. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/core.py +121 -98
  5. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/pyproject.toml +1 -1
  6. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_options.py +10 -7
  7. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/uv.lock +1 -1
  8. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.devcontainer/devcontainer.json +0 -0
  9. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.github/copilot-instructions.md +0 -0
  10. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.github/dependabot.yml +0 -0
  11. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.github/workflows/publish.yml +0 -0
  12. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.gitignore +0 -0
  13. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.pre-commit-config.yaml +0 -0
  14. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.secrets.baseline +0 -0
  15. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/.vscode/settings.json +0 -0
  16. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/Makefile +0 -0
  17. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/docs/FILTER_DESIGN.md +0 -0
  18. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/docs/FILTER_EXAMPLES.sh +0 -0
  19. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/docs/PERFORMANCE_OPTIMIZATION.md +0 -0
  20. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/docs/WEB_REFACTOR.md +0 -0
  21. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/example_usage.sh +0 -0
  22. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/__init__.py +0 -0
  23. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/drawdowns.py +0 -0
  24. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/filter_parser.py +0 -0
  25. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/index.html +0 -0
  26. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/plot_option_interactive.py +0 -0
  27. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/serve.py +0 -0
  28. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/grynn_fplot/web_api.py +0 -0
  29. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_candlestick.py +0 -0
  30. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_cli_integration.py +0 -0
  31. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_cli_options.py +0 -0
  32. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_filter_evaluation.py +0 -0
  33. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_filter_parser.py +0 -0
  34. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_fplot.py +0 -0
  35. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_input_parsing.py +0 -0
  36. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_leverage.py +0 -0
  37. {grynn_fplot-0.3.4 → grynn_fplot-0.3.6}/tests/test_web_api.py +0 -0
  38. {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.4
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 options output includes pricing, return metrics, implied leverage, and efficiency:
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
- Format: `TICKER STRIKE[C|P] DAYS_TO_EXPIRY (price, return_metric, leverage, eff:percentile)`
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, annualized return for puts) - all aliases work
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 options output includes pricing, return metrics, implied leverage, and efficiency:
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
- Format: `TICKER STRIKE[C|P] DAYS_TO_EXPIRY (price, return_metric, leverage, eff:percentile)`
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, annualized return for puts) - all aliases work
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: TICKER STRIKE[C|P] DAYS_TO_EXPIRY (price, return_metric)
86
- - For calls: return_metric is CAGR to breakeven
87
- - For puts: return_metric is annualized return
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 ',' in item:
42
+ if "," in item:
43
43
  # Split by comma and add each part
44
- tickers.extend([t.strip() for t in item.split(',') if t.strip()])
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(',') if t.strip()]
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 = ['Open', 'High', 'Low', 'Close', 'Volume']
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 '/' in t:
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(spot_price: float, option_price: float, dte: int) -> float:
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: (price / (spot - price)) * 365 / dte
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
- denominator = spot_price - option_price
456
- if denominator <= 0:
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 / denominator) * 365 / dte
460
+ return (option_price / capital_at_risk) * 365 / dte
460
461
 
461
462
 
462
- def calculate_black_scholes_delta(spot_price: float, strike: float, time_to_expiry: float,
463
- risk_free_rate: float = 0.05, volatility: float = 0.30,
464
- option_type: str = "call") -> float:
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 ** 2) * time_to_expiry) / \
488
- (volatility * sqrt(time_to_expiry))
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(spot_price: float, option_price: float, strike: float,
499
- time_to_expiry: float, option_type: str = "call",
500
- risk_free_rate: float = 0.05, volatility: float = 0.30) -> float:
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
- risk_free_rate, volatility, option_type)
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(spot_price, last_price, dte)
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
- "ticker": ticker,
757
- "strike": strike,
758
- "dte": dte,
759
- "volume": volume,
760
- "price": last_price,
761
- "return_metric": return_metric,
762
- "return_str": return_str,
763
- "leverage": leverage,
764
- "strike_pct": strike_pct,
765
- "lt_days": lt_days,
766
- "raw_efficiency": raw_efficiency,
767
- "option_type": option_type,
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='rank')
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
- leverage_str = f"{opt['leverage']:.1f}x" if opt['leverage'] and opt['leverage'] > 0 else "N/A"
806
- efficiency_str = f"{efficiency:.0f}" if efficiency is not None else "N/A"
807
-
808
- formatted_option = (
809
- f"{opt['ticker'].upper()} {opt['strike']:.0f}{option_type_letter} {opt['dte']}DTE "
810
- f"(${opt['price']:.2f}, {opt['return_str']}, {leverage_str}, eff:{efficiency_str})"
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
- "display": formatted_option,
816
- "strike": opt["strike"],
817
- "dte": opt["dte"],
818
- "volume": opt["volume"],
819
- "price": opt["price"],
820
- "return_metric": opt["return_metric"],
821
- "leverage": opt["leverage"],
822
- "efficiency": efficiency,
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('inf'))
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(key=lambda x: x["return_metric"] if x["return_metric"] is not None else float('-inf'), reverse=True)
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]
@@ -34,7 +34,7 @@ license = {text = "MIT"}
34
34
  name = "grynn-fplot"
35
35
  readme = "README.md"
36
36
  requires-python = ">=3.12"
37
- version = "0.3.4"
37
+ version = "0.3.6"
38
38
 
39
39
  [project.scripts]
40
40
  fplot = "grynn_fplot.cli:display_plot"
@@ -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
- spot_price = 150.0
114
+ strike_price = 145.0
114
115
  option_price = 3.0
115
116
  dte = 30
116
117
 
117
- annual_return = calculate_put_annualized_return(spot_price, option_price, dte)
118
- self.assertGreater(annual_return, 0)
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(150, 0, 30), 0.0)
122
- self.assertEqual(calculate_put_annualized_return(150, 5, 0), 0.0)
123
- self.assertEqual(calculate_put_annualized_return(150, 200, 30), 0.0) # option price > spot
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__":
@@ -383,7 +383,7 @@ wheels = [
383
383
 
384
384
  [[package]]
385
385
  name = "grynn-fplot"
386
- version = "0.3.4"
386
+ version = "0.3.6"
387
387
  source = { editable = "." }
388
388
  dependencies = [
389
389
  { name = "click" },
File without changes
File without changes