pfund-plot 0.0.1.dev1__py3-none-any.whl → 0.0.1.dev3__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 +35 -1
- pfund_plot/cli/__init__.py +4 -0
- pfund_plot/cli/commands/config.py +81 -0
- pfund_plot/cli/commands/plot.py +7 -0
- pfund_plot/cli/main.py +20 -0
- pfund_plot/composites/composite.py +13 -0
- pfund_plot/config_handler.py +136 -0
- pfund_plot/const/enums/__init__.py +6 -0
- pfund_plot/const/enums/dashboard_type.py +8 -0
- pfund_plot/const/enums/data_type.py +6 -0
- pfund_plot/const/enums/dataframe_backend.py +6 -0
- pfund_plot/const/enums/display_mode.py +7 -0
- pfund_plot/const/enums/notebook_type.py +7 -0
- pfund_plot/const/enums/plotting_backend.py +6 -0
- pfund_plot/const/paths.py +15 -0
- pfund_plot/exports/__init__.py +0 -0
- pfund_plot/layout.py +0 -0
- pfund_plot/main.py +18 -0
- pfund_plot/mosaic.py +0 -0
- pfund_plot/plots/candlestick.py +234 -0
- pfund_plot/plots/dataframe.py +159 -0
- pfund_plot/plots/line.py +2 -0
- pfund_plot/plots/orderbook.py +47 -0
- pfund_plot/renderer.py +143 -0
- pfund_plot/templates/__init__.py +2 -0
- pfund_plot/templates/dashboard.py +13 -0
- pfund_plot/templates/notebook.py +15 -0
- pfund_plot/templates/spreadsheet.py +1 -0
- pfund_plot/templates/template.py +10 -0
- pfund_plot/types/core.py +11 -0
- pfund_plot/types/literals.py +6 -0
- pfund_plot/utils/utils.py +42 -0
- pfund_plot/utils/validate.py +50 -0
- pfund_plot-0.0.1.dev3.dist-info/METADATA +101 -0
- pfund_plot-0.0.1.dev3.dist-info/RECORD +38 -0
- pfund_plot-0.0.1.dev3.dist-info/entry_points.txt +5 -0
- pfund_plot-0.0.1.dev1.dist-info/METADATA +0 -42
- pfund_plot-0.0.1.dev1.dist-info/RECORD +0 -5
- {pfund_plot-0.0.1.dev1.dist-info → pfund_plot-0.0.1.dev3.dist-info}/LICENSE +0 -0
- {pfund_plot-0.0.1.dev1.dist-info → pfund_plot-0.0.1.dev3.dist-info}/WHEEL +0 -0
pfund_plot/__init__.py
CHANGED
|
@@ -1 +1,35 @@
|
|
|
1
|
-
import
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
import hvplot
|
|
4
|
+
import panel as pn
|
|
5
|
+
|
|
6
|
+
from pfund_plot.config_handler import get_config, configure
|
|
7
|
+
from pfund_plot.plots.dataframe import (
|
|
8
|
+
dataframe_plot as dataframe,
|
|
9
|
+
dataframe_plot as df,
|
|
10
|
+
)
|
|
11
|
+
from pfund_plot.plots.candlestick import (
|
|
12
|
+
candlestick_plot as candlestick,
|
|
13
|
+
candlestick_plot as ohlc,
|
|
14
|
+
candlestick_plot as kline,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
hvplot.extension('bokeh', 'plotly')
|
|
19
|
+
pn.extension('tabulator', 'perspective')
|
|
20
|
+
# used to throttle updates in panel plots
|
|
21
|
+
# NOTE: without it, e.g. dragging a slider will cause the plot to update rapidly and lead to an error
|
|
22
|
+
pn.config.throttled = True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__version__ = version("pfund_plot")
|
|
26
|
+
__all__ = (
|
|
27
|
+
"__version__",
|
|
28
|
+
"get_config",
|
|
29
|
+
"configure",
|
|
30
|
+
"candlestick",
|
|
31
|
+
"ohlc",
|
|
32
|
+
"kline",
|
|
33
|
+
"dataframe",
|
|
34
|
+
"df",
|
|
35
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from pfeed.const.enums import DataTool
|
|
4
|
+
from pfund_plot.const.paths import PROJ_NAME
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.group()
|
|
8
|
+
def config():
|
|
9
|
+
"""Manage configuration settings."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@config.command()
|
|
14
|
+
@click.pass_context
|
|
15
|
+
def list(ctx):
|
|
16
|
+
"""List all available options."""
|
|
17
|
+
from pprint import pformat
|
|
18
|
+
from dataclasses import asdict
|
|
19
|
+
config_dict = asdict(ctx.obj['config'])
|
|
20
|
+
content = click.style(pformat(config_dict), fg='green')
|
|
21
|
+
click.echo(f"{PROJ_NAME} config:\n{content}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@config.command()
|
|
25
|
+
@click.pass_context
|
|
26
|
+
def reset(ctx):
|
|
27
|
+
"""Reset the configuration to defaults."""
|
|
28
|
+
ctx.obj['config'].reset()
|
|
29
|
+
click.echo(f"{PROJ_NAME} config reset successfully.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@config.command()
|
|
33
|
+
@click.option('--data-tool', '--dt', type=click.Choice(DataTool, case_sensitive=False), help='Set the data tool')
|
|
34
|
+
@click.option('--max-points', '--mp', type=int, help='Set the maximum number of points to display in the plot')
|
|
35
|
+
@click.option('--data-path', '--dp', type=click.Path(resolve_path=True), help='Set the data path')
|
|
36
|
+
@click.option('--cache-path', '--cp', type=click.Path(resolve_path=True), help='Set the cache path')
|
|
37
|
+
def set(**kwargs):
|
|
38
|
+
"""Configures pfund_plot settings."""
|
|
39
|
+
from pfund_plot.config_handler import configure
|
|
40
|
+
provided_options = {k: v for k, v in kwargs.items() if v is not None}
|
|
41
|
+
if not provided_options:
|
|
42
|
+
raise click.UsageError(f"No options provided. Please run '{PROJ_NAME} config set --help' to see all available options.")
|
|
43
|
+
else:
|
|
44
|
+
configure(write=True, **kwargs)
|
|
45
|
+
click.echo(f"{PROJ_NAME} config updated successfully.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@config.command()
|
|
49
|
+
@click.option('--config-file', '-c', is_flag=True, help=f'Open the {PROJ_NAME}_config.yml file')
|
|
50
|
+
@click.option('--default-editor', '-E', is_flag=True, help='Use default editor')
|
|
51
|
+
def open(config_file, default_editor):
|
|
52
|
+
"""Opens config files, e.g. pfund_plot_config.yml."""
|
|
53
|
+
from pfund_plot.const.paths import CONFIG_FILE_PATH
|
|
54
|
+
|
|
55
|
+
if sum([config_file]) > 1:
|
|
56
|
+
click.echo('Please specify only one file to open')
|
|
57
|
+
return
|
|
58
|
+
else:
|
|
59
|
+
if config_file:
|
|
60
|
+
file_path = CONFIG_FILE_PATH
|
|
61
|
+
else:
|
|
62
|
+
click.echo(f'Please specify a file to open, run "{PROJ_NAME} config open --help" for more info')
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if default_editor:
|
|
66
|
+
click.edit(filename=file_path)
|
|
67
|
+
else:
|
|
68
|
+
open_with_vscode(file_path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def open_with_vscode(file_path):
|
|
72
|
+
import subprocess
|
|
73
|
+
try:
|
|
74
|
+
subprocess.run(["code", str(file_path)], check=True)
|
|
75
|
+
click.echo(f"Opened {file_path} with VS Code")
|
|
76
|
+
except subprocess.CalledProcessError:
|
|
77
|
+
click.echo("Failed to open with VS Code. Falling back to default editor.")
|
|
78
|
+
click.edit(filename=file_path)
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
click.echo("VS Code command 'code' not found. Falling back to default editor.")
|
|
81
|
+
click.edit(filename=file_path)
|
pfund_plot/cli/main.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from trogon import tui
|
|
3
|
+
|
|
4
|
+
from pfund_plot.config_handler import get_config
|
|
5
|
+
from pfund_plot.cli.commands.plot import plot
|
|
6
|
+
from pfund_plot.cli.commands.config import config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@tui(command='tui', help="Open terminal UI")
|
|
10
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
11
|
+
@click.pass_context
|
|
12
|
+
@click.version_option()
|
|
13
|
+
def pfund_plot_group(ctx):
|
|
14
|
+
"""PFundPlot's CLI"""
|
|
15
|
+
ctx.ensure_object(dict)
|
|
16
|
+
ctx.obj['config'] = get_config(verbose=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
pfund_plot_group.add_command(plot)
|
|
20
|
+
pfund_plot_group.add_command(config)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# TEMP
|
|
2
|
+
# composites are plots combined together on the same canvas
|
|
3
|
+
# it can be used to perform comparison analysis, create a graph that has the price line, equity curve etc.
|
|
4
|
+
|
|
5
|
+
# use plotly for composite plots
|
|
6
|
+
class CompositePlot:
|
|
7
|
+
def __init__(self, plots):
|
|
8
|
+
self.plots = plots
|
|
9
|
+
|
|
10
|
+
def render(self):
|
|
11
|
+
# Implement logic to combine plots
|
|
12
|
+
# This is non-trivial and may require standardizing on a single backend
|
|
13
|
+
pass
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
from dataclasses import dataclass, asdict
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from pfund_plot.const.paths import (
|
|
8
|
+
PROJ_NAME,
|
|
9
|
+
DATA_PATH,
|
|
10
|
+
CACHE_PATH,
|
|
11
|
+
CONFIG_PATH,
|
|
12
|
+
CONFIG_FILE_PATH
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'get_config',
|
|
18
|
+
'configure',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ConfigHandler:
|
|
24
|
+
data_path: str = str(DATA_PATH)
|
|
25
|
+
cache_path: str = str(CACHE_PATH)
|
|
26
|
+
|
|
27
|
+
_instance = None
|
|
28
|
+
_verbose = False
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def get_instance(cls):
|
|
32
|
+
if cls._instance is None:
|
|
33
|
+
cls._instance = cls.load()
|
|
34
|
+
return cls._instance
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def set_verbose(cls, verbose: bool):
|
|
38
|
+
cls._verbose = verbose
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def load(cls):
|
|
42
|
+
'''Loads user's config file and returns a ConfigHandler object'''
|
|
43
|
+
CONFIG_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
# Create default config from dataclass fields
|
|
45
|
+
default_config = {
|
|
46
|
+
field.name: field.default
|
|
47
|
+
for field in cls.__dataclass_fields__.values()
|
|
48
|
+
if not field.name.startswith('_') # Skip private fields
|
|
49
|
+
}
|
|
50
|
+
needs_update = False
|
|
51
|
+
if CONFIG_FILE_PATH.is_file():
|
|
52
|
+
with open(CONFIG_FILE_PATH, 'r') as f:
|
|
53
|
+
saved_config = yaml.safe_load(f) or {}
|
|
54
|
+
if cls._verbose:
|
|
55
|
+
print(f"{PROJ_NAME} config loaded from {CONFIG_FILE_PATH}.")
|
|
56
|
+
# Check for new or removed fields
|
|
57
|
+
new_fields = set(default_config.keys()) - set(saved_config.keys())
|
|
58
|
+
removed_fields = set(saved_config.keys()) - set(default_config.keys())
|
|
59
|
+
needs_update = bool(new_fields or removed_fields)
|
|
60
|
+
|
|
61
|
+
if cls._verbose and needs_update:
|
|
62
|
+
if new_fields:
|
|
63
|
+
print(f"New config fields detected: {new_fields}")
|
|
64
|
+
if removed_fields:
|
|
65
|
+
print(f"Removed config fields detected: {removed_fields}")
|
|
66
|
+
|
|
67
|
+
# Filter out removed fields and merge with defaults
|
|
68
|
+
saved_config = {k: v for k, v in saved_config.items() if k in default_config}
|
|
69
|
+
config = {**default_config, **saved_config}
|
|
70
|
+
else:
|
|
71
|
+
config = default_config
|
|
72
|
+
needs_update = True
|
|
73
|
+
config_handler = cls(**config)
|
|
74
|
+
if needs_update:
|
|
75
|
+
config_handler.dump()
|
|
76
|
+
return config_handler
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def reset(cls):
|
|
80
|
+
'''Resets the config by deleting the user config directory and reloading the config'''
|
|
81
|
+
shutil.rmtree(CONFIG_PATH)
|
|
82
|
+
if cls._verbose:
|
|
83
|
+
print(f"{PROJ_NAME} config successfully reset.")
|
|
84
|
+
return cls.load()
|
|
85
|
+
|
|
86
|
+
def dump(self):
|
|
87
|
+
with open(CONFIG_FILE_PATH, 'w') as f:
|
|
88
|
+
yaml.dump(asdict(self), f, default_flow_style=False)
|
|
89
|
+
if self._verbose:
|
|
90
|
+
print(f"{PROJ_NAME} config saved to {CONFIG_FILE_PATH}.")
|
|
91
|
+
|
|
92
|
+
def __post_init__(self):
|
|
93
|
+
self._initialize_configs()
|
|
94
|
+
|
|
95
|
+
def _initialize_configs(self):
|
|
96
|
+
for path in [self.data_path, self.cache_path]:
|
|
97
|
+
if not os.path.exists(path):
|
|
98
|
+
os.makedirs(path)
|
|
99
|
+
if self._verbose:
|
|
100
|
+
print(f'{PROJ_NAME} created {path}')
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def configure(
|
|
104
|
+
data_path: str | None = None,
|
|
105
|
+
cache_path: str | None = None,
|
|
106
|
+
verbose: bool = False,
|
|
107
|
+
write: bool = False,
|
|
108
|
+
):
|
|
109
|
+
'''Configures the global config object.
|
|
110
|
+
It will override the existing config values from the existing config file or the default values.
|
|
111
|
+
Args:
|
|
112
|
+
write: If True, the config will be saved to the config file.
|
|
113
|
+
'''
|
|
114
|
+
NON_CONFIG_KEYS = ['verbose', 'write']
|
|
115
|
+
config_updates = locals()
|
|
116
|
+
for k in NON_CONFIG_KEYS:
|
|
117
|
+
config_updates.pop(k)
|
|
118
|
+
config_updates.pop('NON_CONFIG_KEYS')
|
|
119
|
+
|
|
120
|
+
config = get_config(verbose=verbose)
|
|
121
|
+
|
|
122
|
+
# Apply updates for non-None values
|
|
123
|
+
for k, v in config_updates.items():
|
|
124
|
+
if v is not None:
|
|
125
|
+
setattr(config, k, v)
|
|
126
|
+
|
|
127
|
+
if write:
|
|
128
|
+
config.dump()
|
|
129
|
+
|
|
130
|
+
config._initialize_configs()
|
|
131
|
+
return config
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_config(verbose: bool = False) -> ConfigHandler:
|
|
135
|
+
ConfigHandler.set_verbose(verbose)
|
|
136
|
+
return ConfigHandler.get_instance()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from pfund_plot.const.enums.notebook_type import NotebookType
|
|
2
|
+
from pfund_plot.const.enums.dashboard_type import DashboardType
|
|
3
|
+
from pfund_plot.const.enums.plotting_backend import PlottingBackend
|
|
4
|
+
from pfund_plot.const.enums.display_mode import DisplayMode
|
|
5
|
+
from pfund_plot.const.enums.data_type import DataType
|
|
6
|
+
from pfund_plot.const.enums.dataframe_backend import DataFrameBackend
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from platformdirs import user_data_dir, user_config_dir, user_cache_dir
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# project paths
|
|
6
|
+
PROJ_NAME = Path(__file__).resolve().parents[1].name
|
|
7
|
+
MAIN_PATH = Path(__file__).resolve().parents[2]
|
|
8
|
+
PROJ_PATH = MAIN_PATH / PROJ_NAME
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# user paths
|
|
12
|
+
DATA_PATH = Path(user_data_dir()) / PROJ_NAME
|
|
13
|
+
CACHE_PATH = Path(user_cache_dir()) / PROJ_NAME
|
|
14
|
+
CONFIG_PATH = Path(user_config_dir()) / PROJ_NAME / 'config'
|
|
15
|
+
CONFIG_FILE_PATH = CONFIG_PATH / f'{PROJ_NAME}_config.yml'
|
|
File without changes
|
pfund_plot/layout.py
ADDED
|
File without changes
|
pfund_plot/main.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
|
|
3
|
+
from pfund_plot.cli import pfund_plot_group
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def exit_cli():
|
|
7
|
+
"""Application Exitpoint."""
|
|
8
|
+
print("Cleanup actions here...")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_cli() -> None:
|
|
12
|
+
"""Application Entrypoint."""
|
|
13
|
+
# atexit.register(exit_cli)
|
|
14
|
+
pfund_plot_group(obj={})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == '__main__':
|
|
18
|
+
run_cli()
|
pfund_plot/mosaic.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
if TYPE_CHECKING:
|
|
4
|
+
from narwhals.typing import IntoFrameT, FrameT
|
|
5
|
+
from pfeed.types.core import tDataFrame
|
|
6
|
+
from pfeed.feeds.base_feed import BaseFeed
|
|
7
|
+
from pfund_plot.types.literals import tDISPLAY_MODE
|
|
8
|
+
from pfund_plot.types.core import tOutput
|
|
9
|
+
from holoviews.core.overlay import Overlay
|
|
10
|
+
from panel.layout import Panel
|
|
11
|
+
|
|
12
|
+
import panel as pn
|
|
13
|
+
import narwhals as nw
|
|
14
|
+
from bokeh.models import HoverTool, CrosshairTool
|
|
15
|
+
|
|
16
|
+
from pfund_plot.const.enums import DisplayMode, PlottingBackend, DataType
|
|
17
|
+
from pfund_plot.utils.validate import validate_data_type
|
|
18
|
+
from pfund_plot.renderer import render
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ['candlestick_plot']
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
REQUIRED_COLS = ['ts', 'open', 'high', 'low', 'close', 'volume']
|
|
25
|
+
DEFAULT_STYLE = {
|
|
26
|
+
'title': 'Candlestick Chart',
|
|
27
|
+
'ylabel': 'price',
|
|
28
|
+
'xlabel': 'time',
|
|
29
|
+
}
|
|
30
|
+
# needs a default value for "responsive"=True to work properly in notebook environment
|
|
31
|
+
DEFAULT_HEIGHT_FOR_NOTEBOOK = 280
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_df(df: IntoFrameT) -> FrameT:
|
|
35
|
+
import datetime
|
|
36
|
+
df: FrameT = nw.from_native(df)
|
|
37
|
+
if isinstance(df, nw.LazyFrame):
|
|
38
|
+
df = df.collect()
|
|
39
|
+
# convert all columns to lowercase
|
|
40
|
+
df = df.rename({col: col.lower() for col in df.columns})
|
|
41
|
+
# rename 'date' to 'ts'
|
|
42
|
+
if 'date' in df.columns and 'ts' not in df.columns:
|
|
43
|
+
df = df.rename({'date': 'ts'})
|
|
44
|
+
missing_cols = [col for col in REQUIRED_COLS if col not in df.columns]
|
|
45
|
+
if missing_cols:
|
|
46
|
+
raise ValueError(f"Missing required columns: {missing_cols}")
|
|
47
|
+
ts_value = df.select('ts').row(0)[0]
|
|
48
|
+
# convert ts to datetime if not already
|
|
49
|
+
if not isinstance(ts_value, datetime.datetime):
|
|
50
|
+
# REVIEW: this might mess up the datetime format
|
|
51
|
+
df = df.with_columns(
|
|
52
|
+
nw.col('ts').str.to_datetime(format=None),
|
|
53
|
+
)
|
|
54
|
+
return df
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_style(df: FrameT, display_mode: DisplayMode, height: int | None, width: int | None) -> dict:
|
|
58
|
+
style = DEFAULT_STYLE.copy()
|
|
59
|
+
if height is not None:
|
|
60
|
+
style['height'] = height
|
|
61
|
+
else:
|
|
62
|
+
if display_mode == DisplayMode.notebook:
|
|
63
|
+
style['height'] = DEFAULT_HEIGHT_FOR_NOTEBOOK
|
|
64
|
+
if width is not None:
|
|
65
|
+
style['width'] = width
|
|
66
|
+
product_or_symbol = None
|
|
67
|
+
if 'symbol' in df.columns:
|
|
68
|
+
product_or_symbol = df.select('symbol').row(0)[0]
|
|
69
|
+
elif 'product' in df.columns:
|
|
70
|
+
product_or_symbol = df.select('product').row(0)[0]
|
|
71
|
+
if product_or_symbol:
|
|
72
|
+
style['title'] = f"{product_or_symbol} {style['title']}"
|
|
73
|
+
return style
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _create_hover_tool(df: FrameT) -> HoverTool:
|
|
77
|
+
from pfund_plot.utils.utils import is_daily_data
|
|
78
|
+
ts_format = '%Y-%m-%d' if is_daily_data(df) else '%Y-%m-%d %H:%M:%S'
|
|
79
|
+
return HoverTool(
|
|
80
|
+
tooltips=[
|
|
81
|
+
('ts', f'@ts{{{ts_format}}}'),
|
|
82
|
+
('open', '@open'),
|
|
83
|
+
('high', '@high'),
|
|
84
|
+
('low', '@low'),
|
|
85
|
+
('close', '@close'),
|
|
86
|
+
('volume', '@volume'),
|
|
87
|
+
],
|
|
88
|
+
formatters={'@ts': 'datetime'},
|
|
89
|
+
mode='vline',
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def candlestick_plot(
|
|
94
|
+
data: tDataFrame | BaseFeed,
|
|
95
|
+
streaming: bool = False,
|
|
96
|
+
display_mode: tDISPLAY_MODE = "notebook",
|
|
97
|
+
num_data: int = 100,
|
|
98
|
+
raw_figure: bool = False,
|
|
99
|
+
slider_step: int = 100,
|
|
100
|
+
show_volume: bool = True,
|
|
101
|
+
streaming_freq: int = 1000, # in milliseconds
|
|
102
|
+
# styling
|
|
103
|
+
up_color: str = 'green',
|
|
104
|
+
down_color: str = 'red',
|
|
105
|
+
bg_color: str = 'white',
|
|
106
|
+
height: int | None = None,
|
|
107
|
+
width: int | None = None,
|
|
108
|
+
grid: bool = True,
|
|
109
|
+
) -> tOutput:
|
|
110
|
+
'''
|
|
111
|
+
Args:
|
|
112
|
+
data: the data to plot, either a dataframe or pfeed's feed object
|
|
113
|
+
streaming: if True, the plot will be updated in real-time as new data is received
|
|
114
|
+
display_mode: where to display the plot, either "notebook", "browser", or "desktop"
|
|
115
|
+
streaming_freq: the update frequency of the streaming data in milliseconds
|
|
116
|
+
raw_figure: if True, returns the raw figure object (e.g. bokeh.plotting.figure or plotly.graph_objects.Figure)
|
|
117
|
+
if False, returns the holoviews.core.overlay.Overlay object
|
|
118
|
+
num_data: the initial number of data points to display.
|
|
119
|
+
This can be changed by a slider in the plot.
|
|
120
|
+
up_color: the color of the up candle, hex code is supported
|
|
121
|
+
down_color: the color of the down candle, hex code is supported
|
|
122
|
+
bg_color: the background color of the plot, hex code is supported
|
|
123
|
+
height: the height of the plot
|
|
124
|
+
width: the width of the plot
|
|
125
|
+
'''
|
|
126
|
+
# TODO: add volume plot when show_volume is True
|
|
127
|
+
# TODO: add range selector
|
|
128
|
+
# TODO: date input
|
|
129
|
+
# TODO: using tick data to update the current candlestick
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
display_mode, plotting_backend = DisplayMode[display_mode.lower()], PlottingBackend.bokeh
|
|
133
|
+
data_type: DataType = validate_data_type(data, streaming, import_hvplot=True)
|
|
134
|
+
if data_type == DataType.datafeed:
|
|
135
|
+
# TODO: get streaming data in the format of dataframe, and then call _validate_df
|
|
136
|
+
# df = data.get_realtime_data(...)
|
|
137
|
+
pass
|
|
138
|
+
else:
|
|
139
|
+
df = data
|
|
140
|
+
df: FrameT = _validate_df(df)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Define reactive values
|
|
144
|
+
max_num_data = pn.rx(df.shape[0])
|
|
145
|
+
|
|
146
|
+
# Main Component: candlestick plot
|
|
147
|
+
def _create_plot(_df: FrameT, _num_data: int):
|
|
148
|
+
plot_df: tDataFrame = _df.tail(_num_data).to_native()
|
|
149
|
+
return (
|
|
150
|
+
plot_df
|
|
151
|
+
.hvplot
|
|
152
|
+
.ohlc(
|
|
153
|
+
'ts', ['open', 'low', 'high', 'close'],
|
|
154
|
+
hover_cols=REQUIRED_COLS,
|
|
155
|
+
tools=[
|
|
156
|
+
_create_hover_tool(_df),
|
|
157
|
+
CrosshairTool(dimensions='height', line_color='gray', line_alpha=0.3)
|
|
158
|
+
],
|
|
159
|
+
grid=grid,
|
|
160
|
+
pos_color=up_color,
|
|
161
|
+
neg_color=down_color,
|
|
162
|
+
responsive=True,
|
|
163
|
+
bgcolor=bg_color,
|
|
164
|
+
)
|
|
165
|
+
.opts(**_get_style(_df, display_mode, height, width))
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Side Components 1: data points slider
|
|
169
|
+
points_slider = pn.widgets.IntSlider(
|
|
170
|
+
name='Number of Most Recent Data Points',
|
|
171
|
+
value=min(num_data, max_num_data.rx.value),
|
|
172
|
+
start=num_data,
|
|
173
|
+
end=max_num_data,
|
|
174
|
+
step=slider_step,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Side Components 2: show all data button
|
|
178
|
+
show_all_data_button = pn.widgets.Button(
|
|
179
|
+
name='Show All',
|
|
180
|
+
button_type='primary',
|
|
181
|
+
)
|
|
182
|
+
def max_out_slider(event):
|
|
183
|
+
points_slider.value = max_num_data.rx.value
|
|
184
|
+
show_all_data_button.on_click(max_out_slider)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
periodic_callback = None
|
|
188
|
+
if raw_figure:
|
|
189
|
+
fig: Overlay = _create_plot(df, _num_data=num_data)
|
|
190
|
+
else:
|
|
191
|
+
if not streaming:
|
|
192
|
+
plot_pane = pn.pane.HoloViews(
|
|
193
|
+
pn.bind(_create_plot, _df=df, _num_data=points_slider)
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
# NOTE: do NOT bind the plot to the slider, otherwise the change of slider value and the periodic callback
|
|
197
|
+
# will BOTH trigger the plot update, causing an error, probably a race condition
|
|
198
|
+
plot_pane = pn.pane.HoloViews(_create_plot(df, points_slider.value))
|
|
199
|
+
|
|
200
|
+
def _update_plot():
|
|
201
|
+
# FIXME
|
|
202
|
+
# TEMP: fake streaming data
|
|
203
|
+
import pandas as pd
|
|
204
|
+
nonlocal df
|
|
205
|
+
pandas_df = df.to_native()
|
|
206
|
+
last_ts = pandas_df['ts'].iloc[-1]
|
|
207
|
+
new_ts = last_ts + pd.Timedelta(days=1)
|
|
208
|
+
new_row = pd.DataFrame({
|
|
209
|
+
'date': [new_ts.date()],
|
|
210
|
+
'ts': [new_ts],
|
|
211
|
+
'symbol': ['AAPL'],
|
|
212
|
+
'product': ['AAPL_USD_STK'],
|
|
213
|
+
'open': [pandas_df['open'].iloc[-1] * 1.01], # 1% higher than last price
|
|
214
|
+
'high': [pandas_df['high'].iloc[-1] * 1.02],
|
|
215
|
+
'low': [pandas_df['low'].iloc[-1] * 0.99],
|
|
216
|
+
'close': [pandas_df['close'].iloc[-1] * 1.015],
|
|
217
|
+
'volume': [int(pandas_df['volume'].iloc[-1] * 0.8)]
|
|
218
|
+
})
|
|
219
|
+
df2 = pd.concat([pandas_df, new_row], ignore_index=True)
|
|
220
|
+
df = nw.from_native(df2)
|
|
221
|
+
|
|
222
|
+
# this will also update the slider's end value since it's a reactive object
|
|
223
|
+
max_num_data.rx.value = df.shape[0]
|
|
224
|
+
|
|
225
|
+
plot_pane.object = _create_plot(df, points_slider.value)
|
|
226
|
+
|
|
227
|
+
periodic_callback = pn.state.add_periodic_callback(_update_plot, period=streaming_freq, start=False) # period in milliseconds
|
|
228
|
+
fig: Panel = pn.Column(
|
|
229
|
+
plot_pane,
|
|
230
|
+
pn.Row(points_slider, show_all_data_button, align='center'),
|
|
231
|
+
sizing_mode='stretch_both',
|
|
232
|
+
name=DEFAULT_STYLE['title'],
|
|
233
|
+
)
|
|
234
|
+
return render(fig, display_mode, raw_figure=raw_figure, plotting_backend=plotting_backend, periodic_callback=periodic_callback)
|