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,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, ClassVar, Literal
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from pfund_plot.typing import RawFigure
|
|
7
|
+
|
|
8
|
+
from pfund_kit.style import RichColor, TextStyle, cprint
|
|
9
|
+
|
|
10
|
+
from pfund_plot.enums import PlottingBackend
|
|
11
|
+
from pfund_plot.plots.lazy import LazyPlot
|
|
12
|
+
from pfund_plot.plots.plot import BasePlot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseLayout(BasePlot):
|
|
16
|
+
SUPPORTED_BACKENDS: ClassVar[list[Literal[PlottingBackend.panel]]] = [
|
|
17
|
+
PlottingBackend.panel
|
|
18
|
+
]
|
|
19
|
+
SUPPORT_STREAMING: ClassVar[bool] = True
|
|
20
|
+
REQUIRED_DATA: ClassVar[bool] = False
|
|
21
|
+
|
|
22
|
+
def __new__(cls, *plots: LazyPlot | RawFigure):
|
|
23
|
+
from pfund_plot.utils import convert_to_lazy_plot
|
|
24
|
+
|
|
25
|
+
# Convert any plotting library figures to LazyPlot instances
|
|
26
|
+
lazy_plots = tuple(
|
|
27
|
+
convert_to_lazy_plot(plot) if not isinstance(plot, LazyPlot) else plot
|
|
28
|
+
for plot in plots
|
|
29
|
+
)
|
|
30
|
+
# super().__new__ will call __init__ with converted_plots
|
|
31
|
+
return super().__new__(cls, *lazy_plots)
|
|
32
|
+
|
|
33
|
+
def __init__(self, *plots: LazyPlot): # pyright: ignore[reportInconsistentConstructor]
|
|
34
|
+
self._plots: tuple[LazyPlot, ...] = plots
|
|
35
|
+
super().__init__(data=None)
|
|
36
|
+
|
|
37
|
+
def _add_plots_periodic_callbacks(self):
|
|
38
|
+
"""Transfer child plots' periodic callbacks to Layout's renderer.
|
|
39
|
+
|
|
40
|
+
Child plots inside Layout are never rendered directly — only Layout's
|
|
41
|
+
renderer gets render() called. Without this, child streaming plots'
|
|
42
|
+
periodic callbacks (e.g. _refresh_streaming_ui) would never start.
|
|
43
|
+
"""
|
|
44
|
+
assert self._renderer is not None, "renderer is not set"
|
|
45
|
+
for lazyplot in self._plots:
|
|
46
|
+
plot = lazyplot._plot
|
|
47
|
+
assert plot._renderer is not None, f"{plot.name} renderer is not set"
|
|
48
|
+
for cb in plot._renderer._periodic_callbacks:
|
|
49
|
+
self._renderer.add_periodic_callback(cb)
|
|
50
|
+
|
|
51
|
+
def is_streaming(self):
|
|
52
|
+
return any(lazyplot.is_streaming for lazyplot in self._plots)
|
|
53
|
+
|
|
54
|
+
def _start_streaming(self):
|
|
55
|
+
for lazyplot in self._plots:
|
|
56
|
+
plot = lazyplot._plot
|
|
57
|
+
if plot.is_streaming():
|
|
58
|
+
plot._start_streaming()
|
|
59
|
+
|
|
60
|
+
def _wait_for_streaming_ready(self):
|
|
61
|
+
for lazyplot in self._plots:
|
|
62
|
+
plot = lazyplot._plot
|
|
63
|
+
if plot.is_streaming():
|
|
64
|
+
plot._wait_for_streaming_ready()
|
|
65
|
+
|
|
66
|
+
def _apply_linked_axes(self):
|
|
67
|
+
"""Apply layout-level linked_axes control to all child plots."""
|
|
68
|
+
if self._control is None:
|
|
69
|
+
return
|
|
70
|
+
linked_axes = self._control.get("linked_axes", True)
|
|
71
|
+
for lazyplot in self._plots:
|
|
72
|
+
plot = lazyplot._plot
|
|
73
|
+
if plot._control is not None:
|
|
74
|
+
plot._control["linked_axes"] = linked_axes
|
|
75
|
+
for overlay in plot._overlays:
|
|
76
|
+
if overlay._control is not None:
|
|
77
|
+
overlay._control["linked_axes"] = linked_axes
|
|
78
|
+
|
|
79
|
+
def _warn_if_linked_axes_with_streaming(self):
|
|
80
|
+
"""Warn if linked_axes is enabled when streaming and non-streaming plots coexist.
|
|
81
|
+
|
|
82
|
+
Streaming plots continuously update their x-axis range, which will drag
|
|
83
|
+
linked non-streaming plots to the same range — making their data invisible.
|
|
84
|
+
"""
|
|
85
|
+
if self._control is None or not self._control.get("linked_axes", True):
|
|
86
|
+
return
|
|
87
|
+
has_streaming = any(lazyplot.is_streaming for lazyplot in self._plots)
|
|
88
|
+
has_non_streaming = any(not lazyplot.is_streaming for lazyplot in self._plots)
|
|
89
|
+
if has_streaming and has_non_streaming:
|
|
90
|
+
cprint(
|
|
91
|
+
"Streaming and non-streaming plots detected with linked_axes=True. "
|
|
92
|
+
+ "Non-streaming plots may show empty data as their axes follow the streaming range. "
|
|
93
|
+
+ "Use .control(linked_axes=False) to fix this.",
|
|
94
|
+
style=TextStyle.BOLD + RichColor.YELLOW,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _create(self):
|
|
98
|
+
super()._create()
|
|
99
|
+
self._warn_if_linked_axes_with_streaming()
|
|
100
|
+
|
|
101
|
+
# no plot needed
|
|
102
|
+
def _create_plot(self):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# no pane needed
|
|
106
|
+
def _create_pane(self):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
def _create_component(self):
|
|
110
|
+
self._apply_linked_axes()
|
|
111
|
+
self._component = self._plot_func(
|
|
112
|
+
*self._plots,
|
|
113
|
+
style=self._style,
|
|
114
|
+
control=self._control, # pyright: ignore[reportCallIssue]
|
|
115
|
+
)
|
|
116
|
+
self._add_plots_periodic_callbacks()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# pyright: reportUnusedParameter=false, reportArgumentType=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pfund_plot.plots.lazy import LazyPlot
|
|
8
|
+
|
|
9
|
+
import panel as pn
|
|
10
|
+
from panel.layout.gridstack import GridStack
|
|
11
|
+
|
|
12
|
+
__all__ = ["control", "plot", "style"]
|
|
13
|
+
DEFAULT_NUM_COLS = 3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def style():
|
|
17
|
+
return locals()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def control(
|
|
21
|
+
num_cols: int = DEFAULT_NUM_COLS,
|
|
22
|
+
allow_drag: bool = True,
|
|
23
|
+
allow_resize: bool = True,
|
|
24
|
+
linked_axes: bool = True,
|
|
25
|
+
):
|
|
26
|
+
return locals()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def plot(
|
|
30
|
+
*plots: LazyPlot, style: dict[str, Any], control: dict[str, Any], **kwargs: Any
|
|
31
|
+
) -> GridStack:
|
|
32
|
+
pn.extension("gridstack")
|
|
33
|
+
|
|
34
|
+
gstack = GridStack(
|
|
35
|
+
sizing_mode="stretch_both",
|
|
36
|
+
allow_drag=control["allow_drag"],
|
|
37
|
+
allow_resize=control["allow_resize"],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
grid_specs = [plot._grid_spec for plot in plots]
|
|
41
|
+
if all(grid_spec is not None for grid_spec in grid_specs):
|
|
42
|
+
for plot, grid_spec in zip(plots, grid_specs, strict=True):
|
|
43
|
+
row_slice, col_slice = grid_spec
|
|
44
|
+
gstack[row_slice, col_slice] = plot.component
|
|
45
|
+
else:
|
|
46
|
+
num_plots = len(plots)
|
|
47
|
+
num_cols = control["num_cols"]
|
|
48
|
+
# num_rows = math.ceil(num_plots / num_cols)
|
|
49
|
+
for i in range(num_plots):
|
|
50
|
+
gstack[i // num_cols, i % num_cols] = plots[i].component
|
|
51
|
+
return gstack
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from panel import Tabs as PanelTabs
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
|
|
11
|
+
from pfund_plot.plots.layout.layout import BaseLayout
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TabsStyle:
|
|
15
|
+
from pfund_plot.plots.layout.tabs.panel import style as panel_style
|
|
16
|
+
|
|
17
|
+
panel = panel_style
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TabsControl:
|
|
21
|
+
from pfund_plot.plots.layout.tabs.panel import control as panel_control
|
|
22
|
+
|
|
23
|
+
panel = panel_control
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Tabs(BaseLayout):
|
|
27
|
+
style = TabsStyle
|
|
28
|
+
control = TabsControl
|
|
29
|
+
|
|
30
|
+
# tabs is not at the top level of plots, it's inside layout/tabs, so we need to override the _plot property
|
|
31
|
+
@property
|
|
32
|
+
def _plot_func(self) -> Callable[..., PanelTabs]:
|
|
33
|
+
"""Runs the plot function for the current backend."""
|
|
34
|
+
module_path: str = f"pfund_plot.plots.layout.{self._class_name}.{self._backend}"
|
|
35
|
+
module = importlib.import_module(module_path)
|
|
36
|
+
return module.plot
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# pyright: reportUnusedParameter=false, reportArgumentType=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pfund_plot.plots.lazy import LazyPlot
|
|
8
|
+
from pfund_plot.typing import RawFigure
|
|
9
|
+
|
|
10
|
+
from panel import Tabs
|
|
11
|
+
|
|
12
|
+
__all__ = ["control", "plot", "style"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def style(
|
|
16
|
+
height: int | None = None,
|
|
17
|
+
width: int | None = None,
|
|
18
|
+
):
|
|
19
|
+
return locals()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def control(
|
|
23
|
+
dynamic: bool = False,
|
|
24
|
+
closable: bool = False,
|
|
25
|
+
position: Literal["above", "below", "left", "right"] = "above",
|
|
26
|
+
linked_axes: bool = True,
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Args:
|
|
30
|
+
dynamic: Dynamically populate only the active tab.
|
|
31
|
+
closable: Whether it should be possible to close tabs.
|
|
32
|
+
position: The location of the tabs relative to the tab contents.
|
|
33
|
+
linked_axes: Whether to link axes across plots in different tabs.
|
|
34
|
+
"""
|
|
35
|
+
return locals()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def plot(
|
|
39
|
+
*plots: LazyPlot | RawFigure,
|
|
40
|
+
style: dict[str, Any],
|
|
41
|
+
control: dict[str, Any],
|
|
42
|
+
**kwargs: Any,
|
|
43
|
+
) -> Tabs:
|
|
44
|
+
return Tabs(
|
|
45
|
+
*[plot.component for plot in plots],
|
|
46
|
+
height=style["height"],
|
|
47
|
+
width=style["width"],
|
|
48
|
+
dynamic=control["dynamic"],
|
|
49
|
+
closable=control["closable"],
|
|
50
|
+
tabs_location=control["position"],
|
|
51
|
+
)
|
pfund_plot/plots/lazy.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# pyright: reportUnknownMemberType=false, reportAttributeAccessIssue=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from narwhals.typing import IntoFrame
|
|
8
|
+
from panel.io.server import Server
|
|
9
|
+
from panel.pane import Pane
|
|
10
|
+
from panel.viewable import Viewable
|
|
11
|
+
from pfeed.feeds.market_feed import MarketFeed
|
|
12
|
+
|
|
13
|
+
from pfund_plot.enums import DisplayMode, PlottingBackend
|
|
14
|
+
from pfund_plot.plots.plot import BasePlot
|
|
15
|
+
from pfund_plot.typing import (
|
|
16
|
+
Component,
|
|
17
|
+
Figure,
|
|
18
|
+
Plot,
|
|
19
|
+
RenderedResult,
|
|
20
|
+
)
|
|
21
|
+
from pfund_plot.widgets.base import BaseWidget
|
|
22
|
+
|
|
23
|
+
import panel as pn
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LazyRow(pn.Row):
|
|
27
|
+
"""A pn.Row subclass that supports + and | operators for chaining."""
|
|
28
|
+
|
|
29
|
+
def __add__(self, other: LazyPlot | Viewable) -> LazyRow:
|
|
30
|
+
right = other.component if isinstance(other, LazyPlot) else other
|
|
31
|
+
return LazyRow(*self.objects, right)
|
|
32
|
+
|
|
33
|
+
def __or__(self, other: LazyPlot | Viewable) -> LazyColumn:
|
|
34
|
+
right = other.component if isinstance(other, LazyPlot) else other
|
|
35
|
+
return LazyColumn(self, right)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LazyColumn(pn.Column):
|
|
39
|
+
"""A pn.Column subclass that supports + and | operators for chaining."""
|
|
40
|
+
|
|
41
|
+
def __add__(self, other: LazyPlot | Viewable) -> LazyRow:
|
|
42
|
+
right = other.component if isinstance(other, LazyPlot) else other
|
|
43
|
+
return LazyRow(self, right)
|
|
44
|
+
|
|
45
|
+
def __or__(self, other: LazyPlot | Viewable) -> LazyColumn:
|
|
46
|
+
right = other.component if isinstance(other, LazyPlot) else other
|
|
47
|
+
return LazyColumn(*self.objects, right)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LazyPlot:
|
|
51
|
+
"""Lazy plot builder that defers rendering until display.
|
|
52
|
+
|
|
53
|
+
Enables method chaining for configuring plots:
|
|
54
|
+
plt.ohlc(df).style(height=600, width=800).control(num_data=100)
|
|
55
|
+
|
|
56
|
+
Auto-renders when displayed in notebooks or at end of scripts.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, plot_instance: BasePlot):
|
|
60
|
+
self._plot = plot_instance
|
|
61
|
+
self._grid_spec: tuple[slice, slice] | None = None
|
|
62
|
+
|
|
63
|
+
def __getitem__(self, key: tuple[int | slice, int | slice]) -> LazyPlot:
|
|
64
|
+
"""Set grid position for use with plt.layout (GridStack).
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
key: A tuple of two elements (row, col) specifying the grid position.
|
|
68
|
+
Each element can be an int (single position) or slice (range).
|
|
69
|
+
Examples: [1, 3], [1:2, 3:4], [1, 3:5]
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Self for method chaining
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
plt.ohlc(df)[0, 0] # single cell at row 0, col 0
|
|
76
|
+
plt.ohlc(df)[0:2, 0:6] # spans rows 0-1, cols 0-5
|
|
77
|
+
plt.ohlc(df)[1, 0:3] # row 1, cols 0-2
|
|
78
|
+
plt.layout(plot1[0:1, 0:2], plot2[0:1, 2:4])
|
|
79
|
+
"""
|
|
80
|
+
if not isinstance(key, tuple) or len(key) != 2:
|
|
81
|
+
raise TypeError(
|
|
82
|
+
"Grid spec must be a tuple of two elements, e.g., [1, 3] or [1:2, 3:4]"
|
|
83
|
+
)
|
|
84
|
+
row_spec, col_spec = key
|
|
85
|
+
if not isinstance(row_spec, (int, slice)) or not isinstance(
|
|
86
|
+
col_spec, (int, slice)
|
|
87
|
+
):
|
|
88
|
+
raise TypeError(
|
|
89
|
+
"Grid spec elements must be int or slice, e.g., [1, 3] or [1:2, 3:4]"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Convert ints to slices for consistency
|
|
93
|
+
row_slice = (
|
|
94
|
+
slice(row_spec, row_spec + 1) if isinstance(row_spec, int) else row_spec
|
|
95
|
+
)
|
|
96
|
+
col_slice = (
|
|
97
|
+
slice(col_spec, col_spec + 1) if isinstance(col_spec, int) else col_spec
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
self._grid_spec = (row_slice, col_slice)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def name(self) -> str:
|
|
105
|
+
return self._plot.name
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def figure(self) -> Figure:
|
|
109
|
+
return self._plot.figure
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def plot(self) -> Plot:
|
|
113
|
+
if self._plot._plot is None:
|
|
114
|
+
self._plot._create_plot()
|
|
115
|
+
return self._plot._plot
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def is_streaming(self) -> bool:
|
|
119
|
+
return self._plot.is_streaming()
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def widgets(self) -> Any:
|
|
123
|
+
if not self._plot._widgets:
|
|
124
|
+
self._plot._create_widgets()
|
|
125
|
+
return self._plot._widgets
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def streaming_widgets(self) -> Any:
|
|
129
|
+
if self._plot.is_streaming() and not self._plot._streaming_widgets:
|
|
130
|
+
self._plot._create_widgets()
|
|
131
|
+
return self._plot._streaming_widgets
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def reactive_widgets(self) -> Any:
|
|
135
|
+
if self._plot._reactive_params and not self._plot._reactive_widgets:
|
|
136
|
+
self._plot._create_reactive_widgets()
|
|
137
|
+
return self._plot._reactive_widgets
|
|
138
|
+
|
|
139
|
+
def opts(self, *args: Any, **kwargs: Any) -> LazyPlot:
|
|
140
|
+
"""Pass holoviews opts to the underlying plot.
|
|
141
|
+
|
|
142
|
+
Example:
|
|
143
|
+
candlestick.opts(multi_y=True)
|
|
144
|
+
(candlestick * volume).opts(multi_y=True)
|
|
145
|
+
"""
|
|
146
|
+
self._plot._add_opts(args, kwargs)
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def pane(self) -> Pane:
|
|
151
|
+
if self._plot._pane is None:
|
|
152
|
+
self._plot._create_pane()
|
|
153
|
+
return self._plot._pane
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def component(self) -> Component | None:
|
|
157
|
+
self._plot._create() # create pane+widgets+component
|
|
158
|
+
return self._plot._component
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def df(self) -> IntoFrame | None:
|
|
162
|
+
if self._plot._df is None:
|
|
163
|
+
return None
|
|
164
|
+
return self._plot._df.to_native()
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def feed(self) -> MarketFeed | None:
|
|
168
|
+
return self._plot._feed
|
|
169
|
+
|
|
170
|
+
def style(self, **kwargs) -> LazyPlot:
|
|
171
|
+
"""Configure style options.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
**kwargs: Style parameters (e.g., height, width, colors, etc.)
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Self for method chaining
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
plt.ohlc(df).style(height=600, width=800)
|
|
181
|
+
"""
|
|
182
|
+
self._plot._set_style(kwargs)
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
def get_style(self) -> dict:
|
|
186
|
+
return self._plot._style
|
|
187
|
+
|
|
188
|
+
def control(self, **kwargs) -> LazyPlot:
|
|
189
|
+
"""Configure control options.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
**kwargs: Control parameters (e.g., num_data, etc.)
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Self for method chaining
|
|
196
|
+
|
|
197
|
+
Example:
|
|
198
|
+
plt.ohlc(df).control(num_data=100)
|
|
199
|
+
"""
|
|
200
|
+
self._plot._set_control(kwargs)
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
def get_control(self) -> dict:
|
|
204
|
+
return self._plot._control
|
|
205
|
+
|
|
206
|
+
def backend(self, backend: PlottingBackend | str) -> LazyPlot:
|
|
207
|
+
"""Override backend for this plot only.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
backend: Backend to use ('bokeh' or 'svelte')
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Self for method chaining
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
plt.ohlc(df).backend('svelte').show()
|
|
217
|
+
"""
|
|
218
|
+
self._plot._set_backend(backend)
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def get_backend(self) -> PlottingBackend:
|
|
222
|
+
return self._plot._backend
|
|
223
|
+
|
|
224
|
+
def mode(
|
|
225
|
+
self, mode: DisplayMode | Literal["notebook", "browser", "desktop"]
|
|
226
|
+
) -> LazyPlot:
|
|
227
|
+
"""Override display mode for this plot only.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
mode: Display mode ('notebook', 'browser', or 'desktop')
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Self for method chaining
|
|
234
|
+
|
|
235
|
+
Example:
|
|
236
|
+
plt.ohlc(df).mode('browser').show()
|
|
237
|
+
"""
|
|
238
|
+
self._plot._set_mode(mode)
|
|
239
|
+
return self
|
|
240
|
+
|
|
241
|
+
def get_mode(self) -> DisplayMode:
|
|
242
|
+
return self._plot._mode
|
|
243
|
+
|
|
244
|
+
def remove_widgets(self, *WidgetClasses: type[BaseWidget]) -> LazyPlot:
|
|
245
|
+
"""Remove widgets from the plot.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
*WidgetClasses: The widget classes to remove.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Self for method chaining
|
|
252
|
+
"""
|
|
253
|
+
self._plot._remove_widgets(*WidgetClasses)
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
def _get_existing_server(self) -> Server | None:
|
|
257
|
+
renderer = self._plot._renderer
|
|
258
|
+
server = renderer.server
|
|
259
|
+
# server is already running, return it
|
|
260
|
+
# happens when in notebook envs, somehow _repr_mimebundle_ and _repr_html_ are called multiple times automatically
|
|
261
|
+
# e.g. plt.ohlc(df).mode('browser')
|
|
262
|
+
if server is not None:
|
|
263
|
+
return server
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
def show(self) -> Server | RenderedResult:
|
|
267
|
+
"""Explicitly render and display the plot.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
The rendered plot output
|
|
271
|
+
"""
|
|
272
|
+
if self._plot.is_streaming() and self._plot.is_in_notebook_mode():
|
|
273
|
+
raise RuntimeError(
|
|
274
|
+
"Cannot render streaming plot in synchronous mode, use show_async() instead."
|
|
275
|
+
)
|
|
276
|
+
if server := self._get_existing_server():
|
|
277
|
+
return server
|
|
278
|
+
return self._plot._render_sync()
|
|
279
|
+
|
|
280
|
+
async def show_async(self) -> Server | RenderedResult:
|
|
281
|
+
if server := self._get_existing_server():
|
|
282
|
+
return server
|
|
283
|
+
return await self._plot._render_async()
|
|
284
|
+
|
|
285
|
+
def servable(self, title: str | None = None) -> Component:
|
|
286
|
+
"""Mark the plot as servable for use with `panel serve` CLI.
|
|
287
|
+
|
|
288
|
+
Use this when running with: panel serve myfile.py --show --autoreload
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
title: Optional title for the browser tab
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The servable component
|
|
295
|
+
|
|
296
|
+
Example:
|
|
297
|
+
fig = plt.ohlc(df).backend('bokeh')
|
|
298
|
+
fig.servable()
|
|
299
|
+
"""
|
|
300
|
+
return self.component.servable(title=title)
|
|
301
|
+
|
|
302
|
+
def _repr_mimebundle_(self, include=None, exclude=None) -> dict[str, Any]:
|
|
303
|
+
"""Auto-render in Jupyter/IPython notebooks.
|
|
304
|
+
|
|
305
|
+
This magic method is called when the object is the last expression
|
|
306
|
+
in a notebook cell, enabling auto-display without explicit render().
|
|
307
|
+
"""
|
|
308
|
+
result = self.show()
|
|
309
|
+
if hasattr(result, "_repr_mimebundle_"):
|
|
310
|
+
return result._repr_mimebundle_(include, exclude)
|
|
311
|
+
return {}
|
|
312
|
+
|
|
313
|
+
def _repr_html_(self) -> str | None:
|
|
314
|
+
"""Fallback HTML representation for notebooks."""
|
|
315
|
+
result = self.show()
|
|
316
|
+
if hasattr(result, "_repr_html_"):
|
|
317
|
+
return result._repr_html_()
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
def __add__(self, other: LazyPlot | Viewable) -> LazyRow:
|
|
321
|
+
"""Combine plots horizontally using + operator.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
other: Another LazyPlot, Panel component, or any Panel-compatible object
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
LazyRow containing the combined components
|
|
328
|
+
|
|
329
|
+
Example:
|
|
330
|
+
plot1 + plot2 # Side by side
|
|
331
|
+
plot1 + plot2 + plot3 # Three plots in a row
|
|
332
|
+
plot1 + some_widget # LazyPlot with a widget
|
|
333
|
+
"""
|
|
334
|
+
left = self.component
|
|
335
|
+
right = other.component if isinstance(other, LazyPlot) else other
|
|
336
|
+
return LazyRow(left, right)
|
|
337
|
+
|
|
338
|
+
def __radd__(self, other: Viewable) -> LazyRow:
|
|
339
|
+
"""Support reverse addition (when left operand doesn't support +)."""
|
|
340
|
+
return LazyRow(other, self.component)
|
|
341
|
+
|
|
342
|
+
def __mul__(self, other: LazyPlot) -> LazyPlot:
|
|
343
|
+
"""Overlay another plot or overlay layer onto this plot using * operator.
|
|
344
|
+
|
|
345
|
+
Returns a new LazyPlot with the overlay appended, leaving the original unchanged.
|
|
346
|
+
This allows reuse: plot * marker1 and plot * marker2 are independent.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
other: A LazyPlot to composite onto this plot
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
A new LazyPlot with the overlay added
|
|
353
|
+
|
|
354
|
+
Example:
|
|
355
|
+
candlestick * markers # overlay markers on candlestick
|
|
356
|
+
candlestick * volume_bars # overlay volume on candlestick
|
|
357
|
+
"""
|
|
358
|
+
from copy import deepcopy
|
|
359
|
+
|
|
360
|
+
if not isinstance(other, LazyPlot):
|
|
361
|
+
raise TypeError(f"Cannot overlay {type(other).__name__} onto a plot.")
|
|
362
|
+
|
|
363
|
+
other_plot = other._plot
|
|
364
|
+
current_plot = self._plot
|
|
365
|
+
if other_plot.is_streaming() != current_plot.is_streaming():
|
|
366
|
+
raise RuntimeError(
|
|
367
|
+
"Cannot overlay a streaming plot with a non-streaming plot."
|
|
368
|
+
)
|
|
369
|
+
if other_plot._backend != current_plot._backend:
|
|
370
|
+
raise RuntimeError("Cannot overlay plots with different backends.")
|
|
371
|
+
if other_plot._mode != current_plot._mode:
|
|
372
|
+
raise RuntimeError("Cannot overlay plots with different modes.")
|
|
373
|
+
# NOTE: deepcopy both plots so the originals are not mutated —
|
|
374
|
+
# conceptually the result is a new plot instance that carries its own _overlays list.
|
|
375
|
+
# The overlay must also be cloned so that _parent_plot doesn't get shared
|
|
376
|
+
# when the same overlay is reused across multiple compositions.
|
|
377
|
+
try:
|
|
378
|
+
cloned_plot = deepcopy(self._plot)
|
|
379
|
+
cloned_plot._add_overlay(deepcopy(other._plot))
|
|
380
|
+
except (RuntimeError, AttributeError, TypeError) as e:
|
|
381
|
+
raise RuntimeError(
|
|
382
|
+
"Cannot overlay a plot that has already been rendered. "
|
|
383
|
+
+ "Bokeh objects cannot be copied after rendering. "
|
|
384
|
+
+ "Create fresh plots for the overlay instead, e.g. plt.ohlc(df) * plt.label(df, ...)"
|
|
385
|
+
) from e
|
|
386
|
+
return LazyPlot(cloned_plot)
|
|
387
|
+
|
|
388
|
+
def __or__(self, other: LazyPlot | Viewable) -> LazyColumn:
|
|
389
|
+
"""Stack plots vertically using | operator.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
other: Another LazyPlot, Panel component, or any Panel-compatible object
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
LazyColumn containing the stacked components
|
|
396
|
+
|
|
397
|
+
Example:
|
|
398
|
+
plot1 | plot2 # Vertically stacked
|
|
399
|
+
plot1 | plot2 | plot3 # Three plots stacked
|
|
400
|
+
plot1 | "Some text label" # LazyPlot with text below
|
|
401
|
+
"""
|
|
402
|
+
top = self.component
|
|
403
|
+
bottom = other.component if isinstance(other, LazyPlot) else other
|
|
404
|
+
return LazyColumn(top, bottom)
|
|
405
|
+
|
|
406
|
+
def __ror__(self, other: Viewable) -> LazyColumn:
|
|
407
|
+
"""Support reverse or (when left operand doesn't support |)."""
|
|
408
|
+
return LazyColumn(other, self.component)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# pyright: reportArgumentType=false, reportOptionalMemberAccess=false, reportOptionalSubscript=false, reportCallIssue=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from pfund_plot.widgets.base import BaseStreamingWidget, BaseWidget
|
|
8
|
+
|
|
9
|
+
from pfund_plot.enums import PlottingBackend
|
|
10
|
+
from pfund_plot.plots.plot import BasePlot
|
|
11
|
+
from pfund_plot.widgets.datetime_widget import DatetimeRangeWidget
|
|
12
|
+
from pfund_plot.widgets.ticker_widget import TickerSelectWidget
|
|
13
|
+
|
|
14
|
+
__all__ = ["Line"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LineStyle:
|
|
18
|
+
from pfund_plot.plots.line.bokeh import style as bokeh_style
|
|
19
|
+
|
|
20
|
+
bokeh = bokeh_style
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LineControl:
|
|
24
|
+
from pfund_plot.plots.line.bokeh import control as bokeh_control
|
|
25
|
+
|
|
26
|
+
bokeh = bokeh_control
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Line(BasePlot):
|
|
30
|
+
SUPPORTED_BACKENDS: ClassVar[list[PlottingBackend]] = [PlottingBackend.bokeh]
|
|
31
|
+
SUPPORT_STREAMING: ClassVar[bool] = True
|
|
32
|
+
SUPPORTED_WIDGETS: ClassVar[list[type[BaseWidget]]] = [DatetimeRangeWidget]
|
|
33
|
+
SUPPORTED_STREAMING_WIDGETS: ClassVar[list[type[BaseStreamingWidget]]] = [
|
|
34
|
+
TickerSelectWidget
|
|
35
|
+
]
|
|
36
|
+
style = LineStyle
|
|
37
|
+
control = LineControl
|