pfund-plot 0.0.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.
- pfund_plot/__init__.py +183 -0
- pfund_plot/__main__.py +9 -0
- pfund_plot/cli/__init__.py +3 -0
- pfund_plot/cli/commands/gallery/__init__.py +15 -0
- pfund_plot/cli/commands/gallery/gallery_marimo.py +462 -0
- pfund_plot/cli/commands/serve.py +21 -0
- pfund_plot/cli/main.py +20 -0
- pfund_plot/config.py +109 -0
- pfund_plot/enums/__init__.py +16 -0
- pfund_plot/enums/dataframe_backend.py +6 -0
- pfund_plot/enums/display_mode.py +7 -0
- pfund_plot/enums/panel_design.py +8 -0
- pfund_plot/enums/panel_theme.py +6 -0
- pfund_plot/enums/plotting_backend.py +12 -0
- pfund_plot/js_tap/components/candlestick.js +9566 -0
- pfund_plot/mixins/streaming_market_feed_mixin.py +162 -0
- pfund_plot/plots/altair.py +32 -0
- pfund_plot/plots/area/__init__.py +82 -0
- pfund_plot/plots/area/bokeh.py +151 -0
- pfund_plot/plots/bar/__init__.py +80 -0
- pfund_plot/plots/bar/bokeh.py +128 -0
- pfund_plot/plots/bokeh.py +32 -0
- pfund_plot/plots/candlestick/__init__.py +77 -0
- pfund_plot/plots/candlestick/bokeh.py +124 -0
- pfund_plot/plots/candlestick/svelte.py +161 -0
- pfund_plot/plots/holoviews.py +32 -0
- pfund_plot/plots/label/__init__.py +43 -0
- pfund_plot/plots/label/bokeh.py +89 -0
- pfund_plot/plots/layout/__init__.py +98 -0
- pfund_plot/plots/layout/layout.py +116 -0
- pfund_plot/plots/layout/panel.py +51 -0
- pfund_plot/plots/layout/tabs/__init__.py +36 -0
- pfund_plot/plots/layout/tabs/panel.py +51 -0
- pfund_plot/plots/lazy.py +408 -0
- pfund_plot/plots/line/__init__.py +37 -0
- pfund_plot/plots/line/bokeh.py +137 -0
- pfund_plot/plots/matplotlib.py +32 -0
- pfund_plot/plots/plot.py +1131 -0
- pfund_plot/plots/plotly.py +32 -0
- pfund_plot/plots/scatter/__init__.py +62 -0
- pfund_plot/plots/scatter/bokeh.py +158 -0
- pfund_plot/plots/scatter/marker.py +107 -0
- pfund_plot/plots/ta.py +6 -0
- pfund_plot/renderers/base.py +84 -0
- pfund_plot/renderers/browser.py +28 -0
- pfund_plot/renderers/desktop.py +109 -0
- pfund_plot/renderers/notebook.py +92 -0
- pfund_plot/typing.py +29 -0
- pfund_plot/utils/__init__.py +176 -0
- pfund_plot/utils/bokeh.py +177 -0
- pfund_plot/widgets/base.py +76 -0
- pfund_plot/widgets/datetime_widget.py +221 -0
- pfund_plot/widgets/ticker_widget.py +82 -0
- pfund_plot-0.0.1.dist-info/METADATA +148 -0
- pfund_plot-0.0.1.dist-info/RECORD +57 -0
- pfund_plot-0.0.1.dist-info/WHEEL +4 -0
- pfund_plot-0.0.1.dist-info/entry_points.txt +6 -0
pfund_plot/typing.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, TypeAlias
|
|
4
|
+
|
|
5
|
+
# NOTE: these are kept under TYPE_CHECKING because the backend libs (plotly, altair,
|
|
6
|
+
# matplotlib, marimo, ...) are OPTIONAL dependencies. Importing them at runtime would
|
|
7
|
+
# make `import pfund_plot.typing` crash for users who didn't install that extra.
|
|
8
|
+
# Guarding here (+ `from __future__ import annotations` in consumers) keeps the names
|
|
9
|
+
# available to type-checkers/IDEs while never importing the optional libs at runtime.
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from altair import Chart as AltairChart
|
|
12
|
+
from anywidget import AnyWidget
|
|
13
|
+
from bokeh.plotting._figure import figure as BokehFigure
|
|
14
|
+
from holoviews.core.overlay import Overlay
|
|
15
|
+
from marimo import Html as MarimoHtml
|
|
16
|
+
from matplotlib.figure import Figure as MatplotlibFigure
|
|
17
|
+
from panel.io.threads import StoppableThread
|
|
18
|
+
from panel.layout import Panel
|
|
19
|
+
from panel.widgets import Widget
|
|
20
|
+
from plotly.graph_objects import Figure as PlotlyFigure
|
|
21
|
+
|
|
22
|
+
RawFigure = BokehFigure | PlotlyFigure | AltairChart | MatplotlibFigure
|
|
23
|
+
Figure = RawFigure | AnyWidget
|
|
24
|
+
Plot = Overlay | AnyWidget
|
|
25
|
+
Component = Panel | Widget | MarimoHtml
|
|
26
|
+
RenderedResult = Component | StoppableThread
|
|
27
|
+
|
|
28
|
+
Style: TypeAlias = dict[str, Any]
|
|
29
|
+
Control: TypeAlias = dict[str, Any]
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# pyright: reportUnknownMemberType=false, reportUnusedImport=false, reportMissingImports=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from altair import Chart as AltairChart
|
|
9
|
+
from bokeh.model import Model as BokehModel
|
|
10
|
+
from holoviews.core import Dimensioned as HoloviewsDimensioned
|
|
11
|
+
from matplotlib.figure import Figure as MatplotlibFigure
|
|
12
|
+
from narwhals.typing import IntoFrame
|
|
13
|
+
from plotly.graph_objects import Figure as PlotlyFigure
|
|
14
|
+
|
|
15
|
+
from pfund_plot.plots.lazy import LazyPlot
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import narwhals as nw
|
|
21
|
+
from pfeed.enums import DataTool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_js(path: str) -> str:
|
|
25
|
+
js_code = Path(path).read_text()
|
|
26
|
+
return f"<script>{js_code}</script>"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def convert_to_datetime(
|
|
30
|
+
date: str | datetime.datetime | pd.Timestamp,
|
|
31
|
+
) -> datetime.datetime:
|
|
32
|
+
"""Convert various date types to a naive UTC datetime.
|
|
33
|
+
|
|
34
|
+
Always returns tz-naive datetime (in UTC) because Panel/Bokeh widgets
|
|
35
|
+
don't handle tz-aware datetimes consistently in their internal validation.
|
|
36
|
+
"""
|
|
37
|
+
if isinstance(date, str):
|
|
38
|
+
dt = datetime.datetime.fromisoformat(date)
|
|
39
|
+
elif isinstance(date, datetime.datetime):
|
|
40
|
+
dt = date
|
|
41
|
+
elif isinstance(date, datetime.date):
|
|
42
|
+
dt = datetime.datetime(date.year, date.month, date.day)
|
|
43
|
+
elif isinstance(date, pd.Timestamp):
|
|
44
|
+
dt = date.to_pydatetime()
|
|
45
|
+
else:
|
|
46
|
+
raise ValueError(f"Invalid date type: {type(date)}")
|
|
47
|
+
# convert to UTC then strip tzinfo — Panel widgets don't handle tz-aware datetimes consistently
|
|
48
|
+
if dt.tzinfo is not None:
|
|
49
|
+
dt = dt.astimezone(datetime.UTC).replace(tzinfo=None)
|
|
50
|
+
return dt
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_daily_data(df: nw.DataFrame[Any]) -> bool:
|
|
54
|
+
"""Checks if the 'resolution' column is '1d' and the "ts" column by comparing the first two rows to see if the data is daily data."""
|
|
55
|
+
if df.is_empty():
|
|
56
|
+
return False
|
|
57
|
+
if "resolution" in df.columns and df.select("resolution").row(0)[0] == "1d":
|
|
58
|
+
return True
|
|
59
|
+
assert "date" in df.columns, "DataFrame must have a 'date' column"
|
|
60
|
+
assert isinstance(df.select("date").row(0)[0], datetime.datetime), (
|
|
61
|
+
'"date" column must be of type datetime'
|
|
62
|
+
)
|
|
63
|
+
date1 = df.select("date").row(0)[0]
|
|
64
|
+
if df.shape[0] >= 2:
|
|
65
|
+
date2 = df.select("date").row(1)[0]
|
|
66
|
+
delta = date2 - date1
|
|
67
|
+
return delta == datetime.timedelta(days=1)
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_panel_extensions(extensions: list[str] | None = None):
|
|
72
|
+
import panel as pn
|
|
73
|
+
|
|
74
|
+
extensions = extensions or []
|
|
75
|
+
for extension in extensions:
|
|
76
|
+
if extension not in pn.extension._loaded_extensions:
|
|
77
|
+
pn.extension(extension)
|
|
78
|
+
print(f"loaded Panel extension: {extension}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def match_df_with_data_tool(df: IntoFrame) -> DataTool:
|
|
82
|
+
import pandas as pd
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
import polars as pl
|
|
86
|
+
except ImportError:
|
|
87
|
+
pl = None
|
|
88
|
+
try:
|
|
89
|
+
import dask.dataframe as dd
|
|
90
|
+
except ImportError:
|
|
91
|
+
dd = None
|
|
92
|
+
|
|
93
|
+
if isinstance(df, pd.DataFrame):
|
|
94
|
+
return DataTool.pandas
|
|
95
|
+
elif pl and isinstance(df, (pl.DataFrame, pl.LazyFrame)):
|
|
96
|
+
return DataTool.polars
|
|
97
|
+
elif dd and isinstance(df, dd.DataFrame):
|
|
98
|
+
return DataTool.dask
|
|
99
|
+
else:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Unsupported dataframe type: {type(df)!r}. "
|
|
102
|
+
+ "Expected a pandas.DataFrame, polars.DataFrame/LazyFrame, or dask.dataframe.DataFrame."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def import_hvplot_df_module(data_tool: DataTool | str) -> None:
|
|
107
|
+
|
|
108
|
+
data_tool = DataTool[data_tool.lower()]
|
|
109
|
+
if data_tool == DataTool.pandas:
|
|
110
|
+
import hvplot.pandas
|
|
111
|
+
elif data_tool == DataTool.polars:
|
|
112
|
+
import hvplot.polars
|
|
113
|
+
elif data_tool == DataTool.dask:
|
|
114
|
+
import hvplot.dask # noqa: F401
|
|
115
|
+
else:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Unsupported data tool: {data_tool}, must be one of ['pandas', 'polars', 'dask']"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def convert_to_lazy_plot(
|
|
122
|
+
obj: PlotlyFigure
|
|
123
|
+
| AltairChart
|
|
124
|
+
| MatplotlibFigure
|
|
125
|
+
| BokehModel
|
|
126
|
+
| HoloviewsDimensioned,
|
|
127
|
+
) -> LazyPlot:
|
|
128
|
+
"""Convert plotting library figures to LazyPlot instances."""
|
|
129
|
+
import pfund_plot as plt
|
|
130
|
+
|
|
131
|
+
# Plotly
|
|
132
|
+
try:
|
|
133
|
+
import plotly.graph_objects as go
|
|
134
|
+
|
|
135
|
+
if isinstance(obj, go.Figure):
|
|
136
|
+
return plt.plotly(obj)
|
|
137
|
+
except ImportError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
# Altair (TopLevelMixin is the common base for Chart, LayerChart, HConcatChart, VConcatChart, ConcatChart, FacetChart, RepeatChart)
|
|
141
|
+
try:
|
|
142
|
+
from altair import TopLevelMixin
|
|
143
|
+
|
|
144
|
+
if isinstance(obj, TopLevelMixin):
|
|
145
|
+
return plt.altair(obj)
|
|
146
|
+
except ImportError:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
# Matplotlib
|
|
150
|
+
try:
|
|
151
|
+
from matplotlib.figure import Figure as MatplotlibFigure
|
|
152
|
+
|
|
153
|
+
if isinstance(obj, MatplotlibFigure):
|
|
154
|
+
return plt.matplotlib(obj)
|
|
155
|
+
except ImportError:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
# Bokeh
|
|
159
|
+
try:
|
|
160
|
+
from bokeh.model import Model as BokehModel
|
|
161
|
+
|
|
162
|
+
if isinstance(obj, BokehModel):
|
|
163
|
+
return plt.bokeh(obj)
|
|
164
|
+
except ImportError:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
# Holoviews
|
|
168
|
+
try:
|
|
169
|
+
from holoviews.core import Dimensioned
|
|
170
|
+
|
|
171
|
+
if isinstance(obj, Dimensioned):
|
|
172
|
+
return plt.holoviews(obj)
|
|
173
|
+
except ImportError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
return obj
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# pyright: reportArgumentType=false, reportUnknownMemberType=false, reportUnknownVariableType=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
import narwhals as nw
|
|
8
|
+
from bokeh.models import CustomJSHover, HoverTool
|
|
9
|
+
|
|
10
|
+
DatetimePrecision = Literal["d", "s", "ms"]
|
|
11
|
+
|
|
12
|
+
DATETIME_PRECISION_FORMATS: dict[DatetimePrecision, str] = {
|
|
13
|
+
"d": "%Y-%m-%d",
|
|
14
|
+
"s": "%Y-%m-%d %H:%M:%S",
|
|
15
|
+
"ms": "%Y-%m-%d %H:%M:%S.%3N",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_datetime_hover_format(datetime_precision: DatetimePrecision) -> str:
|
|
20
|
+
if datetime_precision not in DATETIME_PRECISION_FORMATS:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
f"Unsupported datetime_precision: {datetime_precision!r}, must be one of {list(DATETIME_PRECISION_FORMATS)}"
|
|
23
|
+
)
|
|
24
|
+
return DATETIME_PRECISION_FORMATS[datetime_precision]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_number_formatter_for_hover_tool(
|
|
28
|
+
significant_digits: int = 6,
|
|
29
|
+
) -> CustomJSHover:
|
|
30
|
+
"""Create a number formatter for bokeh hover tools.
|
|
31
|
+
Args:
|
|
32
|
+
significant_digits: Number of significant digits to display
|
|
33
|
+
"""
|
|
34
|
+
from bokeh.models import CustomJSHover
|
|
35
|
+
|
|
36
|
+
return CustomJSHover(
|
|
37
|
+
code=f"""
|
|
38
|
+
return value.toPrecision({significant_digits}).replace(/0+$/, '').replace(/\\.$/, '');
|
|
39
|
+
"""
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_hover_col_format(
|
|
44
|
+
df: nw.DataFrame[Any],
|
|
45
|
+
col: str,
|
|
46
|
+
datetime_precision: DatetimePrecision = "s",
|
|
47
|
+
) -> tuple[tuple[str, str], tuple[str, str | CustomJSHover] | None]:
|
|
48
|
+
"""Create the tooltip and formatter for a single column based on its dtype.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
(tooltip, formatter_entry or None)
|
|
52
|
+
- tooltip: e.g. ("date", "@{date}{%Y-%m-%d %H:%M:%S}")
|
|
53
|
+
- formatter_entry: e.g. ("@{date}", "datetime"), or None if no formatter needed
|
|
54
|
+
"""
|
|
55
|
+
num_formatter = create_number_formatter_for_hover_tool()
|
|
56
|
+
schema = df.collect_schema()
|
|
57
|
+
col_dtype = schema[col]
|
|
58
|
+
is_datetime = (
|
|
59
|
+
"datetime" in str(col_dtype).lower() or "date" in str(col_dtype).lower()
|
|
60
|
+
)
|
|
61
|
+
if is_datetime:
|
|
62
|
+
date_format = get_datetime_hover_format(datetime_precision)
|
|
63
|
+
return (col, f"@{{{col}}}{{{date_format}}}"), (f"@{{{col}}}", "datetime")
|
|
64
|
+
elif col_dtype.is_numeric():
|
|
65
|
+
return (col, f"@{{{col}}}{{custom}}"), (f"@{{{col}}}", num_formatter)
|
|
66
|
+
else:
|
|
67
|
+
return (col, f"@{{{col}}}"), None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _bundle_hover_config(
|
|
71
|
+
df: nw.DataFrame[Any],
|
|
72
|
+
x_col: str | None,
|
|
73
|
+
y_cols: list[str],
|
|
74
|
+
datetime_precision: DatetimePrecision = "s",
|
|
75
|
+
) -> tuple[list[tuple[str, str]], dict[str, str | CustomJSHover]]:
|
|
76
|
+
"""Build a single tooltip that bundles x + all y_cols together.
|
|
77
|
+
|
|
78
|
+
Only works when a single renderer's ColumnDataSource contains all the columns
|
|
79
|
+
(e.g. single-y plots, or the scatter workaround). For multi-y overlays where
|
|
80
|
+
hvplot creates separate renderers per y column, use create_hover_col_format()
|
|
81
|
+
to build per-column tooltips instead.
|
|
82
|
+
"""
|
|
83
|
+
tooltips: list[tuple[str, str]] = []
|
|
84
|
+
formatters: dict[str, str | CustomJSHover] = {}
|
|
85
|
+
cols = ([x_col] if x_col is not None else []) + y_cols
|
|
86
|
+
for col in cols:
|
|
87
|
+
tooltip, formatter_entry = create_hover_col_format(df, col, datetime_precision)
|
|
88
|
+
tooltips.append(tooltip)
|
|
89
|
+
if formatter_entry is not None:
|
|
90
|
+
formatters[formatter_entry[0]] = formatter_entry[1]
|
|
91
|
+
return tooltips, formatters
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def create_bundled_hover_tool(
|
|
95
|
+
df: nw.DataFrame[Any],
|
|
96
|
+
x_col: str | None,
|
|
97
|
+
y_cols: list[str],
|
|
98
|
+
datetime_precision: DatetimePrecision = "s",
|
|
99
|
+
) -> HoverTool:
|
|
100
|
+
"""Create a HoverTool that shows all specified columns in one tooltip.
|
|
101
|
+
|
|
102
|
+
Only use when a single renderer's ColumnDataSource contains all the columns
|
|
103
|
+
(e.g. single-y plots, or the scatter workaround where one Scatter holds the
|
|
104
|
+
full dataframe). For multi-y overlays with separate renderers per y column
|
|
105
|
+
"""
|
|
106
|
+
from bokeh.models import HoverTool
|
|
107
|
+
|
|
108
|
+
tooltips, formatters = _bundle_hover_config(df, x_col, y_cols, datetime_precision)
|
|
109
|
+
return HoverTool(tooltips=tooltips, formatters=formatters, mode="vline")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def create_hover_scatter(
|
|
113
|
+
df: nw.DataFrame[Any],
|
|
114
|
+
x_col: str | None,
|
|
115
|
+
y_cols: list[str],
|
|
116
|
+
datetime_precision: DatetimePrecision = "s",
|
|
117
|
+
marker: str | None = None,
|
|
118
|
+
) -> Any:
|
|
119
|
+
"""Workaround: create a scatter overlay that provides hover tooltips.
|
|
120
|
+
|
|
121
|
+
Some hvplot chart types (e.g. area) render as Bokeh glyphs that transform
|
|
122
|
+
the data (e.g. Patch polygons), destroying the original column values in
|
|
123
|
+
the ColumnDataSource.
|
|
124
|
+
|
|
125
|
+
This works by overlaying scatter points whose ColumnDataSource contains the
|
|
126
|
+
original, untransformed data, so a HoverTool attached to it can reference
|
|
127
|
+
the real column values.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
marker: marker shape string (e.g. 'circle', 'cross'). If None, points
|
|
131
|
+
are invisible (alpha=0) but still large enough for Bokeh's hit-testing.
|
|
132
|
+
"""
|
|
133
|
+
import holoviews as hv
|
|
134
|
+
|
|
135
|
+
native_df = df.to_native()
|
|
136
|
+
hover_tool = create_bundled_hover_tool(df, x_col, y_cols, datetime_precision)
|
|
137
|
+
scatter_opts: dict[str, Any] = dict(
|
|
138
|
+
size=7 if marker else 15,
|
|
139
|
+
alpha=1 if marker else 0,
|
|
140
|
+
marker=marker or "circle",
|
|
141
|
+
tools=[hover_tool],
|
|
142
|
+
hover_mode="vline",
|
|
143
|
+
)
|
|
144
|
+
kdims = [x_col or "index"]
|
|
145
|
+
|
|
146
|
+
if len(y_cols) == 1:
|
|
147
|
+
return hv.Scatter(native_df, kdims=kdims, vdims=[y_cols[0]]).opts(
|
|
148
|
+
**scatter_opts
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
scatters = {
|
|
152
|
+
y_col: hv.Scatter(native_df, kdims=kdims, vdims=[y_col]).opts(
|
|
153
|
+
**scatter_opts
|
|
154
|
+
)
|
|
155
|
+
for y_col in y_cols
|
|
156
|
+
}
|
|
157
|
+
return hv.NdOverlay(scatters)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def create_vline_hover_opts(
|
|
161
|
+
df: nw.DataFrame[Any],
|
|
162
|
+
x_col: str | None,
|
|
163
|
+
y_cols: list[str],
|
|
164
|
+
datetime_precision: DatetimePrecision = "s",
|
|
165
|
+
) -> Any:
|
|
166
|
+
"""This shows the tooltip without needing the cursor to hover over the data points
|
|
167
|
+
Useful in plots like plt.line() for multi-line plots where the tooltips are separated for each line
|
|
168
|
+
"""
|
|
169
|
+
from holoviews import opts
|
|
170
|
+
|
|
171
|
+
tooltips, formatters = _bundle_hover_config(df, x_col, y_cols, datetime_precision)
|
|
172
|
+
return opts.Curve(
|
|
173
|
+
tools=["hover"],
|
|
174
|
+
hover_mode="vline",
|
|
175
|
+
hover_tooltips=[("series", "@{Variable}"), *tooltips],
|
|
176
|
+
hover_formatters=formatters,
|
|
177
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import narwhals as nw
|
|
9
|
+
import panel as pn
|
|
10
|
+
|
|
11
|
+
from pfund_plot.plots.plot import MessageKey, StreamingDfs
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseWidget(ABC):
|
|
15
|
+
# Columns this widget requires in the df (e.g. ["date"]).
|
|
16
|
+
REQUIRED_COLS: ClassVar[list[str] | None] = None
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
df: nw.DataFrame[Any],
|
|
21
|
+
control: dict[str, Any],
|
|
22
|
+
update_callback: Callable[[nw.DataFrame[Any]], None],
|
|
23
|
+
):
|
|
24
|
+
self._df = df
|
|
25
|
+
self._control: dict[str, Any] = control
|
|
26
|
+
self._update_callback = update_callback
|
|
27
|
+
self._overlays: list[BaseWidget] = []
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def update_df(self, df: nw.DataFrame[Any]) -> None: ...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def get_panel_objects(self) -> list[pn.widgets.Widget]:
|
|
34
|
+
"""Return the Panel widget objects to be placed in the toolbox."""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
def can_merge_with(self, other: BaseWidget) -> bool:
|
|
38
|
+
"""Can this widget merge with another widget of the same class?
|
|
39
|
+
Same class = same REQUIRED_COLS = always compatible.
|
|
40
|
+
"""
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
def add_overlay(self, other: BaseWidget) -> None:
|
|
44
|
+
"""Register an overlay widget so this widget's actions also update the overlay."""
|
|
45
|
+
self._overlays.append(other)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BaseStreamingWidget(ABC):
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
streaming_dfs: StreamingDfs,
|
|
52
|
+
active_key: MessageKey,
|
|
53
|
+
update_callback: Callable[[MessageKey], None],
|
|
54
|
+
):
|
|
55
|
+
self._streaming_dfs = streaming_dfs
|
|
56
|
+
self._active_key = active_key
|
|
57
|
+
self._update_callback = update_callback
|
|
58
|
+
self._overlays: list[BaseStreamingWidget] = []
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def update_streaming_state(self, streaming_dfs: StreamingDfs) -> None: ...
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def get_panel_objects(self) -> list[pn.widgets.Widget]:
|
|
65
|
+
"""Return the Panel widget objects to be placed in the toolbox."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
def can_merge_with(self, other: BaseStreamingWidget) -> bool:
|
|
69
|
+
"""Can this widget merge with another streaming widget of the same class?
|
|
70
|
+
Merge if they operate on the same set of msg_keys (i.e. same feed).
|
|
71
|
+
"""
|
|
72
|
+
return set(self._streaming_dfs.keys()) == set(other._streaming_dfs.keys())
|
|
73
|
+
|
|
74
|
+
def add_overlay(self, other: BaseStreamingWidget) -> None:
|
|
75
|
+
"""Register an overlay streaming widget so this widget's actions also update the overlay."""
|
|
76
|
+
self._overlays.append(other)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# pyright: reportUnknownMemberType=false, reportGeneralTypeIssues=false, reportUnusedParameter=false, reportUnknownVariableType=false, reportArgumentType=false, reportUnknownArgumentType=false, reportAttributeAccessIssue=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from narwhals.typing import Frame
|
|
9
|
+
from param.parameterized import Event
|
|
10
|
+
|
|
11
|
+
import datetime
|
|
12
|
+
|
|
13
|
+
import narwhals as nw
|
|
14
|
+
import panel as pn
|
|
15
|
+
|
|
16
|
+
from pfund_plot.utils import convert_to_datetime
|
|
17
|
+
from pfund_plot.widgets.base import BaseWidget
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def round_date(dt: datetime.datetime, to: str = "floor") -> datetime.datetime:
|
|
21
|
+
"""Round a datetime to the nearest second boundary.
|
|
22
|
+
|
|
23
|
+
When a user drags the DatetimeRangeSlider, Bokeh snaps BOTH slider handles
|
|
24
|
+
to the nearest step boundary (e.g. every 5000ms). This snapping truncates
|
|
25
|
+
sub-second precision, producing a value like 04:55:19.000 even if the original
|
|
26
|
+
datetime was 04:55:19.732. When that snapped value is synced back to the
|
|
27
|
+
DatetimeRangeInput, Panel validates it against the input's start/end bounds.
|
|
28
|
+
If the bound still has the original sub-second precision (04:55:19.732),
|
|
29
|
+
the snapped value (04:55:19.000) falls outside the bound and validation fails.
|
|
30
|
+
|
|
31
|
+
To prevent this, we round the bounds: floor the start date and ceil the end date,
|
|
32
|
+
so the bounds are always wider than any value the slider step quantization can produce.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
dt: the datetime to round
|
|
36
|
+
to: 'floor' to round down (strip microseconds), 'ceil' to round up (next whole second)
|
|
37
|
+
"""
|
|
38
|
+
if to == "floor":
|
|
39
|
+
return dt.replace(microsecond=0)
|
|
40
|
+
elif to == "ceil":
|
|
41
|
+
return dt.replace(microsecond=0) + datetime.timedelta(seconds=1)
|
|
42
|
+
else:
|
|
43
|
+
raise ValueError(f"Invalid rounding direction: {to}, must be 'floor' or 'ceil'")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DatetimeRangeWidget(BaseWidget):
|
|
47
|
+
REQUIRED_COLS: ClassVar[list[str] | None] = ["date"]
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
df: nw.DataFrame[Any],
|
|
52
|
+
control: dict[str, Any],
|
|
53
|
+
update_callback: Callable[[nw.DataFrame[Any]], None],
|
|
54
|
+
):
|
|
55
|
+
super().__init__(df, control, update_callback)
|
|
56
|
+
date_col = self._df["date"]
|
|
57
|
+
num_data_shown = date_col.len()
|
|
58
|
+
if "num_data" in control and control["num_data"] is not None:
|
|
59
|
+
num_data_shown = min(control["num_data"], num_data_shown)
|
|
60
|
+
start_date, end_date = (
|
|
61
|
+
convert_to_datetime(date_col[0]),
|
|
62
|
+
convert_to_datetime(date_col[-1]),
|
|
63
|
+
)
|
|
64
|
+
start_date = round_date(start_date, to="floor")
|
|
65
|
+
end_date = round_date(end_date, to="ceil")
|
|
66
|
+
data_shown_start_date = round_date(
|
|
67
|
+
convert_to_datetime(date_col[-num_data_shown]), to="floor"
|
|
68
|
+
)
|
|
69
|
+
self._datetime_range_input = pn.widgets.DatetimeRangeInput(
|
|
70
|
+
name="Datetime Range Input",
|
|
71
|
+
start=start_date,
|
|
72
|
+
end=end_date,
|
|
73
|
+
value=(data_shown_start_date, end_date),
|
|
74
|
+
width=150,
|
|
75
|
+
)
|
|
76
|
+
self._input_watcher = self._datetime_range_input.param.watch(
|
|
77
|
+
self._update_datetime_range_input, "value"
|
|
78
|
+
)
|
|
79
|
+
self._datetime_range_slider = pn.widgets.DatetimeRangeSlider(
|
|
80
|
+
name="Period",
|
|
81
|
+
start=start_date,
|
|
82
|
+
end=end_date,
|
|
83
|
+
value=(data_shown_start_date, end_date),
|
|
84
|
+
step=control["slider_step"] or self._derive_slider_step(),
|
|
85
|
+
)
|
|
86
|
+
self._slider_watcher = self._datetime_range_slider.param.watch(
|
|
87
|
+
self._update_datetime_range_slider, "value"
|
|
88
|
+
)
|
|
89
|
+
# self._max_data = pn.rx(df.shape[0])
|
|
90
|
+
# self._data_slider = pn.widgets.IntSlider(
|
|
91
|
+
# name='Number of Most Recent Data Points',
|
|
92
|
+
# value=num_data_shown,
|
|
93
|
+
# start=control['num_data'],
|
|
94
|
+
# end=self._max_data,
|
|
95
|
+
# step=control['slider_step']
|
|
96
|
+
# )
|
|
97
|
+
# self._data_slider.param.watch(self._update_data_slider, 'value')
|
|
98
|
+
# self._show_all_button = pn.widgets.Button(name='Show All', button_type='primary')
|
|
99
|
+
# self._show_all_button.on_click(self._max_out_data_slider)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def datetime_range_input(self) -> pn.widgets.DatetimeRangeInput:
|
|
103
|
+
return self._datetime_range_input
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def datetime_range_slider(self) -> pn.widgets.DatetimeRangeSlider:
|
|
107
|
+
return self._datetime_range_slider
|
|
108
|
+
|
|
109
|
+
def get_panel_objects(self) -> list[pn.widgets.Widget]:
|
|
110
|
+
return [self._datetime_range_input, self._datetime_range_slider]
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _filter_df(
|
|
114
|
+
df: nw.DataFrame[Any],
|
|
115
|
+
start_date: datetime.datetime,
|
|
116
|
+
end_date: datetime.datetime,
|
|
117
|
+
) -> Frame:
|
|
118
|
+
return df.filter((nw.col("date") >= start_date) & (nw.col("date") <= end_date))
|
|
119
|
+
|
|
120
|
+
def _fan_out_to_overlays(
|
|
121
|
+
self, start_date: datetime.datetime, end_date: datetime.datetime
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Filter each overlay widget's full df by the same date range and trigger its update.
|
|
124
|
+
Must be called BEFORE the parent's _update_callback so the DynamicMap re-render
|
|
125
|
+
picks up the updated overlay dfs.
|
|
126
|
+
"""
|
|
127
|
+
for overlay_widget in self._overlays:
|
|
128
|
+
filtered = self._filter_df(overlay_widget._df, start_date, end_date)
|
|
129
|
+
overlay_widget._update_callback(filtered)
|
|
130
|
+
|
|
131
|
+
def _derive_slider_step(self) -> int:
|
|
132
|
+
date_col = self._df["date"]
|
|
133
|
+
# infer resolution from data
|
|
134
|
+
resolution_ms = (date_col[1] - date_col[0]).total_seconds() * 1000
|
|
135
|
+
# use 5x resolution as step, so user can move meaningfully but not too coarsely
|
|
136
|
+
slider_step = int(resolution_ms * 5)
|
|
137
|
+
return slider_step
|
|
138
|
+
|
|
139
|
+
def _update_datetime_range_input(self, event: Event):
|
|
140
|
+
start_date, end_date = self._datetime_range_input.value
|
|
141
|
+
# silently update the _datetime_range_slider as well, temporarily remove the watcher
|
|
142
|
+
self._datetime_range_slider.param.unwatch(self._slider_watcher)
|
|
143
|
+
try:
|
|
144
|
+
_ = self._datetime_range_slider.param.update(value=(start_date, end_date))
|
|
145
|
+
finally:
|
|
146
|
+
self._slider_watcher = self._datetime_range_slider.param.watch(
|
|
147
|
+
self._update_datetime_range_slider, "value"
|
|
148
|
+
)
|
|
149
|
+
# update overlay dfs BEFORE parent re-render so DynamicMap picks them up
|
|
150
|
+
self._fan_out_to_overlays(start_date, end_date)
|
|
151
|
+
df_filtered = self._filter_df(self._df, start_date, end_date)
|
|
152
|
+
self._update_callback(df_filtered)
|
|
153
|
+
|
|
154
|
+
def _update_datetime_range_slider(self, event: Event):
|
|
155
|
+
start_date, end_date = self._datetime_range_slider.value
|
|
156
|
+
# silently update the _datetime_range_input as well, temporarily remove the watcher
|
|
157
|
+
self._datetime_range_input.param.unwatch(self._input_watcher)
|
|
158
|
+
try:
|
|
159
|
+
_ = self._datetime_range_input.param.update(value=(start_date, end_date))
|
|
160
|
+
finally:
|
|
161
|
+
self._input_watcher = self._datetime_range_input.param.watch(
|
|
162
|
+
self._update_datetime_range_input, "value"
|
|
163
|
+
)
|
|
164
|
+
# update overlay dfs BEFORE parent re-render so DynamicMap picks them up
|
|
165
|
+
self._fan_out_to_overlays(start_date, end_date)
|
|
166
|
+
df_filtered = self._filter_df(self._df, start_date, end_date)
|
|
167
|
+
self._update_callback(df_filtered)
|
|
168
|
+
|
|
169
|
+
def update_df(self, df: nw.DataFrame[Any]):
|
|
170
|
+
"""Update widget bounds and df reference for new df (currently only used when receiving streaming data)."""
|
|
171
|
+
self._df = df
|
|
172
|
+
if self._df.shape[0] < 2:
|
|
173
|
+
raise ValueError("df must have at least 2 rows")
|
|
174
|
+
date_col = df["date"]
|
|
175
|
+
new_end = round_date(convert_to_datetime(date_col[-1]), to="ceil")
|
|
176
|
+
|
|
177
|
+
self._datetime_range_input.param.unwatch(self._input_watcher)
|
|
178
|
+
self._datetime_range_slider.param.unwatch(self._slider_watcher)
|
|
179
|
+
try:
|
|
180
|
+
# check if slider was at the end before updating
|
|
181
|
+
# NOTE: strip tzinfo before comparing because Panel/Bokeh may return
|
|
182
|
+
# tz-aware datetimes from .value after user interaction
|
|
183
|
+
slider_start, slider_end = self._datetime_range_slider.value
|
|
184
|
+
|
|
185
|
+
was_at_end = convert_to_datetime(slider_end) >= convert_to_datetime(
|
|
186
|
+
self._datetime_range_slider.end
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# expand bounds
|
|
190
|
+
self._datetime_range_slider.end = new_end
|
|
191
|
+
self._datetime_range_input.end = new_end
|
|
192
|
+
|
|
193
|
+
# auto-extend value to include new data if slider was at the end
|
|
194
|
+
if was_at_end:
|
|
195
|
+
_ = self._datetime_range_slider.param.update(
|
|
196
|
+
value=(slider_start, new_end)
|
|
197
|
+
)
|
|
198
|
+
_ = self._datetime_range_input.param.update(
|
|
199
|
+
value=(slider_start, new_end)
|
|
200
|
+
)
|
|
201
|
+
finally:
|
|
202
|
+
self._input_watcher = self._datetime_range_input.param.watch(
|
|
203
|
+
self._update_datetime_range_input, "value"
|
|
204
|
+
)
|
|
205
|
+
self._slider_watcher = self._datetime_range_slider.param.watch(
|
|
206
|
+
self._update_datetime_range_slider, "value"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# @property
|
|
210
|
+
# def data_slider(self) -> pn.widgets.IntSlider:
|
|
211
|
+
# return self._data_slider
|
|
212
|
+
|
|
213
|
+
# @property
|
|
214
|
+
# def show_all_button(self) -> pn.widgets.Button:
|
|
215
|
+
# return self._show_all_button
|
|
216
|
+
|
|
217
|
+
# def _update_data_slider(self, event: Event):
|
|
218
|
+
# self._update_callback(self._df.tail(self._data_slider.value))
|
|
219
|
+
|
|
220
|
+
# def _max_out_data_slider(self, event: Event):
|
|
221
|
+
# self._data_slider.value = self._max_data.rx.value
|