Files
OpenBB/openbb_terminal/core/plots/plotly_helper.py
montezdesousa 475ebb5acd [WIP] Migrate user, feature flags and configurations to dataclass (#4309)
* implement user and change is guest

* replace User methods

* move remaining functions to user

* fix bug

* portfolio test

* fix tests

* fix tests

* uncomment skip

* ruff

* start credentials model implementation

* change deault structure

* refacto quandl

* change default struct and file names

* bug

* start using global user

* add comments

* move update flair

* change cfg references in keys model

* reddit & fmp

* refactore additional keys

* remaing config keys

* remove unsude imports and fix hub keys

* ruff

* fix line too long

* unblock unittests

* fix keys model tests

* remove os patch

* fix account controller tests

* fix account controller tests

* fix account model

* account fixed

* fix user tests

* User Model

* User Model

* User Model

* Move session folder

* User Model : grouping the models

* User Model : fix bugs

* Config Plot + bug fix

* User Model : Refactoring plots

* Feature flags

* Remove dup

* clean comments a fix some vars

* fix feature flags controller

* refactor obbff from settings controller

* refactor a bunch of obbff variables

* fix bug blocking terminal

* fix integration tests

* refactor controllers with obbff

* refactor remaining ffs

* remove comments

* log not started

* mispelled variable

* set current user on local model

* User Model : moving files + updating

* User Preferences

* User : Refactor is_guest function + bug fix

* User : refactor is_guest function

* Fix conftest.py

* remove lost obbffs

* typo on fileoverwrite

* Plot config: move variables and start plot dpi

* User : tests

* refactor plot dpi references

* remove plot dpi imports

* refactor some config terminal leftover

* /account/sync : fix bug

* remove configs reload

* start path refactor

* installation paths vs user paths

* remove cfg_plot

* Tests : User Refactoring

* user prefix only for preferences

* user prefix only for preferences

* move user paths

* remove comment

* update apply remote configs

* fix tz not updating

* save some copys

* small fix

* create interface for credentials and preferences

* wrong docstrings fixed

* ruff core

* fix some pref setting

* login is better

* ruff

* ruff

* remove Literal

* add small comment

* Tests : User Refactoring

* Credentials Model : add DataBento

* Fix helper_funcs

* Fixing code

* Tests : fixing

* User Model : freezing the models

* Fixed Income + Databento

* Tests : fixing keys

* black

* ruff

* Tests : user model

* Tests : user model

* Fix linting

* Fix linting

* Linting

* Linting

* Fixing code and tests

* Fix tests

* mypy

* SDK

* Clean ruff cache

* Fix syntax issue

* Update fred_view

* Black

* Linting

* Update code

* Update tests

* Linting

* Linting

* Tests

* Black

* Linting

* Linting

* Mock os.listdir to avoid looking for file that doesnt exist

* Mock options paths

* lint

* Tests : fix

* Code : update

* Tests + Linting

* Tests

* Ruff

* Code : update

* Tests

* Code : update

* Fix workflow

* Code : update

---------

Co-authored-by: Chavithra PARANA <chavithra@gmail.com>
Co-authored-by: luqazino@gmail.com <luqazino@gmail.com>
Co-authored-by: James Maslek <jmaslek11@gmail.com>
2023-03-05 00:37:19 +01:00

1694 lines
56 KiB
Python

"""Chart and style helpers for Plotly."""
# pylint: disable=C0302,R0902
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 MISCELLANEOUS_DIRECTORY
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.
"""
DEFAULT_STYLES_LOCATION = MISCELLANEOUS_DIRECTORY / "styles" / "default"
USER_STYLES_LOCATION = (
get_current_user().preferences.USER_DATA_DIRECTORY / "styles" / "user"
)
plt_styles_available: Dict[str, Path] = {}
plt_style: str = "dark"
plotly_template: Dict[str, Any] = {}
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
console_style = console_style or "dark"
self.load_available_styles()
console_json_path = self.console_styles_available.get("dark", None)
for style in ["openbb_config", console_style]:
if style in self.console_styles_available:
console_json_path = self.console_styles_available[style]
break
if console_json_path:
self.console_style = self.load_json_style(console_json_path)
self.load_style(plt_style)
def apply_style(self, style: Optional[str] = "") -> None:
"""Apply the style to the libraries."""
style = style or self.plt_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:
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"
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.glob(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.DEFAULT_STYLES_LOCATION)
self.load_available_styles_from_folder(self.USER_STYLES_LOCATION)
def load_json_style(self, file: Path) -> Dict[str, Any]:
"""Load style from json file.
Parameters
----------
file : str
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 in self.plt_styles_available:
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"]
"""
colors = self.plotly_template.get("layout", {}).get("colorway", PLT_COLORWAY)
if reverse:
colors.reverse()
return colors
theme = TerminalStyle("dark", "dark")
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._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.0001
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 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)
"""
fig = make_subplots(
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,
)
kwargs = {"multi_rows": rows > 1}
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) -> None:
"""Add a trend line to the figure.
Parameters
----------
data : `pd.DataFrame`
Data to plot
name : `str`
Name of the plot
"""
try:
if "OC_High_trend" in data.columns:
high_trend = data.loc[
data["OC_High_trend"]
.idxmin() : data["OC_High_trend"] # noqa: E203
.idxmax()
]
self.add_shape(
type="line",
name="High Trend",
x0=high_trend.index[0],
y0=high_trend["OC_High_trend"].iloc[0],
x1=high_trend.index[-1],
y1=high_trend["OC_High_trend"].iloc[-1],
line=dict(color=theme.up_color, width=2),
row=1,
col=1,
)
if "OC_Low_trend" in data.columns:
low_trend = data.loc[
data["OC_Low_trend"]
.idxmin() : data["OC_Low_trend"] # noqa: E203
.idxmax()
]
self.add_shape(
type="line",
name="Low Trend",
x0=low_trend.index[0],
y0=low_trend["OC_Low_trend"].iloc[0],
x1=low_trend.index[-1],
y1=low_trend["OC_Low_trend"].iloc[-1],
line=dict(color=theme.down_color, width=2),
row=1,
col=1,
)
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,
)
)
def add_stock_volume(
self,
df_stock: pd.DataFrame,
close_col: str = "Close",
volume_col: str = "Volume",
row: int = 2,
col: int = 1,
secondary_y: bool = False,
) -> None:
"""Add the volume of a stock to the figure.
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
secondary_y : `bool`, optional
Whether to use the secondary y axis, by default False
"""
colors = [
theme.down_color if row.Open < row[close_col] else theme.up_color
for _, row in df_stock.iterrows()
]
self.add_bar(
x=df_stock.index,
y=df_stock[volume_col],
name="Volume",
marker_color=colors,
row=row,
col=col,
secondary_y=secondary_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)
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()
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
if self.layout.template.layout.mapbox.style == "dark": # type: ignore
self.update_layout( # type: ignore
newshape_line_color="gold",
modebar=dict(
orientation="v",
bgcolor="#2A2A2A",
color="#FFFFFF",
activecolor="#d1030d",
),
)
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()
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}"
self._date_xaxs[trace.xaxis] = {
"yaxis": trace.yaxis,
"name": name,
}
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]] = []
# 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"))
if not self._has_secondary_y:
self.update_xaxes(rangebreaks=rangebreaks, row=row, col=col)
else:
for entry in self._date_xaxs.values():
self.update_xaxes(
rangebreaks=rangebreaks,
type="date",
selector=dict(anchor=entry["yaxis"]),
)
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,
)
)
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,
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 = (
[80, 60, 85, 60, 0] if not self._has_secondary_y else [80, 50, 85, 40, 0]
)
# We adjust margins
if plots_backend().isatty:
for key, add in zip(
["l", "r", "b", "t", "pad"],
margin_add,
):
if key in self.layout.margin and self.layout.margin[key] is not None:
self.layout.margin[key] += add
else:
self.layout.margin[key] = add
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."""
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 -150
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",
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) -> None:
"""Set the menus for the figure."""
self._added_logscale = True
self.update_layout(
xaxis=dict(
rangeslider=dict(visible=False),
rangeselector=dict(
bgcolor="#000000",
bordercolor="gold",
font=dict(color="white"),
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="#0f0f0f",
font=dict(color="white", size=14),
buttons=[
dict(
label="linear ",
method="relayout",
args=[{"yaxis.type": "linear"}],
),
dict(
label="log",
method="relayout",
args=[{"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