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.
- grynn_fplot/__init__.py +1 -0
- grynn_fplot/cli.py +442 -0
- grynn_fplot/core.py +575 -0
- grynn_fplot/drawdowns.py +80 -0
- grynn_fplot/filter_parser.py +453 -0
- grynn_fplot/index.html +967 -0
- grynn_fplot/plot_option_interactive.py +57 -0
- grynn_fplot/serve.py +221 -0
- grynn_fplot/web_api.py +292 -0
- grynn_fplot-0.3.1.dist-info/METADATA +166 -0
- grynn_fplot-0.3.1.dist-info/RECORD +13 -0
- grynn_fplot-0.3.1.dist-info/WHEEL +4 -0
- grynn_fplot-0.3.1.dist-info/entry_points.txt +2 -0
grynn_fplot/__init__.py
ADDED
|
@@ -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()
|