grynn-fplot 0.3.1__py3-none-any.whl

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.
@@ -0,0 +1 @@
1
+ # only needed so hatch can find this folder using its internal heuristics
grynn_fplot/cli.py ADDED
@@ -0,0 +1,442 @@
1
+ import importlib.metadata
2
+ import sys
3
+ import tempfile
4
+ from datetime import datetime
5
+
6
+ import click
7
+ import matplotlib.pyplot as plt
8
+ import mplcursors
9
+ import numpy as np
10
+ from grynn_pylib.finance.timeseries import rolling_cagr
11
+ from loguru import logger
12
+ from tabulate import tabulate
13
+
14
+ from grynn_fplot.core import (
15
+ calculate_area_under_curve,
16
+ calculate_cagr,
17
+ calculate_drawdowns,
18
+ download_ticker_data,
19
+ normalize_prices,
20
+ parse_start_date,
21
+ format_options_for_display,
22
+ )
23
+
24
+ try:
25
+ # if __package__ is None and __name__ == "__main__" this is being run from vscode interactive
26
+ __version__ = importlib.metadata.version(__package__ or __name__)
27
+ except importlib.metadata.PackageNotFoundError:
28
+ __version__ = f"unknown (__name__: {__name__})"
29
+
30
+
31
+ @click.command()
32
+ @click.option("--since", type=str, default=None, help="Start date for data (e.g., '1y', '6m', '2023-01-01')")
33
+ @click.option("--interval", type=str, default="1d", help="Data interval (1d, 1wk, 1mo)")
34
+ @click.argument("ticker", type=str, nargs=1, required=False)
35
+ @click.option("--version", "-v", is_flag=True, help="Show version and exit")
36
+ @click.option("--debug", is_flag=True, help="Enable debug mode")
37
+ @click.option("--call", is_flag=True, help="List all available call options for the ticker")
38
+ @click.option("--put", is_flag=True, help="List all available put options for the ticker")
39
+ @click.option(
40
+ "--max",
41
+ "max_expiry",
42
+ type=str,
43
+ default="6m",
44
+ help="Maximum expiry time for options (e.g., '3m', '6m', '1y'). Default: 6m",
45
+ )
46
+ @click.option("--min-dte", type=str, default=None, help="Minimum days to expiry (e.g., '30', '1y', '6m', '2w')")
47
+ @click.option("--all", "show_all", is_flag=True, help="Show all available expiries (overrides --max)")
48
+ @click.option(
49
+ "--filter",
50
+ "filter_expr",
51
+ type=str,
52
+ default=None,
53
+ help="Filter expression (e.g., 'dte>300', 'dte>10, dte<15', 'dte>300 + strike<100')",
54
+ )
55
+ @click.option("--web", "-w", is_flag=True, help="Launch interactive web interface")
56
+ @click.option("--port", type=int, default=8000, help="Port for web interface")
57
+ @click.option("--host", type=str, default="127.0.0.1", help="Host for web interface")
58
+ @click.option("--no-browser", is_flag=True, help="Don't automatically open browser")
59
+ def display_plot(
60
+ ticker,
61
+ since,
62
+ interval,
63
+ version,
64
+ debug,
65
+ call,
66
+ put,
67
+ max_expiry,
68
+ min_dte,
69
+ show_all,
70
+ filter_expr,
71
+ web,
72
+ port,
73
+ host,
74
+ no_browser,
75
+ ):
76
+ """Generate a plot of the given ticker(s) or list options contracts.
77
+
78
+ When --call or --put flags are used, lists available options contracts
79
+ in a format suitable for filtering with tools like fzf.
80
+
81
+ Output format: TICKER STRIKE[C|P] DAYS_TO_EXPIRY (price, return_metric)
82
+ - For calls: return_metric is CAGR to breakeven
83
+ - For puts: return_metric is annualized return
84
+
85
+ Examples:
86
+ \b
87
+ # List all AAPL call options (default: 6 months max)
88
+ fplot AAPL --call
89
+
90
+ # List TSLA put options with 3 month max expiry
91
+ fplot TSLA --put --max 3m
92
+
93
+ # List all available call options (no expiry limit)
94
+ fplot AAPL --call --all
95
+
96
+ # Interactive filtering with fzf
97
+ fplot AAPL --call | fzf
98
+ """
99
+ logger.remove() # Remove default handlers
100
+ logger.add(sys.stdout, level="DEBUG" if debug else "WARNING")
101
+
102
+ if debug:
103
+ logger.debug("Debug mode enabled")
104
+
105
+ # Process arguments
106
+ if version:
107
+ print(f"fplot {__version__}")
108
+ return
109
+
110
+ # Launch web interface if --web flag is used
111
+ if web:
112
+ launch_web_interface(ticker, since, interval, port, host, no_browser, debug)
113
+ return
114
+
115
+ # CLI mode - require ticker
116
+ if not ticker:
117
+ click.echo(
118
+ "Error: Missing argument 'TICKER'. Please provide a ticker symbol or symbols as a comma separated list."
119
+ )
120
+ click.echo("Hint: Use --web or -w to launch the interactive web interface.")
121
+ return
122
+
123
+ # Validate filter expression if provided
124
+ parsed_filter = None
125
+ if filter_expr:
126
+ try:
127
+ from grynn_fplot.filter_parser import parse_filter, FilterParseError
128
+
129
+ parsed_filter = parse_filter(filter_expr)
130
+ if debug:
131
+ logger.debug(f"Parsed filter: {parsed_filter}")
132
+ except FilterParseError as e:
133
+ click.echo(f"Error: Invalid filter expression: {e}")
134
+ click.echo("Filter syntax: Use comma (,) for AND, plus (+) for OR")
135
+ click.echo("Examples: 'dte>300', 'dte>10, dte<15', 'dte>300 + strike<100'")
136
+ return
137
+
138
+ # Parse min_dte if provided (supports formats like '30', '1y', '6m', '2w')
139
+ parsed_min_dte = None
140
+ if min_dte:
141
+ try:
142
+ # Try to parse as integer first
143
+ parsed_min_dte = int(min_dte)
144
+ except ValueError:
145
+ # Try to parse as time expression (1y, 6m, 2w, 30d)
146
+ try:
147
+ from grynn_fplot.filter_parser import parse_dte_value, FilterParseError
148
+
149
+ parsed_min_dte = parse_dte_value(min_dte)
150
+ if debug:
151
+ logger.debug(f"Parsed min_dte '{min_dte}' to {parsed_min_dte} days")
152
+ except FilterParseError:
153
+ click.echo(f"Error: Invalid min-dte value: '{min_dte}'")
154
+ click.echo("Expected format: integer days or time expression (e.g., '30', '1y', '6m', '2w')")
155
+ return
156
+
157
+ # When --filter or --min-dte is specified, don't use default values for --max unless explicitly set or --all is used
158
+ # This allows filters to work on all options without artificial date limits
159
+ use_show_all = show_all
160
+ use_max_expiry = max_expiry
161
+ if (filter_expr or parsed_min_dte) and not show_all:
162
+ # Check if max_expiry was explicitly set by the user (not just the default)
163
+ # Since we can't easily detect if a default was used, we'll treat filter/min_dte as implying --all behavior
164
+ # unless max is explicitly different from default or --all is already set
165
+ use_show_all = True
166
+ use_max_expiry = None # Will be ignored when show_all is True
167
+
168
+ # Handle options listing
169
+ if call:
170
+ options_list = format_options_for_display(
171
+ ticker,
172
+ "calls",
173
+ max_expiry=use_max_expiry,
174
+ min_dte=parsed_min_dte,
175
+ show_all=use_show_all,
176
+ filter_ast=parsed_filter,
177
+ )
178
+ if not options_list:
179
+ click.echo(f"No call options found for {ticker.upper()}")
180
+ return
181
+
182
+ for option in options_list:
183
+ print(option)
184
+ return
185
+
186
+ if put:
187
+ options_list = format_options_for_display(
188
+ ticker,
189
+ "puts",
190
+ max_expiry=use_max_expiry,
191
+ min_dte=parsed_min_dte,
192
+ show_all=use_show_all,
193
+ filter_ast=parsed_filter,
194
+ )
195
+ if not options_list:
196
+ click.echo(f"No put options found for {ticker.upper()}")
197
+ return
198
+
199
+ for option in options_list:
200
+ print(option)
201
+ return
202
+
203
+ # Continue with original CLI plotting logic
204
+ display_cli_plot(ticker, since, interval, debug)
205
+
206
+
207
+ def launch_web_interface(ticker, since, interval, port, host, no_browser, debug):
208
+ """Launch the web interface using uvicorn"""
209
+ import subprocess
210
+ import time
211
+ import threading
212
+
213
+ try:
214
+ # Import uvicorn here to avoid import issues
215
+ import uvicorn
216
+ from grynn_fplot.serve import app
217
+
218
+ # Build the URL
219
+ url = f"http://{host}:{port}"
220
+ if ticker:
221
+ # If ticker is provided but no since parameter, use 5y for preloading
222
+ if since is None:
223
+ url += f"?ticker={ticker}&preload=5y"
224
+ else:
225
+ url += f"?ticker={ticker}&since={since}"
226
+
227
+ print("šŸš€ Starting fplot web interface...")
228
+ print(f"šŸ“Š Server will be available at: {url}")
229
+
230
+ if ticker:
231
+ print(f"šŸ“ˆ Pre-loading data for: {ticker}")
232
+
233
+ # Start browser immediately if requested - don't wait for server
234
+ browser_opened = False
235
+ if not no_browser:
236
+
237
+ def open_browser_early():
238
+ nonlocal browser_opened
239
+ # Try to open browser with a shorter delay
240
+ time.sleep(0.5)
241
+ try:
242
+ # Use npx open-in-browser for better cross-platform support
243
+ subprocess.run(["npx", "open-in-browser", url], check=False, capture_output=True, timeout=10)
244
+ browser_opened = True
245
+ print(f"🌐 Opening {url} in your default browser...")
246
+ except subprocess.TimeoutExpired:
247
+ print("āš ļø Browser opening timed out")
248
+ print(f"šŸ“± Please manually open: {url}")
249
+ except subprocess.CalledProcessError as e:
250
+ print(f"āš ļø Browser opening failed: {e}")
251
+ print(f"šŸ“± Please manually open: {url}")
252
+ except FileNotFoundError:
253
+ print("āš ļø npx not found, trying fallback...")
254
+ # Fallback to Python webbrowser
255
+ try:
256
+ import webbrowser
257
+
258
+ webbrowser.open(url)
259
+ browser_opened = True
260
+ print(f"🌐 Opened {url} using fallback method")
261
+ except Exception as fallback_error:
262
+ print(f"āš ļø Fallback browser opening failed: {fallback_error}")
263
+ print(f"šŸ“± Please manually open: {url}")
264
+ except Exception as e:
265
+ print(f"āš ļø Could not open browser automatically: {e}")
266
+ print(f"šŸ“± Please manually open: {url}")
267
+
268
+ # Start browser opening in parallel
269
+ threading.Thread(target=open_browser_early, daemon=True).start()
270
+
271
+ # Configure uvicorn logging
272
+ log_level = "debug" if debug else "info"
273
+
274
+ print("⚔ Starting server...")
275
+ if not no_browser and not browser_opened:
276
+ print("🌐 Browser will open automatically once server is ready...")
277
+ print("šŸ›‘ Press Ctrl+C to stop the server")
278
+
279
+ # Create uvicorn config for faster startup
280
+ config = uvicorn.Config(
281
+ app,
282
+ host=host,
283
+ port=port,
284
+ log_level=log_level,
285
+ access_log=debug,
286
+ reload=False, # Disable reload for faster startup
287
+ workers=1, # Single worker for CLI mode
288
+ loop="asyncio", # Use asyncio for better performance
289
+ )
290
+
291
+ # Run the server
292
+ server = uvicorn.Server(config)
293
+ server.run()
294
+
295
+ except ImportError as e:
296
+ print(f"āŒ Error: Required web dependencies not available: {e}")
297
+ print("šŸ’” Make sure FastAPI and uvicorn are installed")
298
+ print("šŸ”§ Try: uv install fastapi uvicorn")
299
+ return
300
+ except KeyboardInterrupt:
301
+ print("\nšŸ›‘ Server stopped by user")
302
+ return
303
+ except Exception as e:
304
+ print(f"āŒ Error starting web interface: {e}")
305
+ return
306
+
307
+
308
+ def display_cli_plot(ticker, since, interval, debug):
309
+ """Display plot using matplotlib (original CLI functionality)"""
310
+ since = parse_start_date(since)
311
+
312
+ # Download and prepare data
313
+ df = download_ticker_data(ticker, since, interval)
314
+ if df.empty:
315
+ print(f"No data found for the given tickers({ticker}).")
316
+ return
317
+
318
+ tickers = df.columns.tolist()
319
+ print(f"Generating plot for {', '.join(tickers)} since {since.date()}. Interval: {interval}")
320
+
321
+ if debug:
322
+ print(f"Data for {', '.join(tickers)}:")
323
+ print(df.head())
324
+ temp_file = tempfile.NamedTemporaryFile(suffix=".csv", delete=False)
325
+ df.to_csv(temp_file.name)
326
+ print(f"Data saved to temporary file: {temp_file.name}")
327
+
328
+ # Process data
329
+
330
+ # Handle edge case, where last row has data for only some tickers
331
+ # (if a ticker is delisted or using different exchanges with different timezones/trading days/calendars)
332
+ # ONLY check last row for now
333
+ last_row_has_missing_data = df.iloc[-1].isna().any()
334
+ if last_row_has_missing_data:
335
+ click.echo("Last row has missing data for some tickers. Dropping the row.")
336
+ click.echo(tabulate(df.iloc[[-1]], headers="keys", tablefmt="pretty", showindex=False))
337
+ df = df.iloc[:-1] # drop the last row (this helps with plotting)
338
+
339
+ df_normalized = normalize_prices(df)
340
+ df_dd = calculate_drawdowns(df_normalized)
341
+ df_auc = calculate_area_under_curve(df_dd)
342
+ df_days = (df.index[-1] - df.index[0]).days
343
+
344
+ # Display AUC analysis in CLI
345
+ print("\n=== Drawdown Area Under Curve Analysis ===")
346
+ print(tabulate(df_auc, headers="keys", tablefmt="pretty", showindex=False))
347
+ print("Higher values indicate greater drawdowns over time.\n")
348
+
349
+ # Calculate and display rolling, median 1-year return if time period >= 1.5 years
350
+ if df_days >= int(365.25 * 1.5):
351
+ df_rolling_cagr = rolling_cagr(df, years=1).median()
352
+ print("\n=== Rolling Median 1 yr Return ===")
353
+ print(df_rolling_cagr.to_string(float_format="{:.2%}".format))
354
+
355
+ # Calculate and display rolling, median 3-year return if time period >= 3.5 years
356
+ if df_days >= int(365.25 * 3.5):
357
+ df_rolling_cagr = rolling_cagr(df, years=3).median()
358
+ print("\n=== Rolling Median 3 yr Return ===")
359
+ print(df_rolling_cagr.to_string(float_format="{:.2%}".format))
360
+
361
+ # Calculate and display CAGR if time period >= 1 year
362
+ if df_days >= 365:
363
+ cagr_df = calculate_cagr(df_normalized)
364
+ print("\n=== Compound Annual Growth Rate (CAGR) ===")
365
+ print(tabulate(cagr_df, headers="keys", tablefmt="pretty", showindex=False, floatfmt=".2%"))
366
+ print(f"CAGR represents annualized return over the period {df.index[0]} to {df.index[-1]}, {df_days} days.\n")
367
+
368
+ # Prepare for plotting
369
+ auc_values = dict(zip(df_auc["Ticker"], df_auc["AUC"]))
370
+ fig, (ax1, ax2) = plt.subplots(
371
+ 2, 1, figsize=(16, 12), sharex=True, gridspec_kw={"height_ratios": [3, 2], "hspace": 0.3}
372
+ )
373
+
374
+ # Generate colors for each ticker
375
+ color_map = plt.get_cmap("tab10")
376
+ color_iter = iter(color_map.colors)
377
+ colors = [next(color_iter) if t != "SPY" else "darkgrey" for t in tickers]
378
+
379
+ # Plot normalized prices
380
+ for i, ticker in enumerate(tickers):
381
+ label = f"{ticker} - AUC: {auc_values[ticker]:.2f}"
382
+ # Add CAGR to label if applicable
383
+ if (df_days >= 365) and ticker in cagr_df["Ticker"].values:
384
+ cagr_value = cagr_df.loc[cagr_df["Ticker"] == ticker, "CAGR"].values[0]
385
+ label += f" - CAGR: {cagr_value:.2%}"
386
+
387
+ ax1.plot(df_normalized.index, df_normalized[ticker], label=label, color=colors[i])
388
+
389
+ ax1.set_title(f"{', '.join(tickers)} Price")
390
+ ax1.set_ylabel("Normalized Price")
391
+ ax1.legend(loc="best")
392
+
393
+ # Plot drawdowns
394
+ for i, ticker in enumerate(tickers):
395
+ ax2.plot(df_dd.index, df_dd[ticker], label=f"{ticker} - AUC: {auc_values[ticker]:.2f}", color=colors[i])
396
+ ax2.fill_between(df_dd.index, df_dd[ticker], alpha=0.5, color=colors[i])
397
+
398
+ ax2.set_title(f"{', '.join(tickers)} Drawdowns")
399
+ ax2.set_ylabel("Drawdown")
400
+ ax2.set_xlabel(f"from {since.date()} to {datetime.now().date()} in {interval} intervals")
401
+ ax2.legend(loc="best")
402
+
403
+ # Add end-point annotations
404
+ for line in ax1.get_lines():
405
+ y = line.get_ydata()[-1]
406
+ x = line.get_xdata()[-1]
407
+ label = line.get_label().split(" - ")[0] # Extract just the ticker part
408
+
409
+ # Handle masked values
410
+ if isinstance(y, np.ma.core.MaskedConstant):
411
+ value_text = "N/A"
412
+ else:
413
+ value_text = f"{y - 100:.2f}%"
414
+
415
+ ax1.annotate(f"{label}: {value_text}", xy=(x, y), color=line.get_color())
416
+
417
+ # Add interactive cursor functionality
418
+ cursor1 = mplcursors.cursor(ax1)
419
+ cursor1.connect(
420
+ "add",
421
+ lambda sel: (
422
+ sel.annotation.set_text(f"{sel.artist.get_label().split(' - ')[0]}: {sel.target[1]:.2f}"),
423
+ sel.annotation.get_bbox_patch().set(fc=sel.artist.get_color()),
424
+ ),
425
+ )
426
+
427
+ cursor2 = mplcursors.cursor(ax2)
428
+ cursor2.connect(
429
+ "add",
430
+ lambda sel: (
431
+ sel.annotation.set_text(f"{sel.artist.get_label().split(' - ')[0]}: {sel.target[1]:.2f}"),
432
+ sel.annotation.get_bbox_patch().set(fc=sel.artist.get_color()),
433
+ ),
434
+ )
435
+
436
+ # plt.tight_layout()
437
+ plt.show()
438
+
439
+
440
+ # %%
441
+ if __name__ == "__main__":
442
+ display_plot()