Files
OpenBB/openbb_terminal/core/plots/plotly_helper.py
teh_coderer 30ca6c69e0 Hotfix/options plot-smile (#4613)
* init

* Update style.css

* oops was stashed

* Update popups.js

* bump plotly.js version

* fix disappearing modebar
2023-03-29 17:51:02 +00:00

1822 lines
61 KiB
Python

"""Chart and style helpers for Plotly."""
# pylint: disable=C0302,R0902,W3301
import json
import os
import textwrap
from datetime import datetime
from math import floor
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional, Tuple, TypeVar, Union
try:
# pylint: disable=W0611 # noqa: F401
from darts import TimeSeries
except ImportError:
pass
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
import statsmodels.api as sm
from plotly.subplots import make_subplots
from scipy import stats
from openbb_terminal.base_helpers import console, strtobool
from openbb_terminal.core.config.paths import (
STYLES_DIRECTORY_REPO,
)
from openbb_terminal.core.plots.backend import PLOTLYJS_PATH, plots_backend
from openbb_terminal.core.plots.config.openbb_styles import (
PLT_COLORWAY,
PLT_DECREASING_COLORWAY,
PLT_INCREASING_COLORWAY,
PLT_TBL_ROW_COLORS,
)
from openbb_terminal.core.session.current_user import get_current_user
TimeSeriesT = TypeVar("TimeSeriesT", bound="TimeSeries")
class TerminalStyle:
"""The class that helps with handling of style configurations.
It serves styles for 2 libraries. For `Plotly` this class serves absolute paths
to the .pltstyle files. For `Plotly` and `Rich` this class serves custom
styles as python dictionaries.
"""
STYLES_REPO = STYLES_DIRECTORY_REPO
USER_STYLES_DIRECTORY = get_current_user().preferences.USER_STYLES_DIRECTORY
plt_styles_available: Dict[str, Path] = {}
plt_style: str = "dark"
plotly_template: Dict[str, Any] = {}
mapbox_style: str = "dark"
console_styles_available: Dict[str, Path] = {}
console_style: Dict[str, Any] = {}
line_color: str = ""
up_color: str = ""
down_color: str = ""
up_colorway: List[str] = []
down_colorway: List[str] = []
up_color_transparent: str = ""
down_color_transparent: str = ""
line_width: float = 1.5
def __init__(
self,
plt_style: Optional[str] = "",
console_style: Optional[str] = "",
):
"""Initialize the class.
Parameters
----------
plt_style : `str`, optional
The name of the Plotly style to use, by default ""
console_style : `str`, optional
The name of the Rich style to use, by default ""
"""
self.plt_style = plt_style or self.plt_style
self.load_available_styles()
self.load_style(plt_style)
self.load_style(plt_style)
self.apply_console_style(console_style)
def apply_console_style(self, style: Optional[str] = "") -> None:
"""Apply the style to the console."""
if style in self.console_styles_available:
json_path: Optional[Path] = self.console_styles_available[style]
else:
self.load_available_styles()
if style in self.console_styles_available:
json_path = self.console_styles_available[style]
else:
console.print("\nInvalid console style. Using default.")
json_path = self.console_styles_available.get("dark", None)
if json_path:
self.console_style = self.load_json_style(json_path)
else:
console.print("Error loading default.")
def apply_style(self, style: Optional[str] = "") -> None:
"""Apply the style to the libraries."""
if not style:
style = get_current_user().preferences.PLOT_STYLE
if style != self.plt_style:
self.load_style(style)
style = style.lower().replace("light", "white") # type: ignore
if self.plt_style and self.plotly_template:
self.plotly_template.setdefault("layout", {}).setdefault(
"mapbox", {}
).setdefault("style", "dark")
if "tables" in self.plt_styles_available:
tables = self.load_json_style(self.plt_styles_available["tables"])
pio.templates["openbb_tables"] = go.layout.Template(tables)
pio.templates["openbb"] = go.layout.Template(self.plotly_template)
if style in ["dark", "white"]:
pio.templates.default = f"plotly_{style}+openbb"
return
pio.templates.default = "openbb"
self.mapbox_style = (
self.plotly_template.setdefault("layout", {})
.setdefault("mapbox", {})
.setdefault("style", "dark")
)
def load_available_styles_from_folder(self, folder: Path) -> None:
"""Load custom styles from folder.
Parses the styles/default and styles/user folders and loads style files.
To be recognized files need to follow a naming convention:
*.pltstyle - plotly stylesheets
*.richstyle.json - rich stylesheets
Parameters
----------
folder : str
Path to the folder containing the stylesheets
"""
if not folder.exists():
return
for attr, ext in zip(
["plt_styles_available", "console_styles_available"],
[".pltstyle.json", ".richstyle.json"],
):
for file in folder.rglob(f"*{ext}"):
getattr(self, attr)[file.name.replace(ext, "")] = file
def load_available_styles(self) -> None:
"""Load custom styles from default and user folders."""
self.load_available_styles_from_folder(self.STYLES_REPO)
self.load_available_styles_from_folder(self.USER_STYLES_DIRECTORY)
def load_json_style(self, file: Path) -> Dict[str, Any]:
"""Load style from json file.
Parameters
----------
file : Path
Path to the file containing the style
Returns
-------
Dict[str, Any]
Style as a dictionary
"""
with open(file) as f:
return json.load(f)
def load_style(self, style: Optional[str] = "") -> None:
"""Load style from file.
Parameters
----------
style : str
Name of the style to load
"""
style = style or self.plt_style
if style not in self.plt_styles_available:
console.print(
f"[red]Plot Style {style} not found. Using default style.[/red]",
)
style = "dark"
self.load_plt_style(style)
def load_plt_style(self, style: str) -> None:
"""Load Plotly style from file.
Parameters
----------
style : str
Name of the style to load
"""
self.plt_style = style
self.plotly_template = self.load_json_style(self.plt_styles_available[style])
line = self.plotly_template.pop("line", {})
self.up_color = line.get("up_color", "#00ACFF")
self.down_color = line.get("down_color", "#FF0000")
self.up_color_transparent = line.get(
"up_color_transparent", "rgba(0, 170, 255, 0.50)"
)
self.down_color_transparent = line.get(
"down_color_transparent", "rgba(230, 0, 57, 0.50)"
)
self.line_color = line.get("color", "#ffed00")
self.line_width = line.get("width", self.line_width)
self.down_colorway = line.get("down_colorway", PLT_DECREASING_COLORWAY)
self.up_colorway = line.get("up_colorway", PLT_INCREASING_COLORWAY)
def get_colors(self, reverse: bool = False) -> list:
"""Get colors for the plot.
Parameters
----------
reverse : bool, optional
Whether to reverse the colors, by default False
Returns
-------
list
List of colors e.g. ["#00ACFF", "#FF0000"]
"""
self.apply_style()
colors = self.plotly_template.get("layout", {}).get("colorway", PLT_COLORWAY)
if reverse:
colors.reverse()
return colors
theme = TerminalStyle(
get_current_user().preferences.PLOT_STYLE, get_current_user().preferences.RICH_STYLE
)
theme.apply_style()
# pylint: disable=R0913
class OpenBBFigure(go.Figure):
"""Custom Figure class for OpenBB Terminal.
Parameters
----------
fig : `go.Figure`, optional
Figure to copy, by default None
has_subplots : `bool`, optional
Whether the figure has subplots, by default False
**kwargs
Keyword arguments to pass to `go.Figure.update_layout`
Class Methods
-------------
create_subplots(rows: `int`, cols: `int`, **kwargs) -> `OpenBBFigure`
Creates a subplots figure
to_table(data: `pd.DataFrame`, columnwidth: `list`, print_index: `bool`, ...)
Converts a DataFrame to a table figure
Methods
-------
add_hline_legend(y: `float`, name: `str`, line: `dict`, legendrank: `int`, **kwargs)
Adds a horizontal line with a legend label
add_vline_legend(x: `float`, name: `str`, line: `dict`, legendrank: `int`, **kwargs)
Adds a vertical line with a legend label
add_legend_label(trace: `str`, label: `str`, mode: `str`, marker: `dict`, **kwargs)
Adds a legend label
add_histplot(x: `list`, name: `str`, colors: `list`, bins: `int`, show_curve: `str`, ...)
Adds a histogram plot
horizontal_legend(x: `float`, y: `float`, xanchor: `str`, yanchor: `str`, ...)
Moves the legend to a horizontal position
to_subplot(subplot: `OpenBBFigure`, row: `int`, col: `int`, secondary_y: `bool`, ...)
Returns the figure as a subplot of another figure
"""
plotlyjs_path: Path = PLOTLYJS_PATH
def __init__(self, fig: Optional[go.Figure] = None, **kwargs) -> None:
super().__init__()
if fig:
self.__dict__ = fig.__dict__
self._has_secondary_y = kwargs.pop("has_secondary_y", False)
self._subplots_kwargs: Dict[str, Any] = kwargs.pop("subplots_kwargs", {})
self._multi_rows = kwargs.pop("multi_rows", False)
self._added_logscale = False
self._date_xaxs: dict = {}
self._margin_adjusted = False
self._feature_flags_applied = False
self._exported = False
self._cmd_xshift = 0
self._bar_width = 0.2
self._export_image: Optional[Union[Path, str]] = ""
self._subplot_xdates: Dict[int, Dict[int, List[Any]]] = {}
if xaxis := kwargs.pop("xaxis", None):
self.update_xaxes(xaxis)
if yaxis := kwargs.pop("yaxis", None):
self.update_yaxes(yaxis)
self.update_layout(**kwargs)
if plots_backend().isatty:
self.update_layout(
margin=dict(l=0, r=0, t=0, b=0, pad=0, autoexpand=True),
height=plots_backend().HEIGHT,
width=plots_backend().WIDTH,
)
@property
def subplots_kwargs(self):
"""Get subplots kwargs property."""
return self._subplots_kwargs
@subplots_kwargs.setter
def subplots_kwargs(self, value):
"""Get subplots kwargs setter."""
self._subplots_kwargs = value
@property
def has_subplots(self):
"""Has subplots property."""
return self._has_subplots()
@property
def bar_width(self):
"""Bar width property."""
return self._bar_width
@bar_width.setter
def bar_width(self, value):
"""Bar width setter."""
self._bar_width = value
@property
def cmd_xshift(self):
"""Command line x shift property."""
return self._cmd_xshift
@cmd_xshift.setter
def cmd_xshift(self, value):
"""Command line x shift setter."""
self._cmd_xshift = value
@classmethod
def create_subplots(
cls,
rows: int = 1,
cols: int = 1,
shared_xaxes: bool = True,
vertical_spacing: Optional[float] = None,
horizontal_spacing: Optional[float] = None,
subplot_titles: Optional[Union[List[str], tuple]] = None,
row_width: Optional[List[Union[float, int]]] = None,
specs: Optional[List[List[Optional[Dict[Any, Any]]]]] = None,
**kwargs,
) -> "OpenBBFigure":
"""Create a new Plotly figure with subplots.
Parameters
----------
rows : `int`, optional
Number of rows, by default 1
cols : `int`, optional
Number of columns, by default 1
shared_xaxes : `bool`, optional
Whether to share x axes, by default True
vertical_spacing : `float`, optional
Vertical spacing between subplots, by default None
horizontal_spacing : `float`, optional
Horizontal spacing between subplots, by default None
subplot_titles : `Union[List[str], tuple]`, optional
Titles for each subplot, by default None
row_width : `List[Union[float, int]]`, optional
Width of each row, by default [1]
specs : `List[List[dict]]`, optional
Subplot specs, by default `[[{}] * cols] * rows` (all subplots are the same size)
"""
# We save the original kwargs to store them in the figure for later use
subplots_kwargs = dict(
rows=rows,
cols=cols,
shared_xaxes=shared_xaxes,
vertical_spacing=vertical_spacing,
horizontal_spacing=horizontal_spacing,
subplot_titles=subplot_titles,
row_width=row_width or [1] * rows,
specs=specs or [[{}] * cols] * rows,
**kwargs,
)
fig = make_subplots(**subplots_kwargs)
kwargs = {
"multi_rows": rows > 1,
"subplots_kwargs": subplots_kwargs,
}
if specs and any(
spec.get("secondary_y", False) for row in specs for spec in row if spec
):
kwargs["has_secondary_y"] = True
return cls(fig, **kwargs)
def add_trend(
self,
data: pd.DataFrame,
row: int = 1,
col: int = 1,
secondary_y: bool = False,
**kwargs,
):
"""Add a trend line to the figure.
Parameters
----------
data : `pd.DataFrame`
Data to plot
row : `int`, optional
Row number, by default 1
col : `int`, optional
Column number, by default 1
secondary_y : `bool`, optional
Whether to plot on secondary y axis, by default None
"""
try:
for column, color in zip(
["OC_High_trend", "OC_Low_trend"], [theme.up_color, theme.down_color]
):
if column in data.columns:
name = column.split("_")[1].title()
trend = data.copy().dropna()
self.add_shape(
type="line",
name=f"{name} Trend",
x0=trend.index[0],
y0=trend[column].iloc[0],
x1=trend.index[-1],
y1=trend[column].iloc[-1],
line=dict(color=color, width=2),
row=row,
col=col,
secondary_y=secondary_y,
**kwargs,
)
except Exception:
console.print("[red]Error adding trend line[/red]")
def add_histplot(
self,
dataset: Union[np.ndarray, pd.Series, TimeSeriesT],
name: Optional[Union[str, List[str]]] = None,
colors: Optional[List[str]] = None,
bins: Union[int, str] = 15,
curve: Literal["normal", "kde"] = "normal",
show_curve: bool = True,
show_rug: bool = True,
show_hist: bool = True,
forecast: bool = False,
row: int = 1,
col: int = 1,
) -> None:
"""Add a histogram with a curve and rug plot if desired.
Parameters
----------
dataset : `Union[np.ndarray, pd.Series, TimeSeriesT]`
Data to plot
name : `Optional[Union[str, List[str]]]`, optional
Name of the plot, by default None
colors : `Optional[List[str]]`, optional
Colors of the plot, by default None
bins : `Union[int, str]`, optional
Number of bins, by default 15
curve : `Literal["normal", "kde"]`, optional
Type of curve to plot, by default "normal"
show_curve : `bool`, optional
Whether to show the curve, by default True
show_rug : `bool`, optional
Whether to show the rug plot, by default True
show_hist : `bool`, optional
Whether to show the histogram, by default True
forecast : `bool`, optional
Whether the data is a darts forecast TimeSeries, by default False
row : `int`, optional
Row of the subplot, by default 1
col : `int`, optional
Column of the subplot, by default 1
"""
callback = stats.norm if curve == "normal" else stats.gaussian_kde
def _validate_x(data: Union[np.ndarray, pd.Series, type[TimeSeriesT]]):
if forecast:
data = data.univariate_values() # type: ignore
if isinstance(data, pd.Series):
data = data.values
if isinstance(data, np.ndarray):
data = data.tolist()
if isinstance(data, list):
data = [data]
return data
valid_x = _validate_x(dataset)
if isinstance(name, str):
name = [name]
if isinstance(colors, str):
colors = [colors]
if not name:
name = [None] * len(valid_x) # type: ignore
if not colors:
colors = [None] * len(valid_x) # type: ignore
max_y = 0
for i, (x_i, name_i, color_i) in enumerate(zip(valid_x, name, colors)):
if not color_i:
color_i = theme.up_color if i % 2 == 0 else theme.down_color
res_mean, res_std = np.mean(x_i), np.std(x_i)
res_min, res_max = min(x_i), max(x_i)
x = np.linspace(res_min, res_max, 100)
if show_hist:
if forecast:
components = list(dataset.components[:4]) # type: ignore
values = (
dataset[components].all_values(copy=False).flatten(order="F") # type: ignore
)
n_components = len(components)
n_entries = len(values) // n_components
for i, label in zip(range(n_components), components):
self.add_histogram(
x=values[i * n_entries : (i + 1) * n_entries], # noqa: E203
name=label,
marker_color=color_i,
nbinsx=bins,
opacity=0.7,
row=row,
col=col,
)
else:
self.add_histogram(
x=x_i,
name=name_i,
marker_color=color_i,
nbinsx=bins,
histnorm="probability density",
histfunc="sum",
opacity=0.7,
row=row,
col=col,
)
if show_rug:
self.add_scatter(
x=x_i,
y=[0.002] * len(x_i),
name=name_i if len(name) < 2 else name[1],
mode="markers",
marker=dict(
color=theme.down_color,
symbol="line-ns-open",
size=8,
),
row=row,
col=col,
)
if show_curve:
# type: ignore
if curve == "kde":
curve_x = [None] * len(valid_x)
curve_y = [None] * len(valid_x)
# pylint: disable=consider-using-enumerate
for index in range(len(valid_x)):
curve_x[index] = [ # type: ignore
res_min + xx * (res_max - res_min) / 500
for xx in range(500)
]
curve_y[index] = stats.gaussian_kde(valid_x[index])(
curve_x[index]
)
for index in range(len(valid_x)):
self.add_scatter(
x=curve_x[index], # type: ignore
y=curve_y[index], # type: ignore
name=name_i,
mode="lines",
showlegend=False,
marker=dict(color=color_i),
row=row,
col=col,
)
max_y = max(max_y, max(curve_y[index]) * 1.2) # type: ignore
else:
y = (
callback(res_mean, res_std).pdf(x)
* len(valid_x[0])
* (res_max - res_min)
/ bins
)
self.add_scatter(
x=x,
y=y,
name=name_i,
mode="lines",
marker=dict(color=color_i),
showlegend=False,
row=row,
col=col,
)
max_y = max(max_y, max(y * 2))
self.update_yaxes(
position=0.0,
range=[0, max_y],
row=row,
col=col,
automargin=False,
autorange=False,
)
self.update_layout(barmode="overlay", bargap=0.01, bargroupgap=0)
def is_image_export(self, export: Optional[str] = "") -> bool:
"""Check if the export format is an image format.
Parameters
----------
export : `str`
Export format
Returns
-------
`bool`
True if the export format is an image format, False otherwise
"""
if not export:
return False
return any(ext in export for ext in ["jpg", "pdf", "png", "svg"])
def set_title(
self, title: str, wrap: bool = False, wrap_width: int = 80, **kwargs
) -> "OpenBBFigure":
"""Set the main title of the figure.
Parameters
----------
title : `str`
Title of the figure
wrap : `bool`
If True, the title will be wrapped according to the wrap_width parameter
wrap_width : `int`
Width in characters to wrap the title if wrap is True, default is 80
"""
if wrap:
title = "<br>".join(textwrap.wrap(title, width=wrap_width))
if kwargs.get("row", None) is not None and kwargs.get("col", None) is not None:
self.add_annotation(
text=title,
xref="x domain",
yref="y domain",
x=0.5,
y=1.0,
xanchor="center",
yanchor="bottom",
**kwargs,
)
return self
self.update_layout(title=dict(text=title, **kwargs))
return self
def set_xaxis_title(
self,
title: str,
row: Optional[int] = None,
col: Optional[int] = None,
**kwargs,
) -> "OpenBBFigure":
"""Set the x axis title of the figure or subplot (if row and col are specified).
Parameters
----------
title : `str`
Title of the x axis
row : `int`, optional
Row number, by default None
col : `int`, optional
Column number, by default None
"""
self.update_xaxes(title=title, row=row, col=col, **kwargs)
return self
def set_yaxis_title(
self, title: str, row: Optional[int] = None, col: Optional[int] = None, **kwargs
) -> "OpenBBFigure":
"""Set the y axis title of the figure or subplot (if row and col are specified).
Parameters
----------
title : `str`
Title of the x axis
row : `int`, optional
Row number, by default None
col : `int`, optional
Column number, by default None
"""
self.update_yaxes(title=title, row=row, col=col, **kwargs)
return self
def add_hline_legend(
self,
y: float,
name: str,
line: Optional[dict] = None,
legendrank: Optional[int] = None,
**kwargs,
) -> None:
"""Add a horizontal line with a legend label.
Parameters
----------
y : `float`
y value of the line
name : `str`
Name of the to display in the legend
line : `dict`
Line style
legendrank : `int`, optional
Legend rank, by default None (e.g. 1 is above 2)
"""
if line is None:
line = {}
self.add_hline(
y,
line=line,
)
self.add_legend_label(
label=name,
mode="lines",
line_dash=line.get("dash", "solid"),
marker=dict(color=line.get("color", theme.line_color)),
legendrank=legendrank,
**kwargs,
)
def add_vline_legend(
self,
x: float,
name: str,
line: Optional[dict] = None,
legendrank: Optional[int] = None,
**kwargs,
) -> None:
"""Add a vertical line with a legend label.
Parameters
----------
x : `float`
x value of the line
name : `str`
Name of the to display in the legend
line : `dict`
Line style
legendrank : `int`, optional
Legend rank, by default None (e.g. 1 is above 2)
"""
if line is None:
line = {}
self.add_vline(
x,
line=line,
)
self.add_legend_label(
label=name,
mode="lines",
line_dash=line.get("dash", "solid"),
marker=dict(color=line.get("color", theme.line_color)),
legendrank=legendrank,
**kwargs,
)
def horizontal_legend(
self,
x: float = 1,
y: float = 1.02,
xanchor: str = "right",
yanchor: str = "bottom",
orientation: str = "h",
**kwargs,
) -> None:
"""Set the legend to be horizontal.
Parameters
----------
x : `float`, optional
The x position of the legend, by default 1
y : `float`, optional
The y position of the legend, by default 1.02
xanchor : `str`, optional
The x anchor of the legend, by default "right"
yanchor : `str`, optional
The y anchor of the legend, by default "bottom"
orientation : `str`, optional
The orientation of the legend, by default "h"
"""
self.update_layout(
legend=dict(
x=x,
y=y,
xanchor=xanchor,
yanchor=yanchor,
orientation=orientation,
**kwargs,
)
)
@staticmethod
def chart_volume_scaling(
df_volume: pd.DataFrame, range_x: int = 7
) -> Dict[str, list]:
"""Takes df_volume and returns volume_ticks, tickvals for chart volume scaling
Parameters
----------
df_volume : pd.DataFrame
Dataframe of volume (e.g. df_volume = df["Volume"])
range_x : int, optional
Number to multiply volume, by default 7
Returns
-------
Dict[str, list]
{"range": volume_range, "ticks": tickvals}
"""
df_volume = df_volume.apply(lambda x: f"{x:.1f}")
df_volume = pd.to_numeric(df_volume.astype(float))
volume_ticks = int(df_volume.max().max())
round_digits = -3
first_val = round(volume_ticks * 0.20, round_digits)
for x, y in zip([2, 5, 6, 7, 8, 9, 10], [1, 4, 5, 6, 7, 8, 9]):
if len(str(volume_ticks)) > x:
round_digits = -y
first_val = round(volume_ticks * 0.20, round_digits)
tickvals = [
floor(first_val),
floor(first_val * 2),
floor(first_val * 3),
floor(first_val * 4),
]
volume_range = [0, floor(volume_ticks * range_x)]
return {"range": volume_range, "ticks": tickvals}
def add_inchart_volume(
self,
df_stock: pd.DataFrame,
close_col: Optional[str] = "Close",
volume_col: Optional[str] = "Volume",
row: Optional[int] = 1,
col: Optional[int] = 1,
) -> None:
"""Add in-chart volume to a subplot.
Parameters
----------
df_stock : `pd.DataFrame`
Dataframe of the stock
close_col : `str`, optional
Name of the close column, by default "Close"
volume_col : `str`, optional
Name of the volume column, by default "Volume"
row : `int`, optional
Row number, by default 2
col : `int`, optional
Column number, by default 1
"""
colors = np.where(
df_stock.Open < df_stock[close_col], theme.up_color, theme.down_color
)
vol_scale = self.chart_volume_scaling(df_stock[volume_col])
self.add_bar(
x=df_stock.index,
y=df_stock[volume_col],
name="Volume",
marker_color=colors,
yaxis="y2",
row=row,
col=col,
opacity=0.5,
secondary_y=False,
)
ticksize = 14 - (self.subplots_kwargs["rows"] // 2)
self.update_layout(
yaxis=dict(
fixedrange=True,
side="left",
nticks=10,
range=vol_scale["range"],
tickvals=vol_scale["ticks"],
showgrid=False,
showline=False,
zeroline=False,
tickfont=dict(size=ticksize),
),
yaxis2=dict(
autorange=True,
side="right",
fixedrange=False,
anchor="x",
layer="above traces",
overlaying="y",
),
)
def add_legend_label(
self,
trace: Optional[str] = None,
label: Optional[str] = None,
mode: Optional[str] = None,
marker: Optional[dict] = None,
line_dash: Optional[str] = None,
legendrank: Optional[int] = None,
**kwargs,
) -> None:
"""Add a legend label.
Parameters
----------
trace : `str`, optional
The name of the trace to use as a template, by default None
label : `str`, optional
The label to use, by default None (uses the trace name if trace is specified)
If trace is not specified, label must be specified
mode : `str`, optional
The mode to use, by default "lines" (uses the trace mode if trace is specified)
marker : `dict`, optional
The marker to use, by default dict() (uses the trace marker if trace is specified)
line_dash : `str`, optional
The line dash to use, by default "solid" (uses the trace line dash if trace is specified)
legendrank : `int`, optional
The legend rank, by default None (e.g. 1 is above 2)
Raises
------
ValueError
If trace is not found
ValueError
If label is not specified and trace is not specified
"""
if trace:
for trace_ in self.data:
if trace_.name == trace:
for arg, default in zip(
[label, mode, marker, line_dash],
[trace, trace_.mode, trace_.marker, trace_.line_dash],
):
if not arg and default:
arg = default
kwargs.update(dict(yaxis=trace_.yaxis))
break
else:
raise ValueError(f"Trace '{trace}' not found")
if not label:
raise ValueError("Label must be specified")
self.add_scatter(
x=[None],
y=[None],
mode=mode or "lines",
name=label,
marker=marker or dict(),
line_dash=line_dash or "solid",
legendrank=legendrank,
**kwargs,
)
def show(
self,
*args,
external: bool = False,
export_image: Optional[Union[Path, str]] = "",
**kwargs,
) -> Optional["OpenBBFigure"]:
"""Show the figure.
Parameters
----------
external : `bool`, optional
Whether to return the figure object instead of showing it, by default False
export_image : `Union[Path, str]`, optional
The path to export the figure image to, by default ""
cmd_xshift : `int`, optional
The x shift of the command source annotation, by default 0
bar_width : `float`, optional
The width of the bars, by default 0.0001
"""
self.cmd_xshift = kwargs.pop("cmd_xshift", self.cmd_xshift)
self.bar_width = kwargs.pop("bar_width", self.bar_width)
self._export_image = export_image
if export_image and not plots_backend().isatty:
if isinstance(export_image, str):
export_image = Path(export_image).resolve()
export_image.touch()
if kwargs.pop("margin", True):
self._adjust_margins()
theme.apply_style()
self._apply_feature_flags()
self._xaxis_tickformatstops()
self.update_traces(marker_line_width=self.bar_width, selector=dict(type="bar"))
self.update_traces(
selector=dict(type="scatter", hovertemplate=None),
hovertemplate="%{y}<extra></extra>",
)
# Set modebar style
self.update_layout( # type: ignore
newshape_line_color="gold" if theme.mapbox_style == "dark" else "#0d0887",
modebar=dict(
orientation="v",
bgcolor="#2A2A2A" if theme.mapbox_style == "dark" else "gray",
color="#FFFFFF" if theme.mapbox_style == "dark" else "black",
activecolor="#d1030d" if theme.mapbox_style == "dark" else "blue",
),
)
if external or self._exported:
return self # type: ignore
# We check if in headless mode to return the JSON
if strtobool(os.environ.get("HEADLESS", False)):
return self.to_json()
kwargs.update(config=dict(scrollZoom=True, displaylogo=False))
if plots_backend().isatty:
try:
# We check if we need to export the image
# This is done to avoid opening after exporting
if export_image:
self._exported = True
# We send the figure to the backend to be displayed
return plots_backend().send_figure(self, export_image)
except Exception:
# If the backend fails, we just show the figure normally
# This is a very rare case, but it's better to have a fallback
if strtobool(os.environ.get("DEBUG_MODE", False)):
console.print_exception()
# We check if any figures were initialized before the backend failed
# If so, we show them with the default plotly backend
queue = plots_backend().get_pending()
for pending in queue:
fig = json.loads(pending).get("plotly", {})
if not fig:
continue
pio.show(fig, *args, **kwargs)
height = 600 if not self.layout.height else self.layout.height
self.update_layout(
legend=dict(
tracegroupgap=height / 4.5,
groupclick="toggleitem",
orientation="v",
),
barmode="overlay",
bargap=0,
bargroupgap=0,
)
return pio.show(self, *args, **kwargs)
def _xaxis_tickformatstops(self) -> None:
"""Set the datetickformatstops for the xaxis if the x data is datetime."""
if (dateindex := self.get_dateindex()) is None:
return
tickformatstops = [
dict(dtickrange=[None, 86_400_000], value="%I%p\n%b,%d"),
dict(dtickrange=[86_400_000, 604_800_000], value="%Y-%m-%d"),
]
xhoverformat = "%I:%M%p %Y-%m-%d"
# We check if daily data if the first and second time are the same
# since daily data will have the same time (2021-01-01 00:00:00)
if dateindex[-1].time() == dateindex[-2].time():
xhoverformat = "%Y-%m-%d"
tickformatstops = [dict(dtickrange=[None, 604_800_000], value="%Y-%m-%d")]
for entry in self._date_xaxs.values():
self.update_xaxes(
tickformatstops=[
*tickformatstops,
dict(dtickrange=[604_800_000, "M1"], value="%Y-%m-%d"),
dict(dtickrange=["M1", None], value="%Y-%m-%d"),
],
type="date",
selector=dict(anchor=entry["yaxis"]),
)
self.update_traces(
xhoverformat=xhoverformat, selector=dict(name=entry["name"])
)
def get_subplots_dict(self) -> Dict[str, Dict[str, List[Any]]]:
"""Return the subplots dict.
Returns
-------
`dict`
The subplots dict
"""
subplots: Dict[str, Dict[str, List[Any]]] = {}
if not self.has_subplots:
return subplots
grid_ref = self._validate_get_grid_ref() # pylint: disable=protected-access
for r, plot_row in enumerate(grid_ref):
for c, plot_refs in enumerate(plot_row):
if not plot_refs:
continue
for subplot_ref in plot_refs:
if subplot_ref.subplot_type == "xy":
xaxis, yaxis = subplot_ref.layout_keys
xref = xaxis.replace("axis", "")
yref = yaxis.replace("axis", "")
row = r + 1
col = c + 1
subplots.setdefault(xref, {}).setdefault(yref, []).append(
(row, col)
)
return subplots
def get_dateindex(self) -> Optional[List[datetime]]:
"""Return the dateindex of the figure.
Returns
-------
`list`
The dateindex
"""
output: Optional[List[datetime]] = None
subplots = self.get_subplots_dict()
try:
false_y = list(self.select_traces(secondary_y=False))
true_y = list(self.select_traces(secondary_y=True))
except Exception:
false_y = []
true_y = []
for trace in self.select_traces():
if not hasattr(trace, "xaxis"):
continue
xref, yref = trace.xaxis, trace.yaxis
row, col = subplots.get(xref, {}).get(yref, [(None, None)])[0]
if trace.x is not None and len(trace.x) > 5:
for x in trace.x[:2]:
if isinstance(x, (datetime, np.datetime64, pd.DatetimeIndex)):
output = trace.x
name = trace.name if hasattr(trace, "name") else f"{trace}"
secondary_y: Optional[bool] = trace in true_y
if trace not in (false_y + true_y):
secondary_y = None
self._date_xaxs[trace.xaxis] = {
"yaxis": trace.yaxis,
"name": name,
"secondary_y": secondary_y,
}
self._subplot_xdates.setdefault(row, {}).setdefault(
col, []
).append(trace.x)
# We convert the dateindex to a list of datetime objects if it's a numpy array
if output is not None and isinstance(output[0], np.datetime64):
output = (
pd.to_datetime(output).to_pydatetime().astype("datetime64[ms]").tolist()
)
return output
def hide_date_gaps(
self,
df_data: pd.DataFrame,
row: Optional[int] = None,
col: Optional[int] = None,
prepost: bool = False,
) -> None:
"""Add rangebreaks to hide gaps on the xaxis.
Parameters
----------
df_data : `pandas.DataFrame`
The dataframe with the data.
row : `int`, optional
The row of the subplot to hide the gaps, by default None
col : `int`, optional
The column of the subplot to hide the gaps, by default None
prepost : `bool`, optional
Whether to add rangebreaks for pre and post market hours, by default False
"""
# We get the min and max dates
dt_start, dt_end = df_data.index.min(), df_data.index.max()
has_weekends = df_data.index.dayofweek.isin([5, 6]).any()
rangebreaks: List[Dict[str, Any]] = []
# if weekly or monthly data, we don't need to hide gaps
# this prevents distortions in the plot
check_freq = df_data.index.to_series().diff(-5).dt.days.abs().mode().iloc[0]
if check_freq > 7:
return
# We check if weekends are in the df_data
if has_weekends:
# We get the days including weekends
dt_days = pd.date_range(start=dt_start, end=dt_end, normalize=True)
# We get the dates that are missing
dt_missing_days = list(
set(dt_days.strftime("%Y-%m-%d").tolist())
- set(df_data.index.strftime("%Y-%m-%d"))
)
rangebreaks = [dict(values=dt_missing_days)]
else:
# We get the missing days excluding weekends
dt_bdays = pd.bdate_range(start=dt_start, end=dt_end, normalize=True)
# We get the dates that are missing
dt_missing_days = list(
set(dt_bdays.strftime("%Y-%m-%d"))
- set(df_data.index.strftime("%Y-%m-%d"))
)
rangebreaks = [dict(values=dt_missing_days), dict(bounds=["sat", "mon"])]
# We add a rangebreak if the first and second time are not the same
# since daily data will have the same time (00:00)
if df_data.index[-1].time() != df_data.index[-2].time():
if prepost:
rangebreaks.append(dict(bounds=[20.00, 4.00], pattern="hour"))
else:
rangebreaks.append(dict(bounds=[15.99, 9.50], pattern="hour"))
self.update_xaxes(rangebreaks=rangebreaks, row=row, col=col)
def hide_holidays(self, prepost: bool = False) -> None:
"""Add rangebreaks to hide holidays on the xaxis.
Parameters
----------
prepost : `bool`, optional
Whether to add rangebreaks for pre and post market hours, by default False
"""
if self.get_dateindex() is None:
return
for row, row_dict in self._subplot_xdates.items():
for col, values in row_dict.items():
x_values = (
pd.to_datetime(np.concatenate(values))
.to_pydatetime()
.astype("datetime64[ms]")
)
self.hide_date_gaps(
pd.DataFrame(index=x_values.tolist()),
row=row,
col=col,
prepost=prepost,
)
def to_subplot(
self,
subplot: "OpenBBFigure",
row: int,
col: int,
secondary_y: bool = False,
**kwargs,
) -> "OpenBBFigure":
"""Return the figure as a subplot of another figure.
Parameters
----------
subplot : `plotly.graph_objects.Figure`
The subplot
row : `int`
Row number
col : `int`
Column number
secondary_y : `bool`, optional
Whether to use the secondary y axis, by default False
Returns
-------
`plotly.graph_objects.Figure`
The subplot with the figure added
"""
for trace in self.data:
trace.legendgroup = f"{row}"
if kwargs:
trace.update(**kwargs)
subplot.add_trace(trace, row=row, col=col, secondary_y=secondary_y)
subplot.set_xaxis_title(self.layout.xaxis.title.text, row=row, col=col)
subplot.set_yaxis_title(self.layout.yaxis.title.text, row=row, col=col)
return subplot
def to_html(self, *args, **kwargs) -> str:
"""Return the figure as HTML."""
self.update_traces(marker_line_width=0.0001, selector=dict(type="bar"))
kwargs.update(
dict(
config={"scrollZoom": True, "displaylogo": False},
include_plotlyjs=kwargs.pop("include_plotlyjs", False),
full_html=False,
)
)
theme.apply_style()
self._apply_feature_flags()
self._xaxis_tickformatstops()
if not plots_backend().isatty and self.data[0].type != "table":
margin = self.layout.margin
L, R, B, T = margin["l"], margin["r"], margin["b"], margin["t"]
for var, max_val in zip([L, R, B, T], [60, 50, 80, 40]):
if var is not None and var > max_val:
var = max_val
self.layout.margin = dict(l=L, r=R, b=B, t=T, pad=0)
orientation = "v" if self.layout.legend.orientation is None else "h"
if self._multi_rows:
height = 600 if not self.layout.height else self.layout.height
self.update_layout(
legend=dict(tracegroupgap=height / 4.5, groupclick="toggleitem")
)
self.update_layout(
legend=dict(orientation=orientation, x=1.10, font=dict(size=12)),
font=dict(size=14),
)
self.update_xaxes(tickfont=dict(size=13))
self.update_yaxes(tickfont=dict(size=13))
return super().to_html(*args, **kwargs)
@staticmethod
def row_colors(data: pd.DataFrame) -> Optional[List[str]]:
"""Return the row colors of the table.
Parameters
----------
data : `pandas.DataFrame`
The dataframe
Returns
-------
`list`
The list of colors
"""
row_count = len(data)
# we determine how many rows in `data` and then create a list with alternating
# row colors
row_odd_count = floor(row_count / 2) + row_count % 2
row_even_count = floor(row_count / 2)
odd_list = [PLT_TBL_ROW_COLORS[0]] * row_odd_count
even_list = [PLT_TBL_ROW_COLORS[1]] * row_even_count
color_list = [x for y in zip(odd_list, even_list) for x in y]
if row_odd_count > row_even_count:
color_list.append(PLT_TBL_ROW_COLORS[0])
return color_list
@staticmethod
def _tbl_values(data: pd.DataFrame, print_index: bool) -> Tuple[List[str], List]:
"""Return the values of the table.
Parameters
----------
data : `pandas.DataFrame`
The dataframe to convert
print_index : `bool`
Whether to print the index
Returns
-------
`tuple`
The header values and the cell values
"""
if print_index:
header_values = list(
[data.index.name if data.index.name is not None else "", *data.columns]
)
cell_values = [data.index, *[data[col] for col in data]]
else:
header_values = data.columns.to_list()
cell_values = [data[col] for col in data]
header_values = [f"<b>{x}</b>" for x in header_values]
return header_values, cell_values
@classmethod
def to_table(
cls,
data: pd.DataFrame,
columnwidth: Optional[List[Union[int, float]]] = None,
print_index: bool = True,
**kwargs,
) -> "OpenBBFigure":
"""Convert a dataframe to a table figure.
Parameters
----------
data : `pandas.DataFrame`
The dataframe to convert
columnwidth : `list`, optional
The width of each column, by default None (auto)
print_index : `bool`, optional
Whether to print the index, by default True
height : `int`, optional
The height of the table, by default len(data.index) * 28 + 25
width : `int`, optional
The width of the table, by default sum(columnwidth) * 8.7
Returns
-------
`plotly.graph_objects.Figure`
The figure as a table
"""
if not columnwidth:
# we get the length of each column using the max length of the column
# name and the max length of the column values as the column width
columnwidth = [
max(len(str(data[col].name)), data[col].astype(str).str.len().max())
for col in data.columns
]
# we add the length of the index column if we are printing the index
if print_index:
columnwidth.insert(
0,
max(
len(str(data.index.name)),
data.index.astype(str).str.len().max(),
),
)
# we add a percentage of max to the min column width
columnwidth = [
int(x + (max(columnwidth) - min(columnwidth)) * 0.2)
for x in columnwidth
]
header_values, cell_values = cls._tbl_values(data, print_index)
if (height := kwargs.get("height", None)) and height < len(
data.index
) * 28 + 25:
kwargs.pop("height")
if (width := kwargs.get("width", None)) and width < sum(columnwidth) * 8.7:
kwargs.pop("width")
height = kwargs.pop("height", len(data.index) * 28 + 25)
width = kwargs.pop("width", sum(columnwidth) * 8.7)
fig: OpenBBFigure = cls()
fig.add_table(
header=dict(values=header_values),
cells=dict(
values=cell_values,
align="left",
height=25,
),
columnwidth=columnwidth,
**kwargs,
)
fig.update_layout(
height=height,
width=width,
template="openbb_tables",
margin=dict(l=0, r=0, b=0, t=0, pad=0),
font=dict(size=14),
)
return fig
def _adjust_margins(self) -> None:
"""Adjust the margins of the figure."""
if self._margin_adjusted:
return
margin_add = (
dict(l=80, r=60, b=85, t=40, pad=0)
if not self._has_secondary_y
else dict(l=60, r=50, b=85, t=40, pad=0)
)
# We adjust margins
if plots_backend().isatty:
for key in ["l", "r", "b", "t", "pad"]:
if key in self.layout.margin and self.layout.margin[key] is not None:
self.layout.margin[key] += margin_add.get(key, 0)
else:
self.layout.margin[key] = margin_add.get(key, 0)
if not plots_backend().isatty:
org_margin = self.layout.margin
margin = dict(l=40, r=60, b=80, t=50, pad=0)
for key, max_val in zip(["l", "r", "b", "t"], [60, 50, 80, 40]):
org = org_margin[key] or 0
if (org + margin[key]) > max_val:
self.layout.margin[key] = max_val
else:
self.layout.margin[key] = org + margin[key]
self._margin_adjusted = True
def _set_watermark(self) -> None:
"""Set the watermark for OpenBB Terminal."""
if (
not plots_backend().isatty
or not get_current_user().preferences.PLOT_ENABLE_PYWRY
or self._export_image
):
self.add_annotation(
yref="paper",
xref="paper",
x=1,
y=0,
text="OpenBB Terminal",
font_size=17,
font_color="gray",
opacity=0.5,
xanchor="right",
yanchor="bottom",
yshift=-80,
xshift=40,
)
# pylint: disable=import-outside-toplevel
def _add_cmd_source(self) -> None:
"""Set the watermark for OpenBB Terminal."""
from openbb_terminal.helper_funcs import command_location
if command_location:
yaxis = self.layout.yaxis
yaxis2 = self.layout.yaxis2 if hasattr(self.layout, "yaxis2") else None
xshift = -70 if yaxis.side == "right" else -80
if self.layout.margin["l"] > 100:
xshift -= 50 if self._added_logscale else 40
if (
yaxis2
and (yaxis.title.text and yaxis2.title.text)
and (yaxis.side == "left" or yaxis2.side == "left")
):
self.layout.margin["l"] += 20
if (yaxis2 and yaxis2.side == "left") or yaxis.side == "left":
title = yaxis.title.text if not yaxis2 else yaxis2.title.text
xshift = -110 if not title else -135
self.layout.margin["l"] += 60
self.add_annotation(
x=0,
y=0.5,
yref="paper",
xref="paper",
text=command_location,
textangle=-90,
font_size=24,
font_color="gray" if theme.mapbox_style == "dark" else "black",
opacity=0.5,
yanchor="middle",
xanchor="left",
xshift=xshift + self.cmd_xshift,
)
# pylint: disable=import-outside-toplevel
def _apply_feature_flags(self) -> None:
"""Apply watermark and command source annotations."""
if self._feature_flags_applied:
return
if get_current_user().preferences.USE_CMD_LOCATION_FIGURE:
self._add_cmd_source()
if get_current_user().preferences.USE_WATERMARK:
self._set_watermark()
self._feature_flags_applied = True
def add_logscale_menus(self, yaxis: str = "yaxis") -> None:
"""Set the menus for the figure."""
self._added_logscale = True
bg_color = "#000000" if theme.mapbox_style == "dark" else "#FFFFFF" # type: ignore
font_color = "#FFFFFF" if theme.mapbox_style == "dark" else "#000000" # type: ignore
self.update_layout(
xaxis=dict(
rangeslider=dict(visible=False),
rangeselector=dict(
bgcolor=bg_color,
font=dict(color=font_color),
buttons=list(
[
dict(
count=1,
label="1M",
step="month",
stepmode="backward",
),
dict(
count=3,
label="3M",
step="month",
stepmode="backward",
),
dict(count=1, label="YTD", step="year", stepmode="todate"),
dict(
count=1,
label="1y",
step="year",
stepmode="backward",
),
dict(step="all"),
]
),
),
),
bargap=0,
bargroupgap=0,
)
self.update_layout(
updatemenus=[
dict(
bgcolor=bg_color,
font=dict(color=font_color, size=14),
buttons=[
dict(
label="linear ",
method="relayout",
args=[{f"{yaxis}.type": "linear"}],
),
dict(
label="log",
method="relayout",
args=[{f"{yaxis}.type": "log"}],
),
],
y=1.07,
x=-0.01,
)
],
)
def add_corr_plot(
self,
series: pd.DataFrame,
max_lag: int = 20,
m: Optional[int] = None,
alpha: Optional[float] = 0.05,
marker: Optional[dict] = None,
row: Optional[int] = None,
col: Optional[int] = None,
pacf: bool = False,
**kwargs,
) -> None:
"""Add a correlation plot to a figure object.
Parameters
----------
fig : OpenBBFigure
Figure object to add plot to
series : pd.DataFrame
Dataframe to look at
max_lag : int, optional
Number of lags to look at, by default 15
m: Optional[int]
Optionally, a time lag to highlight on the plot. Default is none.
alpha: Optional[float]
Optionally, a significance level to highlight on the plot. Default is 0.05.
row : int, optional
Row to add plot to, by default None
col : int, optional
Column to add plot to, by default None
pacf : bool, optional
Flag to indicate whether to use partial autocorrelation or not, by default False
"""
mode = "markers+lines" if marker else "lines"
line = kwargs.pop("line", None)
def _prepare_data_corr_plot(x, lags):
zero = True
irregular = False
if lags is None:
# GH 4663 - use a sensible default value
nobs = x.shape[0]
lim = min(int(np.ceil(10 * np.log10(nobs))), nobs - 1)
lags = np.arange(not zero, lim + 1)
elif np.isscalar(lags):
lags = np.arange(not zero, int(lags) + 1) # +1 for zero lag
else:
irregular = True
lags = np.asanyarray(lags).astype(int)
nlags = lags.max(0)
return lags, nlags, irregular
lags, nlags, irregular = _prepare_data_corr_plot(series, max_lag)
callback = sm.tsa.stattools.pacf if pacf else sm.tsa.stattools.acf
if not pacf:
kwargs.update(dict(fft=False))
acf_x = callback(
series,
nlags=nlags,
alpha=alpha,
**kwargs,
)
acf_x, confint = acf_x[:2] if not pacf else acf_x
if irregular:
acf_x = acf_x[lags]
try:
confint = confint[lags]
if lags[0] == 0:
lags = lags[1:]
confint = confint[1:]
acf_x = acf_x[1:]
lags = lags.astype(float)
lags[0] -= 0.5
lags[-1] += 0.5
upp_band = confint[:, 0] - acf_x
low_band = confint[:, 1] - acf_x
# pylint: disable=C0200
for x in range(len(acf_x)):
self.add_scatter(
x=(x, x),
y=(0, acf_x[x]),
mode=mode,
marker=marker,
line=line,
line_width=(2 if m is not None and x == m else 1),
row=row,
col=col,
)
self.add_scatter(
x=lags,
y=upp_band,
mode="lines",
line_color="rgba(0, 0, 0, 0)",
opacity=0,
row=row,
col=col,
)
self.add_scatter(
x=lags,
y=low_band,
mode="lines",
fillcolor="rgba(255, 217, 0, 0.30)",
fill="tonexty",
line_color="rgba(0, 0, 0, 0.0)",
opacity=0,
row=row,
col=col,
)
self.add_scatter(
x=[0, max_lag + 1],
y=[0, 0],
mode="lines",
line_color="white",
row=row,
col=col,
)
self.update_traces(showlegend=False)
except ValueError:
pass