Feature/charting obbject (#5938)

* fix circular import issues on the obbject extension framework

* settings available on the obbject

* extension init

* charting assets and code

* introduce to_chart functionality

* removes the to_chart method from obbject in detriment of the one from the obbject extension

* Update openbb_platform/obbject_extensions/charting/charting/__init__.py

Co-authored-by: montezdesousa <79287829+montezdesousa@users.noreply.github.com>

* fix on the show method

* adding the example to the to_chart docstring

* copying the exsting README from charting extension - needs to be revamped

* moving to openbb_charting instead

* update the pyproject.toml file

* pydocstyle

* versions

* better docstrings

* making accessors public

* removing unnecessary credentials variable cc @montezdesousa

* removing access to extra/metadata and use designated private attrs instead

* Fixes linting for the logging service (#5992)

* lints

* typing.Type instead

* deprecating ChartingService to use the new extension instead - adding function discovery capabilities using it

* tests for the obbject charting extension

* removing charting service from command runner

* filter functions not implemented in the module

* remove charting service tests

* removing charting service on the tests for int tests

* charting integration tests

* removing charting service file

* removing old charting extension code

* revamp builder

* fix obbject tests

* fix command_runner tests

* removing openbb_figure_table

* better website docs

* reviewed readme

* unsetting api key for posthog (pywry) windows

* added debug_mode to the system settings

* removing api key

* update nightly script

* fix tests

* black

* black

* Revert "black"

This reverts commit 21d76ad689.

* Revert "black"

This reverts commit 0904653fd7.

* black for henrique

* returning a list instead

* better docstrings

* removing unnecessary indicators docstring

* typo

* black

* intentional access to protected member

* changes to publish script

* filtering deleted files

* black

* adding render argument

* fixing the df_to_basemodel function: it should include the index in the columns either if flagged or if the index is named

* adding the hability to add you own data to to_chart method

* tests

* black

* disable protected access

* accept kwargs to eg. save image

* adjustment cc Igor

* add if statement for protection

* remove unused return typing

* fix technical charting views

* better error message cc @IgorWounds

* better structure and fix test

* Revert "better structure and fix test"

This reverts commit 612ea6117c.

* Revert "better error message cc @IgorWounds"

This reverts commit 3d7ee53878.

---------

Co-authored-by: montezdesousa <79287829+montezdesousa@users.noreply.github.com>
Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Co-authored-by: Igor Radovanovic <74266147+IgorWounds@users.noreply.github.com>
Co-authored-by: James Maslek <jmaslek11@gmail.com>
This commit is contained in:
Henrique Joaquim
2024-02-14 15:19:27 +00:00
committed by GitHub
parent cb192cbdd0
commit 2f01e954d1
72 changed files with 2140 additions and 3315 deletions

View File

@@ -1,3 +1,5 @@
"""Commands: generates the command map."""
import inspect
from functools import partial, wraps
from inspect import Parameter, Signature, signature
@@ -5,7 +7,6 @@ from typing import Any, Callable, Dict, Optional, Tuple, TypeVar
from fastapi import APIRouter, Depends, Header
from fastapi.routing import APIRoute
from openbb_core.app.charting_service import ChartingService
from openbb_core.app.command_runner import CommandRunner
from openbb_core.app.model.command_context import CommandContext
from openbb_core.app.model.obbject import OBBject
@@ -18,6 +19,13 @@ from openbb_core.env import Env
from pydantic import BaseModel
from typing_extensions import Annotated, ParamSpec
try:
from openbb_charting import Charting
CHARTING_INSTALLED = True
except ImportError:
CHARTING_INSTALLED = False
T = TypeVar("T")
P = ParamSpec("P")
@@ -57,10 +65,7 @@ def build_new_signature(path: str, func: Callable) -> Signature:
)
)
if (
path.replace("/", "_")[1:]
in ChartingService.get_implemented_charting_functions()
):
if CHARTING_INSTALLED and path.replace("/", "_")[1:] in Charting.functions():
new_parameter_list.append(
Parameter(
"chart",
@@ -104,6 +109,7 @@ def build_new_signature(path: str, func: Callable) -> Signature:
def validate_output(c_out: OBBject) -> OBBject:
"""
Validate OBBject object.
Checks against the OBBject schema and removes fields that contain the
`exclude_from_api` extra `pydantic.Field` kwarg.
Note that the modification to the `OBBject` object is done in-place.

View File

@@ -1,287 +0,0 @@
"""Charting service."""
from importlib import import_module
from inspect import getmembers, getsource, isfunction
from typing import Callable, List, Optional, Tuple, TypeVar
from openbb_core.app.extension_loader import ExtensionLoader
from openbb_core.app.model.abstract.singleton import SingletonMeta
from openbb_core.app.model.charts.chart import Chart, ChartFormat
from openbb_core.app.model.charts.charting_settings import ChartingSettings
from openbb_core.app.model.system_settings import SystemSettings
from openbb_core.app.model.user_settings import UserSettings
from openbb_core.env import Env
T = TypeVar("T")
# this is needed because static assets and api endpoints are built before any user is instantiated
EXTENSION_NAME = Env().CHARTING_EXTENSION
class ChartingServiceError(Exception):
"""Charting service error."""
class ChartingService(metaclass=SingletonMeta):
"""
Charting Service.
It is responsible for retrieving and executing the charting function, corresponding
to a given route, from the user's preferred charting extension.
Parameters
----------
user_settings : UserSettings
User settings.
system_settings : SystemSettings
System settings.
Attributes
----------
_charting_extension : str
Charting extension name, which is retrieved from the user preferences.
_charting_extension_installed : bool
Either charting extension is installed or not.
Raises
------
ChartingServiceError
If charting extension is not installed.
"""
def __init__(
self,
user_settings: Optional[UserSettings] = None,
system_settings: Optional[SystemSettings] = None,
) -> None:
"""Initializes ChartingService."""
# Although the __init__ method states that both the user_settings and the system_settings
# are optional, they are actually required for the first initialization of the ChartingService.
# This is because the ChartingService is a singleton and it is initialized only once.
# We want to be able to call the ChartingService from other parts of the code without having
# to pass the user_settings and the system_settings every time.
if not user_settings or not system_settings:
raise ChartingServiceError(
"User settings and system settings must be provided."
)
self._charting_settings = ChartingSettings(user_settings, system_settings)
self._charting_extension = self._check_and_get_charting_extension_name(
user_settings.preferences.charting_extension
)
self._charting_extension_installed = self._check_charting_extension_installed(
self._charting_extension
)
@property
def charting_settings(self) -> ChartingSettings:
"""Gets charting settings."""
return self._charting_settings
@charting_settings.setter
def charting_settings(self, value: Tuple[SystemSettings, UserSettings]):
system_settings, user_settings = value
self._charting_settings = ChartingSettings(
user_settings=user_settings,
system_settings=system_settings,
)
@staticmethod
def _check_and_get_charting_extension_name(
user_preferences_charting_extension: str,
):
"""Checks if the charting extension defined on user preferences is the same as the one defined in the env file."""
if user_preferences_charting_extension != EXTENSION_NAME:
raise ChartingServiceError(
f"The charting extension defined on user preferences must be the same as the one defined in the env file."
f"diff: {user_preferences_charting_extension} != {EXTENSION_NAME}"
)
return user_preferences_charting_extension
@staticmethod
def _check_charting_extension_installed(ext_name: str) -> bool:
"""Checks if a given extension is installed."""
extension = ExtensionLoader().get_core_entry_point(ext_name) or False
return extension and ext_name == extension.name # type: ignore
@staticmethod
def _handle_backend(charting_extension: str, charting_settings: ChartingSettings):
"""
Handles the backend of the given charting extension.
This function that the module expose in its root (__init__.py) the following functions:
- `create_backend(charting_settings: ChartingSettings)`
- `get_backend()`
Parameters
----------
charting_extension : str
Charting extension name.
charting_settings : ChartingSettings
Charting settings.
"""
# Dynamically import the backend module
backend_module = import_module(charting_extension)
create_backend_func = getattr(backend_module, "create_backend")
get_backend_func = getattr(backend_module, "get_backend")
create_backend_func(charting_settings=charting_settings)
get_backend_func().start(debug=charting_settings.debug_mode)
@classmethod
def _get_extension_router(cls, extension_name: str):
"""Get the module of the given extension."""
extension = ExtensionLoader().get_core_entry_point(extension_name)
if not extension or extension_name != extension.name:
raise ChartingServiceError(
f"Extension '{extension_name}' is not installed."
)
return import_module(extension.module)
@classmethod
def _get_chart_format(cls, extension_name: str) -> ChartFormat:
"""
Given an extension name, it returns the chart format.
The module must contain the `CHART_FORMAT` attribute.
"""
module = cls._get_extension_router(extension_name)
return getattr(module, "CHART_FORMAT")
@classmethod
def _get_chart_function(cls, extension_name: str, route: str) -> Callable:
"""
Given an extension name and a route, it returns the chart function.
The module must contain the given route.
"""
adjusted_route = route.replace("/", "_")[1:]
module = cls._get_extension_router(extension_name)
return getattr(module, adjusted_route)
@classmethod
def get_implemented_charting_functions(
cls, extension_name: str = EXTENSION_NAME
) -> List[str]:
"""Given an extension name, it returns the implemented charting functions from its router."""
implemented_functions = []
try:
if module := cls._get_extension_router(extension_name):
module_name = module.__name__
for name, obj in getmembers(module, isfunction):
if (
obj.__module__ == module_name
and not name.startswith("_")
and "NotImplementedError" not in getsource(obj)
):
implemented_functions.append(name)
except ChartingServiceError:
pass
return implemented_functions
def to_chart(self, **kwargs) -> Chart:
"""
Returns the chart object.
Parameters
----------
**kwargs
Keyword arguments to be passed to the charting extension.
Returns
-------
Chart
Chart object.
Raises
------
ChartingServiceError
If charting extension is not installed.
Exception
If the charting extension module does not contain the `to_chart` function.
"""
if not self._charting_extension_installed:
raise ChartingServiceError(
f"Charting extension `{self._charting_extension}` is not installed"
)
self._handle_backend(self._charting_extension, self._charting_settings)
# Dynamically import the charting module
backend_module = import_module(self._charting_extension)
# Get the `to_chart` function from the charting module
to_chart_func = getattr(backend_module, "to_chart")
# Add the charting settings to the kwargs
kwargs["charting_settings"] = self._charting_settings
fig, content = to_chart_func(**kwargs)
return Chart(
content=content,
format=self._get_chart_format(self._charting_extension),
fig=fig,
)
def chart(
self,
user_settings: UserSettings,
system_settings: SystemSettings,
route: str,
obbject_item: T,
**kwargs,
) -> Chart:
"""
Given a route and an obbject item, it returns the chart object.
If the charting extension is not installed, an error is raised.
Otherwise, a charting function will be retrieved and executed from the user's preferred charting extension.
This function assumes that, in order to successfully retrieve the charting function,
the charting extension uses the following naming convention to convert routes into charting functions:
- Route: `/stocks/load`
- Charting function: `stocks_load()`
- Route: `/ta/ema`
- Charting function: `ta_ema()`
Note that the route should be in its original format, since it will be converted inside this function.
Parameters
----------
user_settings : UserSettings
User settings.
route : str
Route name, example: `/stocks/load`.
obbject_item
Command output item.
Returns
-------
Chart
Chart object.
"""
self._charting_settings = ChartingSettings(user_settings, system_settings)
self._charting_extension = user_settings.preferences.charting_extension
self._charting_extension_installed = self._check_charting_extension_installed(
self._charting_extension
)
if not self._charting_extension_installed:
raise ChartingServiceError(
f"Charting extension `{self._charting_extension}` is not installed"
)
self._handle_backend(self._charting_extension, self._charting_settings)
kwargs["obbject_item"] = obbject_item
kwargs["charting_settings"] = self._charting_settings
charting_function = self._get_chart_function(self._charting_extension, route)
fig, content = charting_function(**kwargs)
return Chart(
content=content,
format=self._get_chart_format(self._charting_extension),
fig=fig,
)

View File

@@ -10,7 +10,6 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
from pydantic import ConfigDict, create_model
from openbb_core.app.charting_service import ChartingService
from openbb_core.app.logs.logging_service import LoggingService
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.app.model.abstract.warning import cast_warning
@@ -273,22 +272,14 @@ class StaticCommandRunner:
def _chart(
cls,
obbject: OBBject,
user_settings: UserSettings,
system_settings: SystemSettings,
route: str,
**kwargs,
) -> None:
"""Create a chart from the command output."""
cs = ChartingService(
user_settings=user_settings, system_settings=system_settings
)
obbject.chart = cs.chart(
user_settings=user_settings,
system_settings=system_settings,
route=route,
obbject_item=obbject.results,
**kwargs,
)
if "charting" not in obbject.accessors:
raise OpenBBError(
"Charting is not installed. Please install `openbb-charting`."
)
obbject.charting.show(render=False, **kwargs) # type: ignore
@classmethod
async def _execute_func(
@@ -339,13 +330,13 @@ class StaticCommandRunner:
kwargs=kwargs,
show_warnings=user_settings.preferences.show_warnings,
)
# pylint: disable=protected-access
obbject._route = route
obbject._standard_params = kwargs.get("standard_params", None)
if chart and obbject.results:
cls._chart(
obbject=obbject,
user_settings=user_settings,
system_settings=system_settings,
route=route,
**kwargs,
)
@@ -424,10 +415,6 @@ class CommandRunner:
self._system_settings = system_settings or SystemService().system_settings
self._user_settings = user_settings or UserService.read_default_user_settings()
self._charting_service = ChartingService(
system_settings=self._system_settings, user_settings=self._user_settings
)
def init_logging_service(self) -> None:
"""Initialize the logging service."""
_ = LoggingService(

View File

@@ -1,8 +1,10 @@
"""Logging Service Module."""
import json
import logging
from enum import Enum
from types import TracebackType
from typing import Any, Callable, Dict, Optional, Tuple, Type, cast
from typing import Any, Callable, Dict, Optional, Tuple, Type, Union, cast
from openbb_core.app.logs.formatters.formatter_with_exceptions import (
FormatterWithExceptions,
@@ -58,7 +60,8 @@ class LoggingService(metaclass=SingletonMeta):
user_settings: UserSettings,
) -> None:
"""
Logging Manager Constructor
Logging Service Constructor.
Sets up the logging settings and handlers and then logs the startup information.
Parameters
@@ -140,9 +143,7 @@ class LoggingService(metaclass=SingletonMeta):
route: Optional[str] = None,
custom_headers: Optional[Dict[str, Any]] = None,
) -> None:
"""
Log startup information.
"""
"""Log startup information."""
def check_credentials_defined(credentials: Dict[str, Any]):
class CredentialsDefinition(Enum):
@@ -184,9 +185,10 @@ class LoggingService(metaclass=SingletonMeta):
route: str,
func: Callable,
kwargs: Dict[str, Any],
exec_info: Optional[
Tuple[Type[BaseException], BaseException, Optional[TracebackType]]
] = None,
exec_info: Union[
Tuple[Type[BaseException], BaseException, TracebackType],
Tuple[None, None, None],
],
custom_headers: Optional[Dict[str, Any]] = None,
):
"""

View File

@@ -1,3 +1,5 @@
"""Charting settings."""
from pathlib import Path
from typing import Optional
@@ -9,11 +11,14 @@ from openbb_core.env import Env
# pylint: disable=too-many-instance-attributes
class ChartingSettings:
"""Charting settings."""
def __init__(
self,
user_settings: Optional[UserSettings] = None,
system_settings: Optional[SystemSettings] = None,
):
"""Initialize charting settings."""
user_settings = user_settings or UserSettings()
system_settings = system_settings or SystemSettings()
@@ -29,7 +34,7 @@ class ChartingSettings:
self.python_version: str = system_settings.python_version
self.test_mode = system_settings.test_mode
self.app_id: str = get_app_id(user_data_directory)
self.debug_mode: bool = Env().DEBUG_MODE
self.debug_mode: bool = system_settings.debug_mode or Env().DEBUG_MODE
self.headless: bool = system_settings.headless
# User
self.plot_enable_pywry: bool = user_settings.preferences.plot_enable_pywry

View File

@@ -52,7 +52,7 @@ class Extension:
)
setattr(cls, name, CachedAccessor(name, accessor))
# pylint: disable=protected-access
cls._accessors.add(name)
cls.accessors.add(name)
return accessor
return decorator

View File

@@ -4,6 +4,7 @@ from re import sub
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Dict,
Generic,
@@ -17,17 +18,18 @@ from typing import (
import pandas as pd
from numpy import ndarray
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, PrivateAttr
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.app.model.abstract.tagged import Tagged
from openbb_core.app.model.abstract.warning import Warning_
from openbb_core.app.model.charts.chart import Chart
from openbb_core.app.query import Query
from openbb_core.app.utils import basemodel_to_df
from openbb_core.provider.abstract.data import Data
if TYPE_CHECKING:
from openbb_core.app.query import Query
try:
from polars import DataFrame as PolarsDataFrame # type: ignore
except ImportError:
@@ -39,6 +41,10 @@ T = TypeVar("T")
class OBBject(Tagged, Generic[T]):
"""OpenBB object."""
accessors: ClassVar[Set[str]] = set()
_user_settings: ClassVar[Optional[BaseModel]] = None
_system_settings: ClassVar[Optional[BaseModel]] = None
results: Optional[T] = Field(
default=None,
description="Serializable results.",
@@ -59,8 +65,12 @@ class OBBject(Tagged, Generic[T]):
default_factory=dict,
description="Extra info.",
)
_credentials: ClassVar[Optional[BaseModel]] = None
_accessors: ClassVar[Set[str]] = set()
_route: str = PrivateAttr(
default=None,
)
_standard_params: Optional[Dict[str, Any]] = PrivateAttr(
default_factory=dict,
)
def __repr__(self) -> str:
"""Human readable representation of the object."""
@@ -268,48 +278,16 @@ class OBBject(Tagged, Generic[T]):
del results["index"]
return results
def to_chart(self, **kwargs):
"""
Create or update the `Chart`.
This function assumes that the provided data is a time series, if it's not, it will
most likely result in an Exception.
Note that the `chart` attribute is composed by: `content`, `format` and `fig`.
Parameters
----------
**kwargs
Keyword arguments to be passed to the charting extension.
This implies that the user has some knowledge on the charting extension API.
This is the case because the charting extension may vary on user preferences.
Returns
-------
chart.fig
The chart figure.
"""
# pylint: disable=import-outside-toplevel
# Avoids circular import
from openbb_core.app.charting_service import ChartingService
cs = ChartingService()
kwargs["data"] = self.to_dataframe()
self.chart = cs.to_chart(**kwargs)
return self.chart.fig
def show(self):
def show(self, **kwargs: Any) -> None:
"""Display chart."""
# pylint: disable=no-member
if not self.chart or not self.chart.fig:
raise OpenBBError(
"Chart not found. "
"Please compute the chart first by using the `chart=True` argument."
)
self.chart.fig.show()
raise OpenBBError("Chart not found.")
show_function: Callable = getattr(self.chart.fig, "show")
show_function(**kwargs)
@classmethod
async def from_query(cls, query: Query) -> "OBBject":
async def from_query(cls, query: "Query") -> "OBBject":
"""Create OBBject from query.
Parameters

View File

@@ -48,6 +48,7 @@ class SystemSettings(Tagged):
api_settings: FastAPISettings = Field(default_factory=FastAPISettings)
# Others
debug_mode: bool = False
test_mode: bool = False
headless: bool = False
@@ -87,7 +88,7 @@ class SystemSettings(Tagged):
def validate_posthog_handler(cls, values: "SystemSettings") -> "SystemSettings":
"""If the user has enabled log collection, then we need to add the Posthog."""
if (
not any([values.test_mode, values.logging_suppress])
not any([values.test_mode, values.debug_mode, values.logging_suppress])
and values.log_collect
and "posthog" not in values.logging_handlers
):

View File

@@ -1,3 +1,5 @@
"""System service."""
import hashlib
import json
from pathlib import Path
@@ -18,11 +20,10 @@ class SystemService(metaclass=SingletonMeta):
"headless",
"logging_sub_app",
"api_settings",
"debug_mode",
}
PRO_VALIDATION_HASH = (
"300ac59fdcc8f899e0bc5c18cda8652220735da1a00e2af365efe9d8e5fe8306"
)
PRO_VALIDATION_HASH = "300ac59fdcc8f899e0bc5c18cda8652220735da1a00e2af365efe9d8e5fe8306" # pragma: allowlist secret
def __init__(
self,

View File

@@ -11,7 +11,8 @@ class Container:
def __init__(self, command_runner: CommandRunner) -> None:
self._command_runner = command_runner
OBBject._credentials = command_runner.user_settings.credentials
OBBject._user_settings = command_runner.user_settings
OBBject._system_settings = command_runner.system_settings
def _run(self, *args, **kwargs) -> Any:
"""Run a command in the container."""

View File

@@ -34,7 +34,6 @@ from pydantic_core import PydanticUndefined
from starlette.routing import BaseRoute
from typing_extensions import Annotated, _AnnotatedAlias
from openbb_core.app.charting_service import ChartingService
from openbb_core.app.extension_loader import ExtensionLoader, OpenBBGroups
from openbb_core.app.model.custom_parameter import OpenBBCustomParameter
from openbb_core.app.provider_interface import ProviderInterface
@@ -44,6 +43,13 @@ from openbb_core.app.static.utils.linters import Linters
from openbb_core.env import Env
from openbb_core.provider.abstract.data import Data
try:
from openbb_charting import Charting
CHARTING_INSTALLED = True
except ImportError:
CHARTING_INSTALLED = False
DataProcessingSupportedTypes = TypeVar(
"DataProcessingSupportedTypes",
list,
@@ -533,10 +539,7 @@ class MethodDefinition:
parameter_map.pop("cc", None)
# we need to add the chart parameter here bc of the docstring generation
if (
path.replace("/", "_")[1:]
in ChartingService.get_implemented_charting_functions()
):
if CHARTING_INSTALLED and path.replace("/", "_")[1:] in Charting.functions():
parameter_map["chart"] = Parameter(
name="chart",
kind=Parameter.POSITIONAL_OR_KEYWORD,
@@ -706,10 +709,7 @@ class MethodDefinition:
parameter_map.pop("cc", None)
code = ""
if (
path.replace("/", "_")[1:]
in ChartingService.get_implemented_charting_functions()
):
if CHARTING_INSTALLED and path.replace("/", "_")[1:] in Charting.functions():
parameter_map["chart"] = Parameter(
name="chart",
kind=Parameter.POSITIONAL_OR_KEYWORD,

View File

@@ -1,3 +1,5 @@
"""Utility functions for the OpenBB Core app."""
import ast
import json
from typing import Dict, Iterable, List, Optional, Union
@@ -43,8 +45,10 @@ def df_to_basemodel(
df: Union[pd.DataFrame, pd.Series], index: bool = False
) -> List[Data]:
"""Convert from a Pandas DataFrame to list of BaseModel."""
if index and not isinstance(df.index, pd.MultiIndex):
df = df.reset_index(drop=True)
is_multiindex = isinstance(df.index, pd.MultiIndex)
if not is_multiindex and (index or df.index.name):
df = df.reset_index()
if isinstance(df, pd.Series):
df = df.to_frame()
@@ -120,6 +124,7 @@ def get_target_column(df: pd.DataFrame, target: str) -> pd.Series:
def get_target_columns(df: pd.DataFrame, target_columns: List[str]) -> pd.DataFrame:
"""Get target columns from time series data."""
df_result = pd.DataFrame()
for target in target_columns:
df_result[target] = get_target_column(df, target).to_frame()
@@ -127,6 +132,7 @@ def get_target_columns(df: pd.DataFrame, target_columns: List[str]) -> pd.DataFr
def get_user_cache_directory() -> str:
"""Get user cache directory."""
file = SystemSettings().model_dump()["user_settings_path"]
with open(file) as settings_file:
contents = settings_file.read()

View File

@@ -1,6 +1,6 @@
"""Tests for the OBBject class."""
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
import pandas as pd
import pytest
@@ -321,45 +321,6 @@ def test_to_dict(results, expected_dict):
assert str(exc_info.value) == str(expected_dict)
@patch("openbb_core.app.model.obbject.OBBject.to_dataframe")
@patch("openbb_core.app.charting_service.ChartingService")
def test_to_chart_with_new_chart(
mock_charting_service,
mock_to_dataframe,
):
"""Test helper."""
def get_mock_dataframe():
data = {
"col1": [1, 2, 3],
"col2": ["a", "b", "c"],
"col3": [True, False, True],
}
return pd.DataFrame(data)
# Arrange
mock_instance = OBBject()
mock_charting_service_instance = mock_charting_service.return_value
mock_charting_service_instance.to_chart.return_value = Chart(
content={"content": "some_new_content"}, fig={"fig": "some_mock_fig"}
)
mock_to_dataframe.return_value = get_mock_dataframe()
# Act
result = mock_instance.to_chart()
# Assert
assert result == {"fig": "some_mock_fig"}
assert mock_instance.chart.content == {"content": "some_new_content"}
# Ensure self.to_dataframe() was called
mock_to_dataframe.assert_called_once()
# Ensure ChartingService was called with the correct parameters
mock_charting_service_instance.to_chart.assert_called_once()
def test_show_chart_exists():
"""Test helper."""
mock_instance = OBBject()
@@ -372,7 +333,7 @@ def test_show_chart_exists():
mock_instance.show()
# Assert
mock_instance.chart.fig.show.assert_called_once()
mock_instance.chart.fig.show.assert_called_once() # pylint: disable=no-member
def test_show_chart_no_chart():

View File

@@ -90,6 +90,7 @@ def test_create_openbb_directory_directory_exists_system_settings_missing(tmpdir
# Test case: test_mode is True, logging_suppress is True
(
{
"debug_mode": True,
"test_mode": True,
"logging_suppress": True,
"log_collect": True,
@@ -100,6 +101,7 @@ def test_create_openbb_directory_directory_exists_system_settings_missing(tmpdir
# Test case: test_mode is False, logging_suppress is True
(
{
"debug_mode": False,
"test_mode": False,
"logging_suppress": True,
"log_collect": True,
@@ -111,6 +113,7 @@ def test_create_openbb_directory_directory_exists_system_settings_missing(tmpdir
# and "posthog" handler is not present in logging_handlers
(
{
"debug_mode": False,
"test_mode": False,
"logging_suppress": False,
"log_collect": True,
@@ -122,6 +125,7 @@ def test_create_openbb_directory_directory_exists_system_settings_missing(tmpdir
# and "posthog" handler is already present in logging_handlers
(
{
"debug_mode": False,
"test_mode": False,
"logging_suppress": False,
"log_collect": True,

View File

@@ -1,384 +0,0 @@
from typing import Callable
from unittest.mock import patch
import pytest
from openbb_core.app.charting_service import (
Chart,
ChartingService,
ChartingServiceError,
)
from openbb_core.app.model.system_settings import SystemSettings
from openbb_core.app.model.user_settings import UserSettings
@pytest.fixture(autouse=True)
def setup_and_teardown():
# Code to run before each test function
yield # This is where the test function runs
# Code to run after each test function
ChartingService._instances = {}
# fixture for system_settings
@pytest.fixture()
def system_settings():
return SystemSettings()
# fixture for user_settings
@pytest.fixture()
def user_settings():
return UserSettings()
# fixture for charting_service
@pytest.fixture()
def charting_service(user_settings, system_settings):
return ChartingService(user_settings=user_settings, system_settings=system_settings)
def test_charting_service(user_settings, system_settings):
assert ChartingService(user_settings=user_settings, system_settings=system_settings)
def test_charting_service_singleton_prop(user_settings, system_settings):
cm_1 = ChartingService(user_settings=user_settings, system_settings=system_settings)
cm_2 = ChartingService(user_settings=user_settings, system_settings=system_settings)
assert cm_1 is cm_2
def test_charting_settings():
sys = SystemSettings(test_mode=True)
user = UserSettings(preferences={"plot_enable_pywry": False})
cm = ChartingService(user_settings=user, system_settings=sys)
assert cm.charting_settings.plot_enable_pywry is False
assert cm.charting_settings.test_mode is True
def test_charting_settings_setter(charting_service):
sys = SystemSettings(test_mode=True)
user = UserSettings(preferences={"plot_enable_pywry": False})
charting_service.charting_settings = (sys, user)
assert charting_service.charting_settings.plot_enable_pywry is False
assert charting_service.charting_settings.test_mode is True
@pytest.mark.parametrize(
"user_preferences_charting_extension, expected_result",
[
("openbb_charting", "openbb_charting"), # Matching extension names
("openbb_charting", ChartingServiceError), # Mismatching extension names
],
)
def test_check_and_get_charting_extension_name(
system_settings, user_preferences_charting_extension, expected_result
):
user = UserSettings(
preferences={"charting_extension": user_preferences_charting_extension}
)
cm = ChartingService(user_settings=user, system_settings=system_settings)
if expected_result == ChartingServiceError:
with pytest.raises(ChartingServiceError) as exc_info, patch(
"openbb_core.app.charting_service.EXTENSION_NAME", "other_extension"
):
cm._check_and_get_charting_extension_name(
user_preferences_charting_extension
)
assert str(exc_info.value) == (
"The charting extension defined on user preferences must be "
"the same as the one defined in the env file."
f"diff: {user_preferences_charting_extension} != openbb_charting"
)
else:
with patch(
"openbb_core.app.charting_service.EXTENSION_NAME", "openbb_charting"
):
result = cm._check_and_get_charting_extension_name(
user_preferences_charting_extension
)
assert result == expected_result
@patch("openbb_core.app.charting_service.ExtensionLoader")
@pytest.mark.parametrize(
"entry_point_name, extension_name, expected_result",
[
(
"mock_extension",
"mock_extension",
True,
), # Extension is installed and names match
(
"mock_extension",
"another_extension",
False,
), # Extension is installed but names don't match
(None, "mock_extension", False), # Extension is not installed
],
)
def test_check_charting_extension_installed(
mock_extension_loader,
charting_service,
entry_point_name,
extension_name,
expected_result,
):
class MockEntryPoint:
def __init__(self, name):
self.name = name
# Set the return value of get_core_entry_point
if entry_point_name is not None:
mock_extension_loader.return_value.get_core_entry_point.return_value = (
MockEntryPoint(entry_point_name)
)
else:
mock_extension_loader.return_value.get_core_entry_point.return_value = None
# Call the function and assert the result
result = charting_service._check_charting_extension_installed(extension_name)
assert result == expected_result
@pytest.mark.parametrize(
"extension_name, expected_result",
[
("mock_extension", "mock_module"),
("another_extension", ChartingServiceError),
],
)
@patch("openbb_core.app.charting_service.import_module")
@patch("openbb_core.app.charting_service.ExtensionLoader")
def test_get_extension_router(
mock_extension_loader,
mock_import_module,
charting_service,
extension_name,
expected_result,
):
class MockEntryPoint:
def __init__(self, name, module):
self.name = name
self.module = module
mock_extension_loader.return_value.get_core_entry_point.return_value = (
MockEntryPoint("mock_extension", "mock_module")
)
mock_import_module.return_value = "mock_module"
if expected_result == ChartingServiceError:
with pytest.raises(ChartingServiceError) as exc_info:
charting_service._get_extension_router(extension_name)
assert str(exc_info.value) == (
f"Extension '{extension_name}' is not installed."
)
else:
result = charting_service._get_extension_router("mock_extension")
assert result == "mock_module"
@patch("openbb_core.app.charting_service.import_module")
def test_handle_back_end(mock_import_module, charting_service):
class MockBackend:
def __init__(self):
pass
def start(self, debug):
pass
class MockBackendModule:
def __init__(self):
self._backend = None
def create_backend(self, charting_settings):
self._backend = MockBackend()
def get_backend(self):
return self._backend
mock_import_module.return_value = MockBackendModule()
assert (
charting_service._handle_backend(
"mock_extension", charting_service.charting_settings
)
is None
)
@patch("openbb_core.app.charting_service.ChartingService._get_extension_router")
def test_get_chart_format(mock_get_extension_router, charting_service):
class MockChartingRouterModule:
CHART_FORMAT = "mock_chart_format"
mock_get_extension_router.return_value = MockChartingRouterModule()
assert charting_service._get_chart_format("mock_extension") == "mock_chart_format"
@pytest.mark.parametrize(
"route, expected_result",
[
("/stocks/load", ...),
("/crypto/load", ...),
("/ta/ema", ...),
("wrong/route", AttributeError),
],
)
@patch("openbb_core.app.charting_service.ChartingService._get_extension_router")
def test_get_chart_function(
mock_get_extension_router, charting_service, route, expected_result
):
class MockChartingRouterModule:
def stocks_load(self):
pass
def crypto_load(self):
pass
def ta_ema(self):
pass
mock_get_extension_router.return_value = MockChartingRouterModule()
if expected_result != AttributeError:
assert isinstance(
charting_service._get_chart_function("mock_extension", route), Callable
)
else:
with pytest.raises(AttributeError) as exc_info:
charting_service._get_chart_function("mock_extension", route)
assert str(exc_info.value) == f"Attribute '{route}' not found."
@pytest.mark.parametrize("charting_router_module", [(...), (None)])
@patch("openbb_core.app.charting_service.ChartingService._get_extension_router")
def test_get_implemented_charting_functions(
mock_get_extension_router, charting_service, charting_router_module
):
if not charting_router_module:
mock_get_extension_router.return_value = None
assert charting_service.get_implemented_charting_functions() == []
else:
class MockChartingRouterModule:
__name__ = "MockChartingRouterModule"
def stocks_load(self):
pass
def crypto_load(self):
pass
def ta_ema(self):
raise NotImplementedError()
MockChartingRouterModule.stocks_load.__module__ = (
MockChartingRouterModule.__name__
)
MockChartingRouterModule.crypto_load.__module__ = (
MockChartingRouterModule.__name__
)
MockChartingRouterModule.ta_ema.__module__ = MockChartingRouterModule.__name__
mock_get_extension_router.return_value = MockChartingRouterModule
result = charting_service.get_implemented_charting_functions()
assert result == ["crypto_load", "stocks_load"]
@pytest.mark.parametrize(
"charting_extension_installed",
[(True), (False)],
)
@patch("openbb_core.app.charting_service.ChartingService._handle_backend")
@patch("openbb_core.app.charting_service.import_module")
@patch("openbb_core.app.charting_service.ChartingService._get_chart_format")
def test_to_chart(
mock_get_chart_format,
mock_import_module,
mock_handle_backend,
charting_service,
charting_extension_installed,
):
charting_service._charting_extension_installed = charting_extension_installed
class MockBackendModule:
CHART_FORMAT = "plotly"
@staticmethod
def to_chart(**kwargs):
return ("mock_fig", {"content": "mock_content"})
mock_import_module.return_value = MockBackendModule()
mock_get_chart_format.return_value = "plotly"
if charting_extension_installed:
result = charting_service.to_chart()
assert isinstance(result, Chart)
assert result.fig == "mock_fig"
assert result.content == {"content": "mock_content"}
assert mock_handle_backend.called_once()
else:
with pytest.raises(ChartingServiceError) as exc_info:
charting_service.to_chart()
assert str(exc_info.value) == (
"Charting extension `openbb_charting` is not installed"
)
@pytest.mark.parametrize(
"charting_extension_installed, expected_result",
[(True, ...), (False, ChartingServiceError)],
)
@patch("openbb_core.app.charting_service.ChartingService._handle_backend")
@patch(
"openbb_core.app.charting_service.ChartingService._check_charting_extension_installed"
)
@patch("openbb_core.app.charting_service.ChartingService._get_chart_format")
@patch("openbb_core.app.charting_service.ChartingService._get_chart_function")
def test_chart(
mock_get_chart_function,
mock_get_chart_format,
mock_check_charting_extension_installed,
mock_handle_backend,
user_settings,
system_settings,
charting_service,
charting_extension_installed,
expected_result,
):
def mock_chart_function(**kwargs):
return ("mock_fig", {"content": "mock_content"})
mock_check_charting_extension_installed.return_value = charting_extension_installed
mock_get_chart_function.return_value = mock_chart_function
mock_get_chart_format.return_value = "plotly"
if expected_result != ChartingServiceError:
result = charting_service.chart(
user_settings, system_settings, "/mock/route", "mock_obbject_item"
)
assert isinstance(result, Chart)
assert result.fig == "mock_fig"
assert result.content == {"content": "mock_content"}
assert mock_handle_backend.called_once()
else:
with pytest.raises(ChartingServiceError) as exc_info:
charting_service.chart(
user_settings, system_settings, "/mock/route", "mock_obbject_item"
)
assert str(exc_info.value) == (
"Charting extension `openbb_charting` is not installed"
)

View File

@@ -1,6 +1,6 @@
from inspect import Parameter
from typing import Dict, List
from unittest.mock import patch
from unittest.mock import Mock, patch
import pytest
from openbb_core.app.command_runner import (
@@ -361,30 +361,15 @@ async def test_static_command_runner_execute_func(
assert mock_chart.called_once()
@patch("openbb_core.app.command_runner.ChartingService.chart")
def test_static_command_runner_chart(mock_charting_service_chart, execution_context):
"""Test chart."""
def test_static_command_runner_chart():
"""Test _chart method when charting is in obbject.accessors."""
mock_obbject = Mock()
mock_obbject.accessors = ["charting"]
mock_obbject.charting.show = Mock()
class MockOBBject:
"""Mock OBBject"""
StaticCommandRunner._chart(mock_obbject) # pylint: disable=protected-access
def __init__(self, results):
self.results = results
self.chart = {}
mock_charting_service_chart.return_value = {"mock": "chart"}
mock_obbject = MockOBBject(results=[1, 2, 3, 4])
StaticCommandRunner._chart(
obbject=mock_obbject,
user_settings=execution_context.user_settings,
system_settings=execution_context.system_settings,
route="mock/route",
)
assert mock_charting_service_chart.called_once()
assert mock_obbject.results == [1, 2, 3, 4]
assert mock_obbject.chart == {"mock": "chart"}
mock_obbject.charting.show.assert_called_once()
@pytest.mark.asyncio

View File

@@ -54,7 +54,7 @@ openbb-seeking-alpha = { path = "./providers/seeking_alpha", optional = true, de
openbb-stockgrid = { path = "./providers/stockgrid" , optional = true, develop = true }
openbb-wsj = { path = "./providers/wsj", optional = true, develop = true }
openbb-charting = { path = "./extensions/charting", optional = true, develop = true }
openbb-charting = { path = "./obbject_extensions/charting", optional = true, develop = true }
openbb-econometrics = { path = "./extensions/econometrics", optional = true, develop = true }
openbb-quantitative = { path = "./extensions/quantitative", optional = true, develop = true }
openbb-technical = { path = "./extensions/technical", optional = true, develop = true }

View File

@@ -1,6 +0,0 @@
# flake8: noqa: F401
from openbb_charting.core.backend import (
create_backend,
get_backend,
)
from openbb_charting.core.to_chart import to_chart

View File

@@ -1,95 +0,0 @@
from typing import List, Optional, Union
import pandas as pd
from openbb_charting.core.openbb_figure import OpenBBFigure
class OpenBBFigureTable(OpenBBFigure):
def __init__(
self,
tabular_data: pd.DataFrame,
columnwidth: Optional[List[Union[int, float]]] = None,
):
super().__init__()
self._tabular_data = tabular_data
self._columnwidth = columnwidth
def show(
self,
show_index: Optional[bool] = True,
index_name: Optional[str] = "",
headers: Optional[Union[List[str], pd.Index]] = None,
title: Optional[str] = "",
source: Optional[str] = "",
external: Optional[bool] = False,
) -> Optional["OpenBBFigureTable"]:
if external:
return self
if not self._backend.isatty:
tmp_fig = self.to_table()
tmp_fig.show()
return None
else:
# Make a copy of the dataframe to avoid SettingWithCopyWarning
df = self._tabular_data.copy()
show_index = not isinstance(df.index, pd.RangeIndex) and show_index
# convert non-str that are not timestamp or int into str
# eg) praw.models.reddit.subreddit.Subreddit
for col in df.columns:
try:
if not any(
isinstance(df[col].iloc[x], pd.Timestamp)
for x in range(min(10, len(df)))
):
df[col] = pd.to_numeric(df[col], errors="ignore")
except (ValueError, TypeError):
df[col] = df[col].astype(str)
def _get_headers(_headers: Union[List[str], pd.Index]) -> List[str]:
"""Check if headers are valid and return them."""
output = _headers
if isinstance(_headers, pd.Index):
output = list(_headers)
if len(output) != len(df.columns):
raise (
ValueError(
"Length of headers does not match length of DataFrame"
)
)
return output
df_outgoing = df.copy()
# If headers are provided, use them
if headers is not None:
# We check if headers are valid
df_outgoing.columns = _get_headers(headers)
if show_index and index_name not in df_outgoing.columns:
# If index name is provided, we use it
df_outgoing.index.name = index_name or "Index"
df_outgoing = df_outgoing.reset_index()
for col in df_outgoing.columns:
if not col:
df_outgoing = df_outgoing.rename(columns={col: " "})
self._backend.send_table(
df_table=df_outgoing,
title=title,
source=source, # type: ignore
theme=self._charting_settings.table_style,
)
return None
def to_table(
self,
print_index: bool = True,
**kwargs,
) -> "OpenBBFigure":
data = self._tabular_data
columnwidth = self._columnwidth
return super().to_table(data, columnwidth, print_index, **kwargs)

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
from typing import Literal
from openbb_core.app.charting_service import ChartingService
from openbb_charting import Charting
from extensions.tests.utils.integration_tests_testers import (
check_missing_integration_test_params,
@@ -41,7 +41,8 @@ def test_api_interface_wrong_integration_test_params() -> None:
def test_charting_extension_function_coverage() -> None:
"""Test if all charting extension functions are covered by integration tests."""
functions = ChartingService.get_implemented_charting_functions()
functions = Charting.functions()
test_names = [f"test_chart_{func}" for func in functions]
integration_tests_modules = get_integration_tests(
test_type="api", filter_charting_ext=False

View File

@@ -2,7 +2,7 @@
from typing import Literal
from openbb_core.app.charting_service import ChartingService
from openbb_charting import Charting
from extensions.tests.utils.integration_tests_testers import (
check_missing_integration_test_params,
@@ -41,7 +41,8 @@ def test_python_interface_wrong_integration_test_params() -> None:
def test_charting_extension_function_coverage() -> None:
"""Test if all charting extension functions are covered by integration tests."""
functions = ChartingService.get_implemented_charting_functions()
functions = Charting.functions()
test_names = [f"test_chart_{func}" for func in functions]
integration_tests_modules = get_integration_tests(
test_type="python", filter_charting_ext=False

View File

@@ -2,10 +2,11 @@
import argparse
import os
from pathlib import Path
from typing import Dict, List, Literal, Type, get_type_hints
import requests
from openbb_core.app.charting_service import ChartingService
from openbb_charting import Charting
from openbb_core.app.provider_interface import ProviderInterface
from openbb_core.app.router import CommandMap
@@ -160,25 +161,21 @@ def write_commands_integration_tests(
def write_charting_extension_integration_tests():
"""Write the charting extension integration tests."""
functions = ChartingService.get_implemented_charting_functions()
import openbb_charting # pylint: disable=import-outside-toplevel
# we assume test file exists
path = os.path.join(
"openbb_platform",
"extensions",
"charting",
"integration",
"test_charting_api.py",
)
functions = Charting.functions()
charting_ext_path = Path(openbb_charting.__file__).parent.parent
test_file = charting_ext_path / "integration" / "test_charting_api.py"
for function in functions:
route = "/" + function.replace("_", "/")
if not test_exists(route=function, path=path):
if not test_exists(route=function, path=test_file):
write_test_w_template(
http_method="post",
params_list=[{"chart": True}],
route=route,
path=path,
path=test_file,
chart=True,
)

View File

@@ -14,7 +14,7 @@ from typing import (
get_type_hints,
)
from openbb_core.app.charting_service import ChartingService
from openbb_charting import Charting
from openbb_core.app.provider_interface import ProviderInterface
from openbb_core.app.router import CommandMap
from pydantic.fields import FieldInfo
@@ -44,6 +44,9 @@ def find_extensions(filter_chart: Optional[bool] = True):
if filter_chart:
filter_ext.append("charting")
extensions = [x for x in Path("openbb_platform/extensions").iterdir() if x.is_dir()]
extensions.extend(
[x for x in Path("openbb_platform/obbject_extensions").iterdir() if x.is_dir()]
)
extensions = [x for x in extensions if x.name not in filter_ext]
return extensions
@@ -279,7 +282,7 @@ def write_charting_extension_integration_tests():
"""Write charting extension integration tests."""
import openbb_charting # pylint: disable=import-outside-toplevel
functions = ChartingService.get_implemented_charting_functions()
functions = Charting.functions()
charting_ext_path = Path(openbb_charting.__file__).parent.parent
test_file = charting_ext_path / "integration" / "test_charting_python.py"
@@ -306,15 +309,15 @@ if __name__ == "__main__":
parser.add_argument(
"--charting",
dest="charting",
default=True,
action="store_false",
default=False,
action="store_true",
help="Generate charting extension integration tests",
)
parser.add_argument(
"--commands",
dest="commands",
default=True,
action="store_false",
default=False,
action="store_true",
help="Generate commands integration tests",
)

View File

@@ -8,6 +8,9 @@ The library includes:
- a set of charting components
- prebuilt charts for a set of commands that are built-in OpenBB extensions
>[!NOTE]
> The charting library is an [`OBBject` extension](https://docs.openbb.co/platform/development/developer-guidelines/obbject_extensions) which means you'll have the functionality it exposes on every command result.
## Installation
To install the extension, run the following command in this folder:
@@ -52,14 +55,77 @@ equity_data.show()
> Note: The `show()` method currently works either in a Jupyter Notebook or in a standalone python script with a PyWry based backend properly initialized.
Alternatively, you can use the fact that the `openbb-charting` is an `OBBject` extension and use its available methods.
```python
from openbb import obb
res = obb.equity.price.historical("AAPL")
res.charting.show()
```
The above code will produce the same effect as the previous example.
### Discovering available charts
Not all the endpoints are currently supported by the charting extension. To discover which endpoints are supported, you can run the following command:
```python
from openbb_charting import Charting
Charting.functions()
```
### Using the `to_chart` method
The `to_chart` function should be taken as an advanced feature, as it requires the user to have a good understanding of the charting extension and the `OpenBBFigure` class.
The user can use any number of `**kwargs` that will be passed to the `PlotlyTA` class in order to build custom visualizations with custom indicators and similar.
> Note that, this method will only work to some limited extent with data that is not standardized.
> Also, it is currently designed only to handle time series (OHLCV) data.
Example usage:
- Plotting a time series with TA indicators
```python
from openbb import obb
res = obb.equity.price.historical("AAPL")
indicators = dict(
sma=dict(length=[20,30,50]),
adx=dict(length=14),
rsi=dict(length=14),
macd=dict(fast=12, slow=26, signal=9),
bbands=dict(length=20, std=2),
stoch=dict(length=14),
ema=dict(length=[20,30,50]),
)
res.charting.to_chart(**{"indicators": indicators})
```
- Get all the available indicators
```python
# if you have a command result already
res.charting.indicators
# or if you want to know in standalone fashion
from openbb_charting import Charting
Charting.indicators()
```
## Add a visualization to an existing Platform command
One should first ensure that the already implemented endpoint is available in the [charting router](/openbb_platform/extensions/charting/openbb_charting/charting_router.py).
One should first ensure that the already implemented endpoint is available in the [charting router](/openbb_platform/obbject_extensions/charting/openbb_charting/charting_router.py).
To do so, you can run:
`python openbb_platform/extensions/charting/openbb_charting/builder.py` - which will read all the available endpoints and add them to the charting router.
`python openbb_platform/obbject_extensions/charting/openbb_charting/builder.py` - which will read all the available endpoints and add them to the charting router.
Afterwards, you'll need to add the visualization to the [charting router](/openbb_platform/extensions/charting/openbb_charting/charting_router.py). The convention to match the endpoint with the respective charting function is the following:
Afterwards, you'll need to add the visualization to the [charting router]. The convention to match the endpoint with the respective charting function is the following:
- `/equity/price/historical` -> `equity_price_historical`
- `/technical/ema` -> `technical_ema`
@@ -69,7 +135,6 @@ When you spot the charting function on the charting router file, you can add the
The implementation should leverage the already existing classes and methods to do so, namely:
- `OpenBBFigure`
- `OpenBBFigureTable`
- `PlotlyTA`
Note that the return of each charting function should respect the already defined return types: `Tuple[OpenBBFigure, Dict[str, Any]]`.
@@ -77,17 +142,4 @@ Note that the return of each charting function should respect the already define
The returned tuple contains a `OpenBBFigure` that is an interactive plotly figure which can be used in a Python interpreter, and a `Dict[str, Any]` that contains the raw data leveraged by the API.
After you're done implementing the charting function, you can use either the Python interface or the API to get the chart. To do so, you'll only need to set the already available `chart` argument to `True`.
### Using the `to_chart` OBBject method
The `OBBject` is the custom OpenBB object that is returned by the Platform commands.
It implements a set of `to_<something>` functions that enable the user to easily transform the data into a different format.
The `to_chart` function should be taken as an advanced feature, as it requires the user to have a good understanding of the charting extension and the `OpenBBFigure` class.
The user can use any number of `**kwargs` that will be passed to the `PlotlyTA` class in order to build custom visualizations with custom indicators and similar.
Refer to the [`to_chart` implementation](openbb_charting/core/to_chart.py) for further details.
> Note that, this method will only work to some limited extent with data that is not standardized.
> Also, it is currently designed only to handle time series data.
Or accessing the `charting` attribute of the `OBBject` object: `my_obbject.charting.show()`.

View File

@@ -0,0 +1,201 @@
"""OpenBB OBBject extension for charting."""
from typing import (
Any,
Callable,
Dict,
List,
Optional,
Tuple,
Union,
)
import numpy as np
import pandas as pd
from openbb_core.app.model.charts.chart import Chart
from openbb_core.app.model.extension import Extension
from openbb_core.app.utils import basemodel_to_df, convert_to_basemodel
from openbb_core.provider.abstract.data import Data
from openbb_charting import charting_router
from openbb_charting.core.to_chart import ChartIndicators, OpenBBFigure, to_chart
from openbb_charting.utils.helpers import get_charting_functions
ext = Extension(name="charting")
@ext.obbject_accessor
class Charting:
"""
Charting extension.
Methods
-------
show
Display chart and save it to the OBBject.
to_chart
Returns the plotly json representation of the chart.
functions
Returns a list of Platform commands with charting functions.
indicators
Returns a list of the available indicators to use with the `to_chart` method.
"""
def __init__(self, obbject):
"""Initialize Charting extension."""
# pylint: disable=import-outside-toplevel
from openbb_core.app.model.charts.charting_settings import ChartingSettings
from openbb_core.app.model.obbject import OBBject
self._obbject: OBBject = obbject
self._charting_settings = ChartingSettings(
user_settings=self._obbject._user_settings,
system_settings=self._obbject._system_settings,
)
self._handle_backend()
@classmethod
def indicators(cls):
"""Returns a list of the available indicators."""
return ChartIndicators.get_available_indicators()
@classmethod
def functions(cls):
"""Returns a list of the available functions."""
return get_charting_functions()
def _handle_backend(self):
# pylint: disable=import-outside-toplevel
from openbb_charting.core.backend import create_backend, get_backend
create_backend(self._charting_settings)
get_backend().start(debug=self._charting_settings.debug_mode)
@staticmethod
def _get_chart_function(route: str) -> Callable:
"""Given a route, it returns the chart function. The module must contain the given route."""
adjusted_route = route.replace("/", "_")[1:]
return getattr(charting_router, adjusted_route)
def show(self, render: bool = True, **kwargs):
"""Display chart and save it to the OBBject."""
charting_function = self._get_chart_function(
self._obbject._route # pylint: disable=protected-access
)
kwargs["obbject_item"] = self._obbject.results
kwargs["charting_settings"] = self._charting_settings
if (
hasattr(self._obbject, "_standard_params")
and self._obbject._standard_params # pylint: disable=protected-access
):
kwargs["standard_params"] = (
self._obbject._standard_params.__dict__ # pylint: disable=protected-access
)
fig, content = charting_function(**kwargs)
self._obbject.chart = Chart(
fig=fig, content=content, format=charting_router.CHART_FORMAT
)
if render:
fig.show(**kwargs)
# pylint: disable=too-many-arguments
def to_chart(
self,
data: Optional[
Union[
list,
dict,
pd.DataFrame,
List[pd.DataFrame],
pd.Series,
List[pd.Series],
np.ndarray,
Data,
]
] = None,
indicators: Optional[Union[ChartIndicators, Dict[str, Dict[str, Any]]]] = None,
symbol: str = "",
candles: bool = True,
volume: bool = True,
prepost: bool = False,
volume_ticks_x: int = 7,
render: bool = True,
**kwargs,
):
"""
Creates a OpenBBFigure with user customizations (if any) and saves it to the OBBject.
This function is used so it can be called at the module level and used out of the box,
which allows some more flexibility, ease of use and doesn't require the user to know
about the PlotlyTA class.
Parameters
----------
data : Union[pd.DataFrame, pd.Series]
Data to be plotted Data to be plotted (OHLCV data).
indicators : Optional[Union[ChartIndicators, Dict[str, Dict[str, Any]]]], optional
Indicators to be plotted, by default None
symbol : str, optional
Symbol to be plotted, by default ""
candles : bool, optional
If True, candles will be plotted, by default True
volume : bool, optional
If True, volume will be plotted, by default True
prepost : bool, optional
If True, prepost will be plotted, by default False
volume_ticks_x : int, optional
Volume ticks, by default 7
render : bool, optional
If True, the chart will be rendered, by default True
Examples
--------
1) Plotting a time series with TA indicators
> from openbb import obb
> res = obb.equity.price.historical("AAPL")
> indicators = dict(
> sma=dict(length=[20,30,50]),
> adx=dict(length=14),
> rsi=dict(length=14),
> macd=dict(fast=12, slow=26, signal=9),
> bbands=dict(length=20, std=2),
> stoch=dict(length=14),
> ema=dict(length=[20,30,50]),
> )
> res.charting.to_chart(**{"indicators": indicators})
2) Get all the available indicators
> from openbb_charting.core.plotly_ta.data_classes import ChartIndicators
> ChartIndicators.get_available_indicators()
or
> from openbb_charting import Charting
> Charting.indicators()
"""
has_data = (isinstance(data, (pd.DataFrame, pd.Series)) and not data.empty) or (
data
)
index = (
data.index.name
if has_data and isinstance(data, (pd.DataFrame, pd.Series))
else ""
)
data_as_df: pd.DataFrame = (
basemodel_to_df(convert_to_basemodel(data), index=index)
if has_data
else self._obbject.to_dataframe()
)
fig, content = to_chart(
data_as_df,
indicators=indicators,
symbol=symbol,
candles=candles,
volume=volume,
prepost=prepost,
volume_ticks_x=volume_ticks_x,
)
self._obbject.chart = Chart(
fig=fig, content=content, format=charting_router.CHART_FORMAT
)
if render:
fig.show(**kwargs)

View File

@@ -1,35 +1,40 @@
"""Script used to create charting functions in charting_router.py."""
from inspect import getmembers, isfunction
from typing import List
from openbb_charting import charting_router
from openbb_core.app.router import RouterLoader
from openbb_charting import charting_router
# refers to the root of the repo, not the root of the extension
# this script should be run from the root of the repo
CHARTING_ROUTER = (
"openbb_platform/extensions/charting/openbb_charting/charting_router.py"
"openbb_platform/obbject_extensions/charting/openbb_charting/charting_router.py"
)
def get_routes() -> List[str]:
"""Get all routes from the api router."""
router = RouterLoader.from_extensions()
return [route.path for route in router.api_router.routes] # type: ignore
def create_charting_functions_w_routes(routes_to_add: List[str]):
"""Create new charting functions in charting_router.py."""
with open(CHARTING_ROUTER, "a") as f:
for route in routes_to_add:
original_route = route.replace("_", "/")
f.write(
f"""
def {route}(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def {route}(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
raise NotImplementedError("Command `{original_route}` does not have a charting function")
"""
)
def get_charting_functions() -> List[str]:
"""Get all functions from charting_router.py."""
functions = getmembers(charting_router, isfunction)
if functions:
return [function[0] for function in functions]

View File

@@ -1,21 +1,21 @@
"""Charting router."""
from typing import Any, Dict, Tuple
from typing import TYPE_CHECKING, Any, Dict, Tuple
from openbb_core.app.model.charts.chart import ChartFormat
from openbb_core.app.router import Router
from openbb_core.app.utils import basemodel_to_df
from .core.openbb_figure import OpenBBFigure
from .core.openbb_figure_table import OpenBBFigureTable
from .core.plotly_ta.ta_class import PlotlyTA
router = Router(prefix="")
CHART_FORMAT = ChartFormat.plotly
if TYPE_CHECKING:
from .core.openbb_figure import OpenBBFigure
def equity_price_historical(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
# from .core.openbb_figure_table import OpenBBFigureTable
def equity_price_historical(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Equity price chart."""
def handle_indicators(ma):
@@ -26,12 +26,12 @@ def equity_price_historical(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
return k
data = basemodel_to_df(kwargs["obbject_item"], index=kwargs.get("index", "date"))
standard_params = kwargs["standard_params"].__dict__
standard_params = kwargs["standard_params"]
ma = standard_params.get("ma", None)
prepost = standard_params.get("prepost", False)
symbol = standard_params.get("symbol", "")
ta = PlotlyTA(charting_settings=kwargs["charting_settings"])
ta = PlotlyTA()
fig = ta.plot(
data,
indicators=dict(**handle_indicators(ma)),
@@ -45,12 +45,12 @@ def equity_price_historical(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def _ta_ma(ma_type: str, **kwargs):
"""Plot moving average helper."""
data = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "date"))
data = basemodel_to_df(kwargs["obbject_item"], index=kwargs.get("index", "date"))
window = kwargs.get("window", 50)
offset = kwargs.get("offset", 0)
symbol = kwargs.get("symbol", "")
ta = PlotlyTA(charting_settings=kwargs["charting_settings"])
ta = PlotlyTA()
fig = ta.plot(
data,
{f"{ma_type.lower()}": dict(length=window, offset=offset)},
@@ -63,20 +63,20 @@ def _ta_ma(ma_type: str, **kwargs):
return fig, content
def technical_zlma(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def technical_zlma(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Zero lag moving average chart."""
ma_type = "zlma"
return _ta_ma(ma_type, **kwargs)
def technical_aroon(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def technical_aroon(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Aroon chart."""
data = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "date"))
data = basemodel_to_df(kwargs["obbject_item"], index=kwargs.get("index", "date"))
length = kwargs.get("length", 25)
scalar = kwargs.get("scalar", 100)
symbol = kwargs.get("symbol", "")
ta = PlotlyTA(charting_settings=kwargs["charting_settings"])
ta = PlotlyTA()
fig = ta.plot(
data,
dict(aroon=dict(length=length, scalar=scalar)),
@@ -89,21 +89,21 @@ def technical_aroon(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
return fig, content
def technical_sma(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def technical_sma(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Plot simple moving average chart."""
ma_type = "sma"
return _ta_ma(ma_type, **kwargs)
def technical_macd(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def technical_macd(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Plot moving average convergence divergence chart."""
data = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "date"))
data = basemodel_to_df(kwargs["obbject_item"], index=kwargs.get("index", "date"))
fast = kwargs.get("fast", 12)
slow = kwargs.get("slow", 26)
signal = kwargs.get("signal", 9)
symbol = kwargs.get("symbol", "")
ta = PlotlyTA(charting_settings=kwargs["charting_settings"])
ta = PlotlyTA()
fig = ta.plot(
data,
dict(macd=dict(fast=fast, slow=slow, signal=signal)),
@@ -116,21 +116,21 @@ def technical_macd(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
return fig, content
def technical_hma(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def technical_hma(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Hull moving average chart."""
ma_type = "hma"
return _ta_ma(ma_type, **kwargs)
def technical_adx(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def technical_adx(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Average directional movement index chart."""
data = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "date"))
data = basemodel_to_df(kwargs["obbject_item"], index=kwargs.get("index", "date"))
length = kwargs.get("length", 14)
scalar = kwargs.get("scalar", 100.0)
drift = kwargs.get("drift", 1)
symbol = kwargs.get("symbol", "")
ta = PlotlyTA(charting_settings=kwargs["charting_settings"])
ta = PlotlyTA()
fig = ta.plot(
data,
dict(adx=dict(length=length, scalar=scalar, drift=drift)),
@@ -143,21 +143,21 @@ def technical_adx(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
return fig, content
def technical_wma(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def technical_wma(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Weighted moving average chart."""
ma_type = "wma"
return _ta_ma(ma_type, **kwargs)
def technical_rsi(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def technical_rsi(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Relative strength index chart."""
data = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "date"))
data = basemodel_to_df(kwargs["obbject_item"], index=kwargs.get("index", "date"))
window = kwargs.get("window", 14)
scalar = kwargs.get("scalar", 100.0)
drift = kwargs.get("drift", 1)
symbol = kwargs.get("symbol", "")
ta = PlotlyTA(charting_settings=kwargs["charting_settings"])
ta = PlotlyTA()
fig = ta.plot(
data,
dict(rsi=dict(length=window, scalar=scalar, drift=drift)),
@@ -170,19 +170,7 @@ def technical_rsi(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
return fig, content
def technical_ema(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
def technical_ema(**kwargs) -> Tuple["OpenBBFigure", Dict[str, Any]]:
"""Exponential moving average chart."""
ma_type = "ema"
return _ta_ma(ma_type, **kwargs)
def equity_fundamental_multiples(**kwargs) -> Tuple[OpenBBFigure, Dict[str, Any]]:
"""Equity multiples chart."""
data = basemodel_to_df(kwargs["obbject_item"], index=kwargs.get("index", "date"))
standard_params = kwargs["standard_params"].__dict__
columnwidth = standard_params.get("columnwidth", None)
tbl_fig = OpenBBFigureTable(tabular_data=data, columnwidth=columnwidth)
content = tbl_fig.to_table().show(external=True).to_plotly_json()
return tbl_fig, content

View File

@@ -11,15 +11,19 @@ import warnings
from multiprocessing import current_process
from pathlib import Path
from threading import Thread
from typing import Any, Dict, Optional, Union
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
import aiohttp
import pandas as pd
import plotly.graph_objects as go
from openbb_core.app.model.charts.charting_settings import ChartingSettings
from openbb_core.env import Env
from packaging import version
from reportlab.graphics import renderPDF
from svglib.svglib import svg2rlg
if TYPE_CHECKING:
from openbb_core.app.model.charts.charting_settings import ChartingSettings
# pylint: disable=C0415
try:
@@ -34,8 +38,6 @@ except ImportError as e:
"""Dummy backend for charts."""
from svglib.svglib import svg2rlg
try:
from IPython import get_ipython
@@ -67,7 +69,7 @@ class Backend(PyWry):
def __init__(
self,
charting_settings: ChartingSettings,
charting_settings: "ChartingSettings",
daemon: bool = True,
max_retries: int = 30,
proc_name: str = "OpenBB Terminal",
@@ -504,7 +506,10 @@ if not PLOTLYJS_PATH.exists() and not JUPYTER_NOTEBOOK:
Thread(target=asyncio.run, args=(download_plotly_js(),)).start()
def create_backend(charting_settings: Optional[ChartingSettings] = None):
def create_backend(charting_settings: Optional["ChartingSettings"] = None):
# # pylint: disable=import-outside-toplevel
from openbb_core.app.model.charts.charting_settings import ChartingSettings
charting_settings = charting_settings or ChartingSettings()
global BACKEND # pylint: disable=W0603 # noqa
if BACKEND is None:

View File

@@ -15,6 +15,7 @@ from warnings import warn
import plotly.graph_objects as go
import plotly.io as pio
from openbb_charting.core.config.openbb_styles import (
PLT_COLORWAY,
PLT_DECREASING_COLORWAY,

View File

@@ -23,15 +23,17 @@ import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots
from openbb_charting.core.backend import PLOTLYJS_PATH, create_backend, get_backend
from openbb_charting.core.chart_style import ChartStyle
from openbb_charting.core.config.openbb_styles import (
PLT_TBL_ROW_COLORS,
)
from openbb_core.app.model.charts.charting_settings import ChartingSettings
from plotly.subplots import make_subplots
if TYPE_CHECKING:
from openbb_core.app.model.charts.charting_settings import ChartingSettings
try: # noqa: SIM105
# pylint: disable=W0611 # noqa: F401
from darts import TimeSeries
@@ -86,7 +88,9 @@ class OpenBBFigure(go.Figure):
if fig:
self.__dict__ = fig.__dict__
self._charting_settings = kwargs.pop("charting_settings", ChartingSettings())
self._charting_settings: Optional["ChartingSettings"] = kwargs.pop(
"charting_settings", None
)
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)
@@ -102,10 +106,12 @@ class OpenBBFigure(go.Figure):
if kwargs.pop("create_backend", False):
create_backend(self._charting_settings)
get_backend().start(debug=self._charting_settings.debug_mode)
get_backend().start(
debug=getattr(self._charting_settings, "debug_mode", False)
)
self._theme = ChartStyle(
self._charting_settings.chart_style,
self._charting_settings.user_styles_directory,
getattr(self._charting_settings, "plt_style", ""),
getattr(self._charting_settings, "user_styles_directory", None),
)
if xaxis := kwargs.pop("xaxis", None):
@@ -232,7 +238,8 @@ class OpenBBFigure(go.Figure):
secondary_y: bool = False,
**kwargs,
):
"""Add a trend line to the figure.
"""
Add a trend line to the figure.
Parameters
----------
@@ -245,7 +252,6 @@ class OpenBBFigure(go.Figure):
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"],
@@ -285,7 +291,8 @@ class OpenBBFigure(go.Figure):
row: int = 1,
col: int = 1,
) -> None:
"""Add a histogram with a curve and rug plot if desired.
"""
Add a histogram with a curve and rug plot if desired.
Parameters
----------
@@ -312,7 +319,6 @@ class OpenBBFigure(go.Figure):
col : `int`, optional
Column of the subplot, by default 1
"""
from scipy import stats # pylint: disable=import-outside-toplevel
callback = stats.norm if curve == "normal" else stats.gaussian_kde
@@ -554,7 +560,8 @@ class OpenBBFigure(go.Figure):
legendrank: Optional[int] = None,
**kwargs,
) -> None:
"""Add a horizontal line with a legend label.
"""
Add a horizontal line with a legend label.
Parameters
----------
@@ -567,7 +574,6 @@ class OpenBBFigure(go.Figure):
legendrank : `int`, optional
Legend rank, by default None (e.g. 1 is above 2)
"""
if line is None:
line = {}
@@ -592,7 +598,8 @@ class OpenBBFigure(go.Figure):
legendrank: Optional[int] = None,
**kwargs,
) -> None:
"""Add a vertical line with a legend label.
"""
Add a vertical line with a legend label.
Parameters
----------
@@ -605,7 +612,6 @@ class OpenBBFigure(go.Figure):
legendrank : `int`, optional
Legend rank, by default None (e.g. 1 is above 2)
"""
if line is None:
line = {}
@@ -661,7 +667,8 @@ class OpenBBFigure(go.Figure):
def chart_volume_scaling(
df_volume: pd.DataFrame, volume_ticks_x: int = 7
) -> Dict[str, list]:
"""Takes df_volume and returns volume_ticks, tickvals for chart volume scaling
"""
Takes df_volume and returns volume_ticks, tickvals for chart volume scaling.
Parameters
----------
@@ -709,7 +716,8 @@ class OpenBBFigure(go.Figure):
col: Optional[int] = 1,
volume_ticks_x: int = 7,
) -> None:
"""Add in-chart volume to a subplot.
"""
Add in-chart volume to a subplot.
Parameters
----------
@@ -726,7 +734,6 @@ class OpenBBFigure(go.Figure):
volume_ticks_x : int, optional
Number to multiply volume, by default 7
"""
colors = np.where(
df_stock.open < df_stock[close_col],
self._theme.up_color,
@@ -838,8 +845,9 @@ class OpenBBFigure(go.Figure):
external: bool = False,
export_image: Optional[Union[Path, str]] = "",
**kwargs,
) -> Optional["OpenBBFigure"]:
"""Show the figure.
) -> "OpenBBFigure":
"""
Show the figure.
Parameters
----------
@@ -854,7 +862,6 @@ class OpenBBFigure(go.Figure):
date_xaxis : `bool`, optional
Whether to check if the xaxis is a date axis, by default True
"""
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
@@ -899,7 +906,7 @@ class OpenBBFigure(go.Figure):
if external or self._exported:
return self # type: ignore
if self._charting_settings.headless:
if getattr(self._charting_settings, "headless", False):
return self.to_json()
kwargs.update(config=dict(scrollZoom=True, displaylogo=False))
@@ -916,7 +923,7 @@ class OpenBBFigure(go.Figure):
# 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 self._charting_settings.debug_mode:
if getattr(self._charting_settings, "debug_mode", False):
warn(f"Failed to show figure with backend: {e}")
warn(
f"Failed to show figure with backend: {e}"
@@ -1232,8 +1239,7 @@ class OpenBBFigure(go.Figure):
self, ndarray: bool = False, np_nan: bool = False
) -> Dict[str, Any]:
"""
Convert figure to a JSON representation as a Python dict
Convert figure to a JSON representation as a Python dict.
Parameters
----------
@@ -1512,7 +1518,6 @@ class OpenBBFigure(go.Figure):
def add_logscale_menus(self, yaxis: str = "yaxis") -> None:
"""Set the menus for the figure."""
self._added_logscale = True
bg_color = "#000000" if self._theme.mapbox_style == "dark" else "#FFFFFF" # type: ignore
font_color = "#FFFFFF" if self._theme.mapbox_style == "dark" else "#000000" # type: ignore

View File

@@ -1,9 +1,11 @@
"""Dataclasses for the charting extension."""
# pylint: disable=C0302,R0915,R0914,R0913,R0903,R0904
import sys
import warnings
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
import pandas as pd
import pandas_ta as ta
@@ -15,7 +17,7 @@ datacls_kwargs = {"slots": True} if sys.version_info >= (3, 10) else {}
def columns_regex(df_ta: pd.DataFrame, name: str) -> List[str]:
"""Return columns that match regex name"""
"""Return columns that match regex name."""
column_name = df_ta.filter(regex=rf"{name}(?=[^\d]|$)").columns.tolist()
return column_name
@@ -23,28 +25,58 @@ def columns_regex(df_ta: pd.DataFrame, name: str) -> List[str]:
@dataclass(**datacls_kwargs)
class Arguments:
"""Arguments for technical analysis indicators."""
label: str
values: Any
def __post_init__(self):
"""Post init."""
if isinstance(self.values, list) and len(self.values) == 1:
self.values = self.values[0]
@dataclass(**datacls_kwargs)
class TAIndicator:
name: str
"""Technical analysis indicator."""
name: Literal[
"ad",
"adosc",
"adx",
"aroon",
"atr",
"cci",
"donchian",
"fisher",
"kc",
"obv",
"stoch",
"vwap",
"fib",
"srlines",
"clenow",
"demark",
"ichimoku",
"sma",
"ema",
"wma",
"hma",
"zlma",
"rma",
]
args: List[Arguments]
def __post_init__(self):
"""Post init."""
self.args = [Arguments(**arg) for arg in self.args]
def __iter__(self):
"""Return iterator"""
"""Return iterator."""
return iter(self.args)
def get_args(self, label: str) -> Union[Arguments, None]:
"""Return arguments by label"""
"""Return arguments by label."""
output = None
for opt in self.args:
if opt.label == label:
@@ -52,7 +84,7 @@ class TAIndicator:
return output
def get_argument_values(self, label: str) -> Union[List[Any], Any]:
"""Returns arguments values by label"""
"""Return arguments values by label."""
output = []
options = self.get_args(label)
if options is not None:
@@ -62,9 +94,12 @@ class TAIndicator:
@dataclass(**datacls_kwargs)
class ChartIndicators:
"""Chart technical analysis indicators."""
indicators: Optional[List[TAIndicator]] = None
def __post_init__(self):
"""Post init."""
self.indicators = (
[TAIndicator(**indicator) for indicator in self.indicators]
if self.indicators
@@ -72,7 +107,7 @@ class ChartIndicators:
)
def get_indicator(self, name: str) -> Union[TAIndicator, None]:
"""Returns indicator with given name"""
"""Return indicator with given name."""
output = None
for indicator in self.indicators: # type: ignore
if indicator.name == name:
@@ -80,7 +115,7 @@ class ChartIndicators:
return output
def get_indicator_args(self, name: str, label: str) -> Union[Arguments, None]:
"""Returns argument values for given indicator and label"""
"""Return argument values for given indicator and label."""
output = None
indicator = self.get_indicator(name)
if indicator is not None:
@@ -90,25 +125,25 @@ class ChartIndicators:
return output
def get_indicators(self) -> Optional[List[TAIndicator]]:
"""Return active indicators and their arguments"""
"""Return active indicators and their arguments."""
return self.indicators
def get_params(self) -> Dict[str, TAIndicator]:
"""Return dictionary of active indicators and their arguments"""
"""Return dictionary of active indicators and their arguments."""
output = {}
if self.indicators:
output = {indicator.name: indicator for indicator in self.indicators}
output = {str(indicator.name): indicator for indicator in self.indicators}
return output
def get_active_ids(self) -> List[str]:
"""Returns list of names of active indicators"""
"""Return list of names of active indicators."""
active_ids = []
if self.indicators:
active_ids = [indicator.name for indicator in self.indicators]
active_ids = [str(indicator.name) for indicator in self.indicators]
return active_ids
def get_arg_names(self, name: str) -> List[str]:
"""Returns list of argument labels for given indicator"""
"""Return list of argument labels for given indicator."""
output = []
indicator = self.get_indicator(name)
if indicator is not None:
@@ -117,7 +152,7 @@ class ChartIndicators:
return output
def get_options_dict(self, name: str) -> Dict[str, Optional[Arguments]]:
"""Returns dictionary of argument labels and values for given indicator"""
"""Return dictionary of argument labels and values for given indicator."""
output = None
options = self.get_arg_names(name)
if options:
@@ -127,9 +162,16 @@ class ChartIndicators:
return output
@staticmethod
def get_available_indicators() -> Tuple[str, ...]:
"""Return tuple of available indicators."""
return list(
TAIndicator.__annotations__["name"].__args__ # pylint: disable=E1101
)
@classmethod
def from_dict(cls, indicators: Dict[str, Dict[str, Any]]) -> "ChartIndicators":
"""Return ChartIndicators from dictionary"""
"""Return ChartIndicators from dictionary."""
data = []
for indicator in indicators:
args = []
@@ -164,7 +206,7 @@ class ChartIndicators:
return output
def remove_indicator(self, name: str) -> None:
"""Remove indicator from active indicators"""
"""Remove indicator from active indicators."""
if self.indicators:
for indicator in self.indicators:
if indicator.name == name:
@@ -172,12 +214,12 @@ class ChartIndicators:
class TA_DataException(Exception):
pass
"""Exception for TA_Data."""
class TA_Data:
"""Process technical analysis data
"""
Process technical analysis data.
Parameters
----------
@@ -193,14 +235,12 @@ class TA_Data:
rsi=dict(length=14),
)
Methods
-------
to_dataframe()
Return dataframe with technical analysis indicators
get_indicator_data(indicator: TAIndicator, **kwargs)
Return dataframe given indicator and arguments
"""
def __init__(
@@ -209,6 +249,7 @@ class TA_Data:
indicators: Union[ChartIndicators, Dict[str, Dict[str, Any]]],
ma_mode: Optional[List[str]] = None,
):
"""Initialize."""
if isinstance(df_ta, pd.Series):
df_ta = df_ta.to_frame()
@@ -240,7 +281,8 @@ class TA_Data:
self.has_volume = "volume" in df_ta.columns and bool(df_ta["volume"].sum() > 0)
def get_indicator_data(self, indicator: TAIndicator, **args) -> pd.DataFrame:
"""Returns dataframe with indicator data
"""
Return dataframe with indicator data.
Parameters
----------
@@ -249,7 +291,7 @@ class TA_Data:
args : dict
Arguments for given indicator
Returns
Return
-------
pd.DataFrame
Dataframe with indicator data
@@ -302,7 +344,7 @@ class TA_Data:
return output
def to_dataframe(self) -> pd.DataFrame:
"""Returns dataframe with all indicators"""
"""Return dataframe with all indicators."""
active_indicators = self.indicators.get_indicators()
if not active_indicators:

View File

@@ -6,22 +6,25 @@ import inspect
import sys
import warnings
from pathlib import Path
from typing import Any, Dict, List, Optional, Type, Union
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union
import pandas as pd
from openbb_charting.core.chart_style import ChartStyle
from openbb_charting.core.openbb_figure import OpenBBFigure
from openbb_core.app.model.charts.charting_settings import ChartingSettings
from .base import PltTA
from .data_classes import ChartIndicators
from .ta_helpers import check_columns
OPENBB_CHARTING_EXTENSION_PATH = Path(__file__).parent.parent.parent
CHARTING_INSTALL_PATH = OPENBB_CHARTING_EXTENSION_PATH.parent
charting_EXTENSION_PATH = Path(__file__).parent.parent.parent
CHARTING_INSTALL_PATH = charting_EXTENSION_PATH.parent
PLUGINS_PATH = Path(__file__).parent / "plugins"
PLOTLY_TA: Optional["PlotlyTA"] = None
if TYPE_CHECKING:
from openbb_core.app.model.charts.charting_settings import ChartingSettings
class PlotlyTA(PltTA):
"""Plotly Technical Analysis class.
@@ -60,7 +63,7 @@ class PlotlyTA(PltTA):
Examples
--------
>>> from openbb import obb
>>> from openbb_charting.core.plotly_ta.ta_class import PlotlyTA
>>> from charting.core.plotly_ta.ta_class import PlotlyTA
>>> df = obb.equity.price.historical("SPY")
>>> indicators = dict(
@@ -90,13 +93,18 @@ class PlotlyTA(PltTA):
has_volume: bool = True
show_volume: bool = True
prepost: bool = False
charting_settings: ChartingSettings = ChartingSettings()
charting_settings: Optional["ChartingSettings"] = None
theme: Optional[ChartStyle] = None
def __new__(cls, *args, **kwargs):
"""Method is overridden to create a singleton instance of the class."""
cls.charting_settings = kwargs.pop("charting_settings", cls.charting_settings)
cls.theme = cls.setup_theme(cls.charting_settings)
cls.theme = cls.setup_theme(
chart_style=getattr(cls.charting_settings, "chart_style", ""),
user_styles_directory=getattr(
cls.charting_settings, "user_styles_directory", ""
),
)
cls.inchart_colors = cls.theme.get_colors()
global PLOTLY_TA # pylint: disable=global-statement # noqa
@@ -105,14 +113,14 @@ class PlotlyTA(PltTA):
# We set the global variable to the instance of the class so that
# the plugins are only loaded once
PLOTLY_TA = super().__new__(cls)
PLOTLY_TA._locate_plugins(cls.charting_settings.debug_mode)
PLOTLY_TA._locate_plugins(
getattr(cls.charting_settings, "debug_mode", False)
)
PLOTLY_TA.add_plugins(PLOTLY_TA.plugins)
return PLOTLY_TA
def __init__(
self, *args, charting_settings: Optional[ChartingSettings] = None, **kwargs
): # pylint: disable=unused-argument
def __init__(self, *args, **kwargs):
"""Method is overridden to do nothing, except to clear the internal data structures."""
if not args and not kwargs:
self._clear_data()
@@ -121,12 +129,9 @@ class PlotlyTA(PltTA):
super().__init__(*args, **kwargs)
@staticmethod
def setup_theme(charting_settings: ChartingSettings) -> ChartStyle:
def setup_theme(chart_style, user_styles_directory) -> ChartStyle:
"""Setup theme for charting."""
return ChartStyle(
charting_settings.chart_style,
charting_settings.user_styles_directory,
)
return ChartStyle(chart_style, user_styles_directory)
@property
def ma_mode(self) -> List[str]:

View File

@@ -8,8 +8,8 @@
<script>
if (window.json_data && JSON.parse(window.json_data)?.posthog?.collect_logs) {
!function (t, e) { var o, n, p, r; e.__SV || (window.posthog = e, e._i = [], e.init = function (i, s, a) { function g(t, e) { var o = e.split("."); 2 == o.length && (t = t[o[0]], e = o[1]), t[e] = function () { t.push([e].concat(Array.prototype.slice.call(arguments, 0))) } } (p = t.createElement("script")).type = "text/javascript", p.async = !0, p.src = s.api_host + "/static/array.js", (r = t.getElementsByTagName("script")[0]).parentNode.insertBefore(p, r); var u = e; for (void 0 !== a ? u = e[a] = [] : a = "posthog", u.people = u.people || [], u.toString = function (t) { var e = "posthog"; return "posthog" !== a && (e += "." + a), t || (e += " (stub)"), e }, u.people.toString = function () { return u.toString(1) + ".people (stub)" }, o = "capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags".split(" "), n = 0; n < o.length; n++)g(u, o[n]); e._i.push([i, s, a]) }, e.__SV = 1) }(document, window.posthog || []);
posthog.init("phc_6FXLqu4uW9yxfyN8DpPdgzCdlYXOmIWdMGh6GnBgJLX", {
api_host: "https://eu.posthog.com",
posthog.init("", {
api_host: "https://app.posthog.com",
autocapture: {
css_selector_allowlist: [".ph-capture"],
},

View File

@@ -1,14 +1,15 @@
"""Module containing the to_chart function."""
from typing import Any, Dict, Optional, Tuple, Union
import pandas as pd
from openbb_charting.core.openbb_figure import OpenBBFigure
from openbb_charting.core.plotly_ta.data_classes import ChartIndicators
from openbb_charting.core.plotly_ta.ta_class import PlotlyTA
from openbb_core.app.model.charts.charting_settings import ChartingSettings
def to_chart(
charting_settings: ChartingSettings,
data: Union[pd.DataFrame, pd.Series],
indicators: Optional[Union[ChartIndicators, Dict[str, Dict[str, Any]]]] = None,
symbol: str = "",
@@ -19,14 +20,13 @@ def to_chart(
) -> Tuple[OpenBBFigure, Dict[str, Any]]:
"""
Returns the plotly json representation of the chart.
This function is used so it can be called at the module level and used out of the box,
which allows some more flexibility, ease of use and doesn't require the user to know
about the PlotlyTA class.
Parameters
----------
charting_settings : ChartingSettings
Charting settings.
data : Union[pd.DataFrame, pd.Series]
Data to be plotted.
indicators : Optional[Union[ChartIndicators, Dict[str, Dict[str, Any]]]], optional
@@ -47,9 +47,8 @@ def to_chart(
Tuple[OpenBBFigure, Dict[str, Any]]
Tuple containing the OpenBBFigure and the plotly json representation of the chart.
"""
try:
ta = PlotlyTA(charting_settings=charting_settings)
ta = PlotlyTA()
fig = ta.plot(
df_stock=data,
indicators=indicators,

View File

@@ -0,0 +1 @@
"""OpenBB Charting utils."""

View File

@@ -0,0 +1,21 @@
"""Helper functions for charting."""
from inspect import getmembers, getsource, isfunction
from typing import List
from openbb_charting import charting_router
def get_charting_functions() -> List[str]:
"""Discover charting functions."""
implemented_functions = []
for name, obj in getmembers(charting_router, isfunction):
if (
obj.__module__ == charting_router.__name__
and not name.startswith("_")
and "NotImplementedError" not in getsource(obj)
):
implemented_functions.append(name)
return implemented_functions

1382
openbb_platform/obbject_extensions/charting/poetry.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
[tool.poetry]
name = "openbb-charting"
version = "1.1.1"
version = "2.0.0"
description = "Charting extension for OpenBB"
authors = ["OpenBB Team <hello@openbb.co>"]
readme = "README.md"
packages = [{ include = "openbb_charting" }]
[tool.poetry.dependencies]
python = ">=3.8,<3.12" # scipy forces python <4.0 explicitly
python = ">=3.8,<3.12" # scipy forces python <4.0 explicitly
scipy = "^1.10.0"
plotly = "^5.17.0"
statsmodels = "^0.14.0"
@@ -22,5 +22,5 @@ openbb-core = "^1.1.1"
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.plugins."openbb_core_extension"]
openbb_charting = "openbb_charting.charting_router:router"
[tool.poetry.plugins."openbb_obbject_extension"]
openbb_charting = "openbb_charting:ext"

View File

@@ -0,0 +1,131 @@
from unittest.mock import MagicMock, patch
import pytest
from openbb_charting import Charting
from openbb_core.app.model.system_settings import SystemSettings
from openbb_core.app.model.user_settings import UserSettings
from pydantic import BaseModel
@pytest.fixture()
def obbject():
class MockStdParams(BaseModel):
param1: str
param2: str
class MockOOBject:
def __init__(self):
self._user_settings = UserSettings()
self._system_settings = SystemSettings()
self._route = "mock/route"
self._standard_params = MockStdParams(
param1="mock_param1", param2="mock_param2"
)
self.results = "mock_results"
def to_dataframe(self):
return "mock_dataframe"
return MockOOBject()
def test_charting_settings(obbject):
cm = Charting(obbject)
assert isinstance(cm, Charting)
@patch("openbb_charting.ChartIndicators.get_available_indicators")
def test_indicators(mock_get_available_indicators, obbject):
# Arrange
mock_get_available_indicators.return_value = [
"indicator1",
"indicator2",
"indicator3",
]
obj = Charting(obbject)
# Act
result = obj.indicators()
# Assert
assert result == ["indicator1", "indicator2", "indicator3"]
mock_get_available_indicators.assert_called_once()
@patch("openbb_charting.get_charting_functions")
def test_functions(mock_get_charting_functions):
# Arrange
mock_get_charting_functions.return_value = ["function1", "function2", "function3"]
# Act
result = Charting.functions()
# Assert
assert result == ["function1", "function2", "function3"]
mock_get_charting_functions.assert_called_once()
@patch("openbb_charting.core.backend.get_backend")
@patch("openbb_charting.core.backend.create_backend")
def test_handle_backend(mock_create_backend, mock_get_backend, obbject):
# Act -> _handle backend is called in the constructor
obj = Charting(obbject)
# Assert
mock_create_backend.assert_called_once_with(obj._charting_settings)
mock_get_backend.assert_called_once()
@patch("openbb_charting.charting_router")
def test_get_chart_function(mock_charting_router):
# Arrange
mock_function = MagicMock()
mock_charting_router.some_function = mock_function
route = "/some/function"
# Act
result = Charting._get_chart_function(route)
# Assert
assert result == mock_function
@patch("openbb_charting.Charting._get_chart_function")
def test_show(mock_get_chart_function, obbject):
# Arrange
mock_function = MagicMock()
mock_get_chart_function.return_value = mock_function
mock_fig = MagicMock()
mock_function.return_value = (mock_fig, {"content": "mock_content"})
obj = Charting(obbject)
# Act
obj.show()
# Assert
mock_get_chart_function.assert_called_once()
mock_function.assert_called_once()
mock_fig.show.assert_called_once()
@patch("openbb_charting.to_chart")
def test_to_chart(mock_to_chart, obbject):
# Arrange
mock_fig = MagicMock()
mock_to_chart.return_value = (mock_fig, {"content": "mock_content"})
obj = Charting(obbject)
# Act
obj.to_chart()
# Assert
assert obj._obbject.chart.fig == mock_fig
mock_to_chart.assert_called_once_with(
"mock_dataframe",
indicators=None,
symbol="",
candles=True,
volume=True,
prepost=False,
volume_ticks_x=7,
)

View File

@@ -47,7 +47,7 @@ openbb-seeking-alpha = { version = "^1.1.1", optional = true }
openbb-stockgrid = { version = "^1.1.1", optional = true }
openbb-wsj = { version = "^1.1.1", optional = true }
openbb-charting = { version = "^1.1.1", optional = true }
openbb-charting = { version = "^2.0.0", optional = true }
openbb-econometrics = { version = "^1.1.1", optional = true }
openbb-quantitative = { version = "^1.1.1", optional = true }
openbb-technical = { version = "^1.1.2", optional = true }