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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# pyright: reportArgumentType=false, reportOptionalMemberAccess=false, reportOptionalSubscript=false, reportCallIssue=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportAttributeAccessIssue=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pfeed.requests.market_feed_stream_request import MarketFeedStreamRequest
|
|
8
|
+
from pfeed.streaming import BarMessage, TickMessage
|
|
9
|
+
from pfeed.streaming.market_data_message import MarketDataMessage
|
|
10
|
+
from pfund.datas.resolution import Resolution
|
|
11
|
+
|
|
12
|
+
from pfund_plot.plots.plot import MessageKey
|
|
13
|
+
|
|
14
|
+
import narwhals as nw
|
|
15
|
+
from pfund_kit.style import RichColor, TextStyle, cprint
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StreamingMarketFeedMixin:
|
|
19
|
+
def _is_streaming_ready(self) -> bool:
|
|
20
|
+
"""Return True if all streaming dataframes have at least 2 rows (needed for e.g. ohlc to compute candle width)."""
|
|
21
|
+
if not self._streaming_dfs:
|
|
22
|
+
return False
|
|
23
|
+
return all(df.shape[0] >= 2 for df in self._streaming_dfs.values())
|
|
24
|
+
|
|
25
|
+
def _start_streaming(self):
|
|
26
|
+
from pfund_plot.utils import import_hvplot_df_module
|
|
27
|
+
|
|
28
|
+
# streaming always uses polars internally (see _create_streaming_row)
|
|
29
|
+
import_hvplot_df_module("polars")
|
|
30
|
+
|
|
31
|
+
requests = cast("list[MarketFeedStreamRequest]", self._feed._requests)
|
|
32
|
+
for request in requests:
|
|
33
|
+
resolution = cast("Resolution", request.target_resolution)
|
|
34
|
+
if resolution.is_bar():
|
|
35
|
+
if self._y is not None:
|
|
36
|
+
assert self._y in ["open", "high", "low", "close", "volume"], (
|
|
37
|
+
"y must be 'open', 'high', 'low', 'close', or 'volume' when streaming bar data"
|
|
38
|
+
)
|
|
39
|
+
elif resolution.is_tick():
|
|
40
|
+
if self._y is not None:
|
|
41
|
+
assert self._y in ["price", "volume"], (
|
|
42
|
+
"y must be 'price' or 'volume' when streaming tick data"
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
raise ValueError(f"Unsupported resolution: {resolution}")
|
|
46
|
+
|
|
47
|
+
super()._start_streaming()
|
|
48
|
+
|
|
49
|
+
def _create_streaming_row(self, msg: MarketDataMessage) -> nw.DataFrame[Any]:
|
|
50
|
+
import polars as pl
|
|
51
|
+
|
|
52
|
+
if msg.is_tick():
|
|
53
|
+
tick_msg = cast("TickMessage", msg)
|
|
54
|
+
return nw.from_native(
|
|
55
|
+
pl.DataFrame(
|
|
56
|
+
{
|
|
57
|
+
# msg.ts is int64 ns since epoch; from_epoch yields a tz-naive
|
|
58
|
+
# (already UTC) datetime[ns], preserving nanosecond precision
|
|
59
|
+
"date": pl.from_epoch(pl.Series([tick_msg.ts]), time_unit="ns"),
|
|
60
|
+
"price": [tick_msg.price],
|
|
61
|
+
# volume can be None (e.g. yahoo finance); pin dtype so a leading
|
|
62
|
+
# None row doesn't become a Null column and break later concats
|
|
63
|
+
"volume": pl.Series([tick_msg.volume], dtype=pl.Float64),
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
elif msg.is_bar():
|
|
68
|
+
bar_msg = cast("BarMessage", msg)
|
|
69
|
+
return nw.from_native(
|
|
70
|
+
pl.DataFrame(
|
|
71
|
+
{
|
|
72
|
+
# bar_msg.start_ts is int64 ns since epoch (start of bar);
|
|
73
|
+
# from_epoch yields a tz-naive (already UTC) datetime[ns]
|
|
74
|
+
"date": pl.from_epoch(
|
|
75
|
+
pl.Series([bar_msg.start_ts]), time_unit="ns"
|
|
76
|
+
),
|
|
77
|
+
"open": [bar_msg.open],
|
|
78
|
+
"high": [bar_msg.high],
|
|
79
|
+
"low": [bar_msg.low],
|
|
80
|
+
"close": [bar_msg.close],
|
|
81
|
+
"volume": [bar_msg.volume],
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError(f"Unsupported streaming message type: {type(msg)}")
|
|
87
|
+
|
|
88
|
+
def _create_streaming_df(
|
|
89
|
+
self, msg_key: MessageKey, msg: MarketDataMessage
|
|
90
|
+
) -> nw.DataFrame[Any]:
|
|
91
|
+
new_row = self._create_streaming_row(msg)
|
|
92
|
+
|
|
93
|
+
if msg_key not in self._streaming_dfs:
|
|
94
|
+
import datetime
|
|
95
|
+
|
|
96
|
+
from pfund.datas.resolution import Resolution
|
|
97
|
+
|
|
98
|
+
# prepend a dummy row so df starts with 2 rows,
|
|
99
|
+
# needed for DatetimeRangeWidget to derive slider step from date_col[1] - date_col[0]
|
|
100
|
+
if msg.is_bar():
|
|
101
|
+
resolution_seconds = Resolution(msg.resolution).to_seconds()
|
|
102
|
+
else:
|
|
103
|
+
# for tick data, use 1 second as the dummy interval
|
|
104
|
+
resolution_seconds = 1
|
|
105
|
+
dummy = new_row.with_columns(
|
|
106
|
+
# cast back to ns: subtracting a timedelta downcasts datetime[ns] -> datetime[us],
|
|
107
|
+
# which would then fail to concat with the ns-precision new_row below
|
|
108
|
+
(nw.col("date") - datetime.timedelta(seconds=resolution_seconds)).cast(
|
|
109
|
+
nw.Datetime("ns")
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
cprint(
|
|
113
|
+
f"Prepending dummy row for {msg_key} to ensure at least 2 data points for the {self._class_name}\n"
|
|
114
|
+
+ "i.e. The first data point is dummy data",
|
|
115
|
+
style=TextStyle.BOLD + RichColor.YELLOW,
|
|
116
|
+
)
|
|
117
|
+
df = nw.concat([dummy, new_row])
|
|
118
|
+
else:
|
|
119
|
+
# update the streaming dataframe
|
|
120
|
+
existing_df = self._streaming_dfs[msg_key]
|
|
121
|
+
last_date = existing_df["date"][-1]
|
|
122
|
+
new_date = new_row["date"][0]
|
|
123
|
+
if new_date == last_date:
|
|
124
|
+
# same bar — replace last row
|
|
125
|
+
if msg.is_bar():
|
|
126
|
+
df = nw.concat([existing_df.head(-1), new_row])
|
|
127
|
+
# NOTE: tick data could have the same timestamp!
|
|
128
|
+
elif msg.is_tick():
|
|
129
|
+
df = nw.concat([existing_df, new_row])
|
|
130
|
+
else:
|
|
131
|
+
raise ValueError(f"Unsupported streaming message type: {type(msg)}")
|
|
132
|
+
elif new_date > last_date:
|
|
133
|
+
df = nw.concat([existing_df, new_row])
|
|
134
|
+
else:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"New date {new_date} is before last date {last_date}, something is wrong with the streaming data"
|
|
137
|
+
)
|
|
138
|
+
return df
|
|
139
|
+
|
|
140
|
+
def _create_msg_key(self, msg: MarketDataMessage) -> MessageKey:
|
|
141
|
+
"""Create a message key for streaming"""
|
|
142
|
+
msg_key = (msg.product, msg.resolution)
|
|
143
|
+
# set the first product as active by default
|
|
144
|
+
if self._active_msg_key is None:
|
|
145
|
+
self._active_msg_key = msg_key
|
|
146
|
+
return msg_key
|
|
147
|
+
|
|
148
|
+
# NOTE: this is added to streaming feed as a custom transformation
|
|
149
|
+
def _on_streaming_callback(self, msg: MarketDataMessage) -> MarketDataMessage:
|
|
150
|
+
# for bar data, skip incremental updates unless incremental_update is enabled
|
|
151
|
+
if (
|
|
152
|
+
msg.is_bar()
|
|
153
|
+
and not self._control["incremental_update"]
|
|
154
|
+
and msg.is_incremental
|
|
155
|
+
): # pyright: ignore[reportAttributeAccessIssue]
|
|
156
|
+
return msg
|
|
157
|
+
|
|
158
|
+
msg_key = self._create_msg_key(msg)
|
|
159
|
+
df = self._create_streaming_df(msg_key, msg)
|
|
160
|
+
self._update_streaming_df(msg_key, df)
|
|
161
|
+
|
|
162
|
+
return msg
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from altair import Chart as AltairChart
|
|
7
|
+
|
|
8
|
+
from pfund_plot.enums import PlottingBackend
|
|
9
|
+
from pfund_plot.plots.plot import BasePlot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Altair(BasePlot):
|
|
13
|
+
SUPPORTED_BACKENDS: ClassVar[list[Literal[PlottingBackend.altair]]] = [
|
|
14
|
+
PlottingBackend.altair
|
|
15
|
+
]
|
|
16
|
+
REQUIRED_DATA: ClassVar[bool] = False
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, fig: AltairChart, sizing_mode: str | None = None, **pane_kwargs: Any
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Args:
|
|
23
|
+
fig: An Altair Chart object.
|
|
24
|
+
sizing_mode: Panel sizing mode: e.g. "stretch_width", "stretch_height", or "stretch_both".
|
|
25
|
+
**pane_kwargs: Additional keyword arguments passed to pn.pane.Vega,
|
|
26
|
+
e.g. height, width, max_width, margin.
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(data=None)
|
|
29
|
+
self._plot: AltairChart = fig
|
|
30
|
+
self._pane_kwargs = pane_kwargs
|
|
31
|
+
if sizing_mode is not None:
|
|
32
|
+
self._pane_kwargs["sizing_mode"] = sizing_mode
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# pyright: reportArgumentType=false, reportOptionalMemberAccess=false, reportOptionalSubscript=false, reportCallIssue=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=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 IntoFrame
|
|
9
|
+
from pfeed.feeds.market_feed import MarketFeed
|
|
10
|
+
|
|
11
|
+
from pfund_plot.widgets.base import BaseStreamingWidget, BaseWidget
|
|
12
|
+
|
|
13
|
+
from pfund_plot.enums import PlottingBackend
|
|
14
|
+
from pfund_plot.plots.plot import BasePlot
|
|
15
|
+
from pfund_plot.widgets.datetime_widget import DatetimeRangeWidget
|
|
16
|
+
from pfund_plot.widgets.ticker_widget import TickerSelectWidget
|
|
17
|
+
|
|
18
|
+
__all__ = ["Area"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AreaStyle:
|
|
22
|
+
from pfund_plot.plots.area.bokeh import style as bokeh_style
|
|
23
|
+
|
|
24
|
+
bokeh = bokeh_style
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AreaControl:
|
|
28
|
+
from pfund_plot.plots.area.bokeh import control as bokeh_control
|
|
29
|
+
|
|
30
|
+
bokeh = bokeh_control
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Area(BasePlot):
|
|
34
|
+
SUPPORTED_BACKENDS: ClassVar[list[PlottingBackend]] = [PlottingBackend.bokeh]
|
|
35
|
+
SUPPORT_STREAMING: ClassVar[bool] = True
|
|
36
|
+
SUPPORTED_WIDGETS: ClassVar[list[type[BaseWidget]]] = [DatetimeRangeWidget]
|
|
37
|
+
SUPPORTED_STREAMING_WIDGETS: ClassVar[list[type[BaseStreamingWidget]]] = [
|
|
38
|
+
TickerSelectWidget
|
|
39
|
+
]
|
|
40
|
+
style = AreaStyle
|
|
41
|
+
control = AreaControl
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
data: IntoFrame | MarketFeed | None = None,
|
|
46
|
+
x: str | None = None,
|
|
47
|
+
y: str | list[str] | None = None,
|
|
48
|
+
y2: str | None = None,
|
|
49
|
+
callback: Callable[..., Any] | None = None,
|
|
50
|
+
name: str | None = None,
|
|
51
|
+
plot_kwargs: dict[str, Any] | None = None,
|
|
52
|
+
**reactive_params: Any,
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Args:
|
|
56
|
+
data: The dataframe for static plot or pfeed's feed object for streaming plot
|
|
57
|
+
x: Column name for x-positions. If not specified, the index is used.
|
|
58
|
+
Can refer to continuous and categorical data.
|
|
59
|
+
y: Column name for the first y-position (lower bound of the area)
|
|
60
|
+
# REVIEW: y2 is only supported by hvplot
|
|
61
|
+
y2: Column name for the second y-position (upper bound of the area).
|
|
62
|
+
If not specified, the area is drawn from 0 to y.
|
|
63
|
+
callback: A reactive callback function. When provided with **reactive_params,
|
|
64
|
+
auto-creates widgets that re-fetch data on change.
|
|
65
|
+
name: Display name for this plot (used as label when widgets are shown alongside overlays).
|
|
66
|
+
Defaults to the class name lowercased.
|
|
67
|
+
plot_kwargs: keyword arguments for the plot function.
|
|
68
|
+
e.g. if the plot function is hvplot.line, plot_kwargs will be passed to hvplot.line(**plot_kwargs)
|
|
69
|
+
**reactive_params: name=value pairs for reactive widgets (e.g. ticker=["BTC", "ETH"]).
|
|
70
|
+
Requires callback to be set.
|
|
71
|
+
"""
|
|
72
|
+
super().__init__(
|
|
73
|
+
data=data,
|
|
74
|
+
x=x,
|
|
75
|
+
y=y,
|
|
76
|
+
callback=callback,
|
|
77
|
+
name=name,
|
|
78
|
+
plot_kwargs=plot_kwargs,
|
|
79
|
+
**reactive_params,
|
|
80
|
+
)
|
|
81
|
+
if y2 is not None:
|
|
82
|
+
self._plot_kwargs["y2"] = y2
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# pyright: reportUnusedParameter=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from holoviews.core.overlay import NdOverlay
|
|
8
|
+
|
|
9
|
+
import narwhals as nw
|
|
10
|
+
|
|
11
|
+
from pfund_plot.enums import PlottingBackend
|
|
12
|
+
|
|
13
|
+
__all__ = ["control", "plot", "style"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_COLOR = "steelblue"
|
|
17
|
+
DEFAULT_HEIGHT = 280
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def style(
|
|
21
|
+
title: str = "",
|
|
22
|
+
xlabel: str = "",
|
|
23
|
+
ylabel: str = "",
|
|
24
|
+
color: str = DEFAULT_COLOR,
|
|
25
|
+
alpha: float = 0.5,
|
|
26
|
+
marker: Literal[
|
|
27
|
+
"circle",
|
|
28
|
+
"square",
|
|
29
|
+
"triangle_up",
|
|
30
|
+
"triangle_down",
|
|
31
|
+
"diamond",
|
|
32
|
+
"cross",
|
|
33
|
+
"x",
|
|
34
|
+
"star",
|
|
35
|
+
]
|
|
36
|
+
| None = None,
|
|
37
|
+
stacked: bool = True,
|
|
38
|
+
bg_color: str = "", # empty string by default because Panel will automatically use the theme color
|
|
39
|
+
grid: bool = False,
|
|
40
|
+
total_height: int | None = None,
|
|
41
|
+
height: int = DEFAULT_HEIGHT,
|
|
42
|
+
width: int | None = None,
|
|
43
|
+
):
|
|
44
|
+
"""
|
|
45
|
+
Args:
|
|
46
|
+
title: the title of the plot
|
|
47
|
+
xlabel: the label of the x-axis
|
|
48
|
+
ylabel: the label of the y-axis
|
|
49
|
+
color: the color of the plot, hex code is supported, only used when there is only one area chart
|
|
50
|
+
alpha: the alpha of the plot, 0.0 to 1.0
|
|
51
|
+
marker: marker shape for data points. None hides markers, any value shows them.
|
|
52
|
+
Default is None (hidden). Options: 'circle', 'square', 'triangle_up',
|
|
53
|
+
'triangle_down', 'diamond', 'cross', 'x', 'star'.
|
|
54
|
+
stacked: Whether to stack multiple areas. Default is True.
|
|
55
|
+
bg_color: the background color of the plot, hex code is supported
|
|
56
|
+
total_height: the height of the component (including the figure + widgets)
|
|
57
|
+
Default is None, when it is None, Panel will automatically adjust its height
|
|
58
|
+
height: the height of the figure
|
|
59
|
+
width: the width of the plot, since the plot is responsive, this is only used in panel layout
|
|
60
|
+
grid: whether to show the grid
|
|
61
|
+
"""
|
|
62
|
+
return locals()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def control(
|
|
66
|
+
num_data: int | None = None,
|
|
67
|
+
max_data: int | None = None,
|
|
68
|
+
slider_step: int | None = None,
|
|
69
|
+
widgets: bool = True,
|
|
70
|
+
linked_axes: bool = True,
|
|
71
|
+
update_interval: int = 5000, # ms
|
|
72
|
+
incremental_update: bool = True,
|
|
73
|
+
datetime_precision: Literal["d", "s", "ms"] = "s",
|
|
74
|
+
):
|
|
75
|
+
"""
|
|
76
|
+
Args:
|
|
77
|
+
num_data: (DatetimeRangeWidget) initial number of most recent data points to display.
|
|
78
|
+
max_data: (streaming) maximum number of data points kept in memory.
|
|
79
|
+
If None, data will continue to grow unbounded.
|
|
80
|
+
slider_step: (DatetimeRangeWidget) step size in ms for the datetime range slider.
|
|
81
|
+
If None, derived from data resolution.
|
|
82
|
+
widgets: whether to show widgets. default is True.
|
|
83
|
+
For granular control, use remove_widgets() to remove specific widget classes.
|
|
84
|
+
linked_axes: whether to link axes across plots in a layout (plt.layout(...)).
|
|
85
|
+
incremental_update: (streaming) whether to update even when the bar is incomplete. default is True.
|
|
86
|
+
update_interval: (streaming) interval in ms to update the plot. default is 5000 ms.
|
|
87
|
+
datetime_precision: the precision of datetime formatting on the hover tooltip.
|
|
88
|
+
"d" for days (%Y-%m-%d), "s" for seconds (default, %Y-%m-%d %H:%M:%S), "ms" for milliseconds (%Y-%m-%d %H:%M:%S.%3N).
|
|
89
|
+
"""
|
|
90
|
+
return locals()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def plot(
|
|
94
|
+
df: nw.DataFrame[Any],
|
|
95
|
+
style: dict[str, Any],
|
|
96
|
+
control: dict[str, Any],
|
|
97
|
+
x: str | None = None,
|
|
98
|
+
y: str | list[str] | None = None,
|
|
99
|
+
**kwargs: Any,
|
|
100
|
+
) -> NdOverlay:
|
|
101
|
+
import hvplot
|
|
102
|
+
from bokeh.models import CrosshairTool
|
|
103
|
+
|
|
104
|
+
from pfund_plot.plots.area import Area
|
|
105
|
+
|
|
106
|
+
_ = hvplot.extension(PlottingBackend.bokeh)
|
|
107
|
+
|
|
108
|
+
# resolve y column names
|
|
109
|
+
x_col = x
|
|
110
|
+
y_cols = Area._derive_y_cols(df, x, y)
|
|
111
|
+
datetime_precision = control["datetime_precision"]
|
|
112
|
+
|
|
113
|
+
crosshair_tool = CrosshairTool(
|
|
114
|
+
dimensions="height", line_color="gray", line_alpha=0.3
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if len(y_cols) == 1:
|
|
118
|
+
kwargs["color"] = style["color"]
|
|
119
|
+
|
|
120
|
+
area_plot = (
|
|
121
|
+
df.to_native()
|
|
122
|
+
.hvplot.area(
|
|
123
|
+
x=x,
|
|
124
|
+
y=y,
|
|
125
|
+
tools=[crosshair_tool],
|
|
126
|
+
grid=style["grid"],
|
|
127
|
+
bgcolor=style["bg_color"],
|
|
128
|
+
stacked=style["stacked"],
|
|
129
|
+
alpha=style["alpha"],
|
|
130
|
+
responsive=True,
|
|
131
|
+
# Disable hvplot's built-in hover (shows "???") because hvplot converts
|
|
132
|
+
# area to Patch polygons, destroying original column data in ColumnDataSource
|
|
133
|
+
hover=False,
|
|
134
|
+
**kwargs,
|
|
135
|
+
)
|
|
136
|
+
.opts(
|
|
137
|
+
title=style["title"],
|
|
138
|
+
xlabel=style["xlabel"],
|
|
139
|
+
ylabel=style["ylabel"],
|
|
140
|
+
height=style["height"],
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# NOTE: this is not needed if hvplot has fixed the tooltip issue in area plot
|
|
145
|
+
# Overlay invisible scatter points that carry the real data for hover tooltips
|
|
146
|
+
from pfund_plot.utils.bokeh import create_hover_scatter
|
|
147
|
+
|
|
148
|
+
hover_scatter = create_hover_scatter(
|
|
149
|
+
df, x_col, y_cols, datetime_precision, marker=style.get("marker")
|
|
150
|
+
)
|
|
151
|
+
return area_plot * hover_scatter
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# pyright: reportArgumentType=false, reportOptionalMemberAccess=false, reportOptionalSubscript=false, reportCallIssue=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=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 IntoFrame
|
|
9
|
+
from pfeed.feeds.market_feed import MarketFeed
|
|
10
|
+
|
|
11
|
+
from pfund_plot.widgets.base import BaseStreamingWidget, BaseWidget
|
|
12
|
+
|
|
13
|
+
from pfund_plot.enums import PlottingBackend
|
|
14
|
+
from pfund_plot.plots.plot import BasePlot
|
|
15
|
+
from pfund_plot.widgets.datetime_widget import DatetimeRangeWidget
|
|
16
|
+
from pfund_plot.widgets.ticker_widget import TickerSelectWidget
|
|
17
|
+
|
|
18
|
+
__all__ = ["Bar"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BarStyle:
|
|
22
|
+
from pfund_plot.plots.bar.bokeh import style as bokeh_style
|
|
23
|
+
|
|
24
|
+
bokeh = bokeh_style
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BarControl:
|
|
28
|
+
from pfund_plot.plots.bar.bokeh import control as bokeh_control
|
|
29
|
+
|
|
30
|
+
bokeh = bokeh_control
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Bar(BasePlot):
|
|
34
|
+
SUPPORTED_BACKENDS: ClassVar[list[PlottingBackend]] = [PlottingBackend.bokeh]
|
|
35
|
+
SUPPORT_STREAMING: ClassVar[bool] = True
|
|
36
|
+
SUPPORTED_WIDGETS: ClassVar[list[type[BaseWidget]]] = [DatetimeRangeWidget]
|
|
37
|
+
SUPPORTED_STREAMING_WIDGETS: ClassVar[list[type[BaseStreamingWidget]]] = [
|
|
38
|
+
TickerSelectWidget
|
|
39
|
+
]
|
|
40
|
+
style = BarStyle
|
|
41
|
+
control = BarControl
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
data: IntoFrame | MarketFeed | None = None,
|
|
46
|
+
x: str | None = None,
|
|
47
|
+
y: str | list[str] | None = None,
|
|
48
|
+
by: str | list[str] | None = None,
|
|
49
|
+
callback: Callable[..., Any] | None = None,
|
|
50
|
+
name: str | None = None,
|
|
51
|
+
plot_kwargs: dict[str, Any] | None = None,
|
|
52
|
+
**reactive_params: Any,
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Args:
|
|
56
|
+
data: The dataframe for static plot or pfeed's feed object for streaming plot
|
|
57
|
+
x: Column name for x-positions. If not specified, the index is used.
|
|
58
|
+
Can refer to continuous and categorical data.
|
|
59
|
+
y: Column name for the y-position.
|
|
60
|
+
by: A single column or list of columns to group by. All the subgroups are visualized.
|
|
61
|
+
callback: A reactive callback function. When provided with **reactive_params,
|
|
62
|
+
auto-creates widgets that re-fetch data on change.
|
|
63
|
+
name: Display name for this plot (used as label when widgets are shown alongside overlays).
|
|
64
|
+
Defaults to the class name lowercased.
|
|
65
|
+
plot_kwargs: keyword arguments for the plot function.
|
|
66
|
+
e.g. if the plot function is hvplot.line, plot_kwargs will be passed to hvplot.line(**plot_kwargs)
|
|
67
|
+
**reactive_params: name=value pairs for reactive widgets (e.g. ticker=["BTC", "ETH"]).
|
|
68
|
+
Requires callback to be set.
|
|
69
|
+
"""
|
|
70
|
+
super().__init__(
|
|
71
|
+
data=data,
|
|
72
|
+
x=x,
|
|
73
|
+
y=y,
|
|
74
|
+
callback=callback,
|
|
75
|
+
name=name,
|
|
76
|
+
plot_kwargs=plot_kwargs,
|
|
77
|
+
**reactive_params,
|
|
78
|
+
)
|
|
79
|
+
if by is not None:
|
|
80
|
+
self._plot_kwargs["by"] = by
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# pyright: reportUnusedParameter=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from holoviews.core.overlay import Overlay
|
|
8
|
+
|
|
9
|
+
import narwhals as nw
|
|
10
|
+
|
|
11
|
+
from pfund_plot.enums import PlottingBackend
|
|
12
|
+
|
|
13
|
+
__all__ = ["control", "plot", "style"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_COLOR = "steelblue"
|
|
17
|
+
DEFAULT_HEIGHT = 280
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def style(
|
|
21
|
+
title: str = "",
|
|
22
|
+
xlabel: str = "",
|
|
23
|
+
ylabel: str = "",
|
|
24
|
+
stacked: bool = False,
|
|
25
|
+
color: str | list[str] = DEFAULT_COLOR,
|
|
26
|
+
bg_color: str = "", # empty string by default because Panel will automatically use the theme color
|
|
27
|
+
grid: bool = False,
|
|
28
|
+
total_height: int | None = None,
|
|
29
|
+
height: int = DEFAULT_HEIGHT,
|
|
30
|
+
width: int | None = None,
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Args:
|
|
34
|
+
title: the title of the plot
|
|
35
|
+
xlabel: the label of the x-axis
|
|
36
|
+
ylabel: the label of the y-axis
|
|
37
|
+
stacked: Whether to stack multiple bars. Default is False.
|
|
38
|
+
color: the color of the plot, hex code is supported
|
|
39
|
+
bg_color: the background color of the plot, hex code is supported
|
|
40
|
+
total_height: the height of the component (including the figure + widgets)
|
|
41
|
+
Default is None, when it is None, Panel will automatically adjust its height
|
|
42
|
+
height: the height of the figure
|
|
43
|
+
width: the width of the plot, since the plot is responsive, this is only used in panel layout
|
|
44
|
+
grid: whether to show the grid
|
|
45
|
+
"""
|
|
46
|
+
return locals()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def control(
|
|
50
|
+
num_data: int | None = None,
|
|
51
|
+
max_data: int | None = None,
|
|
52
|
+
slider_step: int | None = None,
|
|
53
|
+
widgets: bool = True,
|
|
54
|
+
linked_axes: bool = True,
|
|
55
|
+
update_interval: int = 5000, # ms
|
|
56
|
+
incremental_update: bool = True,
|
|
57
|
+
datetime_precision: Literal["d", "s", "ms"] = "s",
|
|
58
|
+
):
|
|
59
|
+
"""
|
|
60
|
+
Args:
|
|
61
|
+
num_data: (DatetimeRangeWidget) initial number of most recent data points to display.
|
|
62
|
+
max_data: (streaming) maximum number of data points kept in memory.
|
|
63
|
+
If None, data will continue to grow unbounded.
|
|
64
|
+
slider_step: (DatetimeRangeWidget) step size in ms for the datetime range slider.
|
|
65
|
+
If None, derived from data resolution.
|
|
66
|
+
widgets: whether to show widgets. default is True.
|
|
67
|
+
For granular control, use remove_widgets() to remove specific widget classes.
|
|
68
|
+
linked_axes: whether to link axes across plots in a layout (plt.layout(...)).
|
|
69
|
+
incremental_update: (streaming) whether to update even when the bar is incomplete. default is True.
|
|
70
|
+
update_interval: (streaming) interval in ms to update the plot. default is 5000 ms.
|
|
71
|
+
datetime_precision: the precision of datetime formatting on the hover tooltip.
|
|
72
|
+
"d" for days (%Y-%m-%d), "s" for seconds (default, %Y-%m-%d %H:%M:%S), "ms" for milliseconds (%Y-%m-%d %H:%M:%S.%3N).
|
|
73
|
+
"""
|
|
74
|
+
return locals()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def plot(
|
|
78
|
+
df: nw.DataFrame[Any],
|
|
79
|
+
style: dict[str, Any],
|
|
80
|
+
control: dict[str, Any],
|
|
81
|
+
x: str | None = None,
|
|
82
|
+
y: str | list[str] | None = None,
|
|
83
|
+
**kwargs: Any,
|
|
84
|
+
) -> Overlay:
|
|
85
|
+
import hvplot
|
|
86
|
+
from bokeh.models import CrosshairTool
|
|
87
|
+
|
|
88
|
+
from pfund_plot.plots.bar import Bar
|
|
89
|
+
from pfund_plot.utils.bokeh import create_bundled_hover_tool
|
|
90
|
+
|
|
91
|
+
_ = hvplot.extension(PlottingBackend.bokeh)
|
|
92
|
+
|
|
93
|
+
x_col = x
|
|
94
|
+
y_cols = Bar._derive_y_cols(df, x, y)
|
|
95
|
+
datetime_precision = control["datetime_precision"]
|
|
96
|
+
|
|
97
|
+
is_single = len(y_cols) == 1
|
|
98
|
+
crosshair_tool = CrosshairTool(
|
|
99
|
+
dimensions="height", line_color="gray", line_alpha=0.3
|
|
100
|
+
)
|
|
101
|
+
if is_single:
|
|
102
|
+
tools = [
|
|
103
|
+
create_bundled_hover_tool(df, x_col, y_cols, datetime_precision),
|
|
104
|
+
crosshair_tool,
|
|
105
|
+
]
|
|
106
|
+
else:
|
|
107
|
+
tools = [crosshair_tool]
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
df.to_native()
|
|
111
|
+
.hvplot.bar(
|
|
112
|
+
x=x,
|
|
113
|
+
y=y,
|
|
114
|
+
tools=tools,
|
|
115
|
+
responsive=True,
|
|
116
|
+
color=style["color"],
|
|
117
|
+
stacked=style["stacked"],
|
|
118
|
+
bgcolor=style["bg_color"],
|
|
119
|
+
grid=style["grid"],
|
|
120
|
+
**kwargs,
|
|
121
|
+
)
|
|
122
|
+
.opts(
|
|
123
|
+
title=style["title"],
|
|
124
|
+
xlabel=style["xlabel"],
|
|
125
|
+
ylabel=style["ylabel"],
|
|
126
|
+
height=style["height"],
|
|
127
|
+
)
|
|
128
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from bokeh.model import Model as BokehModel
|
|
7
|
+
|
|
8
|
+
from pfund_plot.enums import PlottingBackend
|
|
9
|
+
from pfund_plot.plots.plot import BasePlot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Bokeh(BasePlot):
|
|
13
|
+
SUPPORTED_BACKENDS: ClassVar[list[Literal[PlottingBackend.bokeh]]] = [
|
|
14
|
+
PlottingBackend.bokeh
|
|
15
|
+
]
|
|
16
|
+
REQUIRED_DATA: ClassVar[bool] = False
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self, fig: BokehModel, sizing_mode: str | None = None, **pane_kwargs: Any
|
|
20
|
+
):
|
|
21
|
+
"""
|
|
22
|
+
Args:
|
|
23
|
+
fig: A Bokeh Model object.
|
|
24
|
+
sizing_mode: Panel sizing mode: e.g. "stretch_width", "stretch_height", or "stretch_both".
|
|
25
|
+
**pane_kwargs: Additional keyword arguments passed to pn.pane.Bokeh,
|
|
26
|
+
e.g. height, width, max_width, margin.
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(data=None)
|
|
29
|
+
self._plot: BokehModel = fig
|
|
30
|
+
self._pane_kwargs = pane_kwargs
|
|
31
|
+
if sizing_mode is not None:
|
|
32
|
+
self._pane_kwargs["sizing_mode"] = sizing_mode
|