[V5] openbb-charting: Refactor for V5 and Decouple (#7545)

* refactor charting for v5 patterns and decouple entirely

* coveragerc

* lint and tests

* fix tests

* configure dev group with openbb-technical

* ty

* fix cli test

* remove 3.15 from test matrix - dependencies are unstable

---------

Co-authored-by: deeleeramone <>
This commit is contained in:
Danglewood
2026-07-01 09:37:01 -07:00
committed by GitHub
parent 3fd14d7068
commit f6867c2b18
85 changed files with 11846 additions and 5678 deletions

View File

@@ -0,0 +1,179 @@
name: 📦 publish openbb-charting
# Triggers exclusively on the merge of a ``release/openbb-charting-v*`` branch
# into ``develop``. No push / tag triggers. Three sequential jobs —
# build once, test the built artifact across the full cross-platform
# matrix, and only publish to PyPI if the entire matrix is green.
on:
pull_request:
types: [closed]
branches: [develop, v5]
workflow_dispatch:
permissions:
contents: read
jobs:
version-check:
name: Verify version bump
if: ${{ (github.event_name == 'pull_request' && github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/openbb-charting-v')) || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
outputs:
version: ${{ steps.parse.outputs.version }}
defaults:
run:
shell: bash
working-directory: openbb_platform/obbject_extensions/charting
steps:
- uses: actions/checkout@v6
with:
ref: develop
- name: Parse [project].version from pyproject.toml
id: parse
run: |
VERSION=$(grep -m1 -E '^version[[:space:]]*=' pyproject.toml | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')
if [ -z "$VERSION" ]; then
echo "::error::Could not parse [project].version from pyproject.toml."
exit 1
fi
echo "Local version: $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Fail if version already exists on PyPI
env:
VERSION: ${{ steps.parse.outputs.version }}
run: |
STATUS=$(curl -sS -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/openbb-charting/${VERSION}/json")
case "$STATUS" in
200)
echo "::error::openbb-charting ${VERSION} is already published on PyPI."
echo "Bump openbb_platform/obbject_extensions/charting/pyproject.toml [project].version and re-merge a fresh release branch."
exit 1
;;
404)
echo "openbb-charting ${VERSION} is not yet on PyPI — proceeding."
;;
*)
echo "::error::Unexpected status $STATUS from PyPI JSON API. Aborting to avoid a silent skip."
exit 1
;;
esac
- name: Verify release branch name matches pyproject version
if: github.event_name == 'pull_request'
env:
VERSION: ${{ steps.parse.outputs.version }}
BRANCH: ${{ github.event.pull_request.head.ref }}
run: |
EXPECTED="release/openbb-charting-v${VERSION}"
if [ "$BRANCH" != "$EXPECTED" ]; then
echo "::error::Release branch '${BRANCH}' does not match pyproject version '${VERSION}'."
echo "Expected branch name: '${EXPECTED}'."
echo "Either rename the release branch or update [project].version in pyproject.toml so they agree."
exit 1
fi
echo "Branch '${BRANCH}' matches pyproject version '${VERSION}'."
build:
name: Build sdist + wheel
needs: version-check
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: openbb_platform/obbject_extensions/charting
steps:
- uses: actions/checkout@v6
with:
ref: develop
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- run: python -m pip install --upgrade pip uv
- name: Build sdist + wheel
run: uv build --no-sources
- uses: actions/upload-artifact@v4
with:
name: openbb-charting-dist
path: openbb_platform/obbject_extensions/charting/dist/
if-no-files-found: error
retention-days: 7
test:
name: Test (${{ matrix.os }} / Python ${{ matrix.python_version }})
needs: build
strategy:
fail-fast: false
matrix:
include:
# Linux: full sweep across every released Python.
- { os: ubuntu-latest, python_version: "3.10" }
- { os: ubuntu-latest, python_version: "3.11" }
- { os: ubuntu-latest, python_version: "3.12" }
- { os: ubuntu-latest, python_version: "3.13" }
- { os: ubuntu-latest, python_version: "3.14" }
# macOS + Windows: min and max supported.
- { os: macos-latest, python_version: "3.10" }
- { os: macos-latest, python_version: "3.14" }
- { os: windows-latest, python_version: "3.10" }
- { os: windows-latest, python_version: "3.14" }
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
working-directory: openbb_platform/obbject_extensions/charting
steps:
- uses: actions/checkout@v6
with:
ref: develop
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python_version }}
allow-prereleases: true
- uses: actions/download-artifact@v4
with:
name: openbb-charting-dist
path: openbb_platform/obbject_extensions/charting/dist
- run: python -m pip install --upgrade pip uv
- name: Install dependencies then openbb-charting wheel
# ``openbb-core>=2.0.0`` isn't on PyPI yet — install it from the sibling
# source tree first so the wheel's pinned deps resolve. ``--group dev``
# brings in pytest / pytest-cov.
run: |
uv pip install --system ../../core
uv pip install --system --group dev
uv pip install --system dist/openbb_charting-*.whl
- name: Test the installed wheel
# Remove the local ``openbb_charting/`` source so pytest imports the
# package from site-packages — i.e., the EXACT wheel that's about to be
# uploaded to PyPI.
run: |
rm -rf openbb_charting
pytest tests --cov=openbb_charting --cov-report=term-missing --cov-fail-under=100
publish:
name: Publish to PyPI
needs: test
runs-on: ubuntu-latest
permissions:
id-token: write # required for PyPI OIDC trusted publishing
steps:
- uses: actions/download-artifact@v4
with:
name: openbb-charting-dist
path: dist
- uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist

View File

@@ -49,10 +49,9 @@ jobs:
- { os: ubuntu-latest, python_version: "3.12" }
- { os: ubuntu-latest, python_version: "3.13" }
- { os: ubuntu-latest, python_version: "3.14" }
- { os: ubuntu-latest, python_version: "3.15" }
# macOS and Windows: min and max supported.
- { os: macos-latest, python_version: "3.10" }
- { os: macos-latest, python_version: "3.15" }
- { os: macos-latest, python_version: "3.14" }
- { os: windows-latest, python_version: "3.10" }
- { os: windows-latest, python_version: "3.14" }
steps:

View File

@@ -0,0 +1,129 @@
name: 🧪 test openbb-charting
on:
pull_request:
branches: [develop, main, v5]
paths:
- "openbb_platform/obbject_extensions/charting/**"
- ".github/workflows/test-unit-openbb-charting.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
# Cancel in-flight matrix runs when a new commit lands on the same PR.
group: test-openbb-charting-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build sdist + wheel
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: openbb_platform/obbject_extensions/charting
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- run: python -m pip install --upgrade pip uv
- name: Build sdist + wheel
run: uv build --no-sources
- uses: actions/upload-artifact@v5
with:
name: openbb-charting-dist
path: openbb_platform/obbject_extensions/charting/dist/
if-no-files-found: error
retention-days: 7
lint:
name: Lint (ruff + ty)
needs: build
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: openbb_platform/obbject_extensions/charting
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- run: python -m pip install --upgrade pip uv
- uses: actions/download-artifact@v5
with:
name: openbb-charting-dist
path: openbb_platform/obbject_extensions/charting/dist
- name: Install dependencies then openbb-charting wheel
run: |
uv pip install --system ../../core
uv pip install --system --group dev
uv pip install --system dist/openbb_charting-*.whl
- name: ruff check
run: ruff check openbb_charting tests
- name: ruff format --check
run: ruff format --check openbb_charting tests
- name: ty check
run: ty check openbb_charting
test:
name: Test (${{ matrix.os }} / Python ${{ matrix.python_version }})
needs: build
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, python_version: "3.10" }
- { os: ubuntu-latest, python_version: "3.11" }
- { os: ubuntu-latest, python_version: "3.12" }
- { os: ubuntu-latest, python_version: "3.13" }
- { os: ubuntu-latest, python_version: "3.14" }
- { os: macos-latest, python_version: "3.10" }
- { os: macos-latest, python_version: "3.14" }
- { os: windows-latest, python_version: "3.10" }
- { os: windows-latest, python_version: "3.14" }
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
working-directory: openbb_platform/obbject_extensions/charting
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python_version }}
allow-prereleases: true
- uses: actions/download-artifact@v5
with:
name: openbb-charting-dist
path: openbb_platform/obbject_extensions/charting/dist
- run: python -m pip install --upgrade pip uv
- name: Install dependencies then openbb-charting wheel
run: |
uv pip install --system ../../core
uv pip install --system --group dev
uv pip install --system dist/openbb_charting-*.whl
- name: Test the installed wheel
run: |
rm -rf openbb_charting
pytest tests --cov=openbb_charting --cov-report=term-missing --cov-fail-under=100

View File

@@ -4,6 +4,7 @@ from collections.abc import Callable, Iterable, Mapping
from re import Pattern
from typing import (
Any,
cast,
)
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
@@ -65,8 +66,7 @@ class WordCompleter(Completer):
) -> Iterable[Completion]:
"""Get completions."""
words = self.words
if callable(words):
words = words() # ty: ignore[call-top-callable]
word_list = cast("list[str]", words if isinstance(words, list) else words())
if self.sentence:
word_before_cursor = document.text_before_cursor
@@ -97,7 +97,7 @@ class WordCompleter(Completer):
return word_before_cursor in word
return word.startswith(word_before_cursor)
for a in words:
for a in word_list:
if word_matches(a):
display = self.display_dict.get(a, a)
display_meta = self.meta_dict.get(a, "")

View File

@@ -269,7 +269,7 @@ class CLIController(BaseController):
bound_method = MethodType(method_call_command, self)
bound_method = update_wrapper(
partial(bound_method, router=router),
method_call_command, # ty: ignore[invalid-argument-type]
method_call_command,
)
setattr(self, f"call_{router}", bound_method)

View File

@@ -7,13 +7,14 @@ import random
import re
import shutil
import sqlite3
import subprocess
import sys
from contextlib import contextmanager
from datetime import (
datetime,
)
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from typing import Any
from zoneinfo import ZoneInfo, available_timezones
import numpy as np
@@ -27,11 +28,6 @@ from rich.table import Table
from openbb_cli.config.constants import AVAILABLE_FLAIRS, ENV_FILE_SETTINGS
from openbb_cli.session import Session
if TYPE_CHECKING:
from openbb_charting.core.openbb_figure import ( # ty: ignore[unresolved-import]
OpenBBFigure,
)
class SQLiteTable:
"""Lazy-loading wrapper for SQLite tables."""
@@ -857,7 +853,7 @@ def export_data( # noqa: PLR0912
func_name: str,
df: pd.DataFrame = pd.DataFrame(),
sheet_name: str | None = None,
figure: Optional["OpenBBFigure"] = None,
figure: Any = None,
margin: bool = True,
) -> None:
"""Export data to a file.
@@ -874,7 +870,7 @@ def export_data( # noqa: PLR0912
Dataframe of data to save.
sheet_name : str | None
The name of the sheet to save in excel file.
figure : OpenBBFigure | None
figure : Any
Figure object to save as image file.
margin : bool
Automatically adjust subplot parameters to give specified padding.
@@ -997,7 +993,9 @@ def export_data( # noqa: PLR0912
def system_clear():
"""Clear screen."""
os.system("cls||clear") # noqa: S605, S607
subprocess.run( # noqa: S602
"cls" if os.name == "nt" else "clear", shell=True, check=False
)
def request(
@@ -1070,7 +1068,7 @@ def handle_obbject_display(
):
"""Handle the display of an OBBject."""
df: pd.DataFrame = pd.DataFrame()
fig: OpenBBFigure | None = None
fig: Any = None
if isinstance(getattr(obbject, "results", None), SQLiteTable):
sqlite_tbl: SQLiteTable = obbject.results # ty: ignore[invalid-assignment]

View File

@@ -137,14 +137,36 @@ class Session(metaclass=SingletonMeta):
except Exception:
return None
@staticmethod
def _resolve_backend_class() -> Any:
"""Resolve the charting backend class, honoring overrides and the engine.
Prefers a backend registered under the ``openbb_charting_backend``
entry-point group, otherwise asks the resolved charting engine for its
built-in backend, so the CLI never depends on ``openbb_charting`` by name.
"""
from openbb_core.app.charting import (
ChartingManager,
get_charting_backend_class,
)
backend_class = get_charting_backend_class()
if backend_class is not None:
return backend_class
engine = ChartingManager.get_charting_class()
getter = getattr(engine, "get_backend_class", None) if engine else None
return getter() if callable(getter) else None
def _build_backend(self) -> Any:
try:
from openbb_charting.core.backend import ( # ty: ignore[unresolved-import]
Backend,
)
from openbb_core.app.model.charts.charting_settings import ChartingSettings
return Backend(
backend_class = self._resolve_backend_class()
if backend_class is None:
return None
return backend_class(
ChartingSettings(
system_settings=self._obb.system,
user_settings=self._obb.user,

View File

@@ -417,13 +417,16 @@ def test_request_preserves_user_supplied_user_agent(mock_session):
assert headers["User-Agent"] == "custom"
def test_system_clear_invokes_os_system():
"""``system_clear`` calls ``os.system`` with the cls/clear command."""
def test_system_clear_invokes_subprocess():
"""``system_clear`` runs the platform clear command via ``subprocess.run``."""
import os
from openbb_cli.controllers.utils import system_clear
with patch("openbb_cli.controllers.utils.os.system") as os_system:
with patch("openbb_cli.controllers.utils.subprocess.run") as run:
system_clear()
os_system.assert_called_once_with("cls||clear")
expected = "cls" if os.name == "nt" else "clear"
run.assert_called_once_with(expected, shell=True, check=False) # noqa: S604
def test_save_to_excel_creates_new_file(mock_session, tmp_path):

View File

@@ -162,6 +162,30 @@ def test_build_backend_returns_real_backend_on_success(session):
assert session._build_backend() is sentinel
def test_resolve_backend_class_prefers_registered_backend():
"""A backend registered via the entry-point group is returned directly."""
sentinel = object()
with patch(
"openbb_core.app.charting.get_charting_backend_class",
return_value=sentinel,
):
assert Session._resolve_backend_class() is sentinel
def test_build_backend_returns_none_when_no_backend_resolves(session):
"""``_build_backend`` returns None when no backend class resolves."""
import sys
with (
patch.dict(
sys.modules,
{"openbb_core.app.model.charts.charting_settings": MagicMock()},
),
patch.object(session, "_resolve_backend_class", return_value=None),
):
assert session._build_backend() is None
def test_build_prompt_session_swallows_failure(session):
"""Constructing PromptSession may fail (e.g. terminal init); session degrades to None."""
import sys

View File

@@ -24,3 +24,6 @@ exclude_lines =
if TYPE_CHECKING:
if __name__ == .__main__.:
raise NotImplementedError
^\s*\.\.\.$
@(abc\.)?abstractmethod
@(typing\.)?overload

View File

@@ -19,6 +19,7 @@ from starlette.responses import (
)
from typing_extensions import ParamSpec
from openbb_core.app.charting import ChartingManager
from openbb_core.app.command_runner import CommandRunner
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.app.model.command_context import CommandContext
@@ -30,18 +31,14 @@ from openbb_core.app.router import RouterLoader
from openbb_core.app.service.auth_service import AuthService
from openbb_core.app.service.system_service import SystemService
from openbb_core.app.service.user_service import UserService
from openbb_core.app.utils_optional import is_installed
from openbb_core.env import Env
from openbb_core.provider.utils.helpers import to_snake_case
CHARTING_INSTALLED = is_installed("openbb_charting")
Charting = None
if CHARTING_INSTALLED:
import importlib
Charting = getattr(
importlib.import_module("openbb_charting"), "Charting"
) # pragma: no cover
# Resolved through the charting manager so a drop-in engine override is honored
# instead of importing ``openbb_charting`` by name. ``Charting`` is the resolved
# engine accessor class (or ``None`` when no engine is installed).
Charting = ChartingManager.get_charting_class()
CHARTING_INSTALLED = Charting is not None
T = TypeVar("T")
P = ParamSpec("P")

View File

@@ -0,0 +1,44 @@
"""Engine-agnostic charting contracts and managers for the OpenBB Platform.
This package decouples the Platform interfaces (Python, API, CLI, MCP) from any
single charting implementation. Interfaces resolve the active charting engine
through :class:`ChartingManager` instead of importing ``openbb_charting``
directly, which lets a developer ship a drop-in replacement engine, lifecycle
hooks, and a rendering backend purely through entry points.
"""
from openbb_core.app.charting.abstract import (
AbstractChartingBackend,
ChartingExtension,
)
from openbb_core.app.charting.backend import (
CHARTING_BACKEND_GROUP,
get_charting_backend_class,
)
from openbb_core.app.charting.hooks import (
CHARTING_HOOKS_GROUP,
ChartingHook,
ChartingHooksManager,
ChartLifecycle,
HookContext,
get_hooks_manager,
)
from openbb_core.app.charting.manager import (
DEFAULT_CHARTING_ACCESSOR,
ChartingManager,
)
__all__ = [
"CHARTING_BACKEND_GROUP",
"CHARTING_HOOKS_GROUP",
"DEFAULT_CHARTING_ACCESSOR",
"AbstractChartingBackend",
"ChartLifecycle",
"ChartingExtension",
"ChartingHook",
"ChartingHooksManager",
"ChartingManager",
"HookContext",
"get_charting_backend_class",
"get_hooks_manager",
]

View File

@@ -0,0 +1,54 @@
"""Abstract contracts an OBBject charting engine and backend must satisfy."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
if TYPE_CHECKING:
from openbb_core.app.model.charts.chart import Chart
@runtime_checkable
class ChartingExtension(Protocol):
"""Surface a charting engine must expose to the Platform interfaces.
An engine is the OBBject accessor resolved by
:class:`~openbb_core.app.charting.manager.ChartingManager`. Implementing
this contract keeps every interface (Python, API, CLI, MCP) free of any
hard dependency on a specific charting package.
"""
@classmethod
def functions(cls) -> list[str]:
"""Return the route-derived names the engine can chart.
Names are the command route with slashes replaced by underscores and
the leading underscore stripped, e.g. ``equity_price_historical``.
"""
... # pragma: no cover - Protocol stub, never executed
def show(self, render: bool = True, **kwargs: Any) -> Chart | None:
"""Create the chart for the bound OBBject and store it on it."""
... # pragma: no cover - Protocol stub, never executed
@runtime_checkable
class AbstractChartingBackend(Protocol):
"""Surface a rendering backend must expose to charting engines.
A backend is resolved through the ``openbb_charting_backend`` entry-point
group (see :mod:`openbb_core.app.charting.backend`) and is constructed with
a single ``charting_settings`` argument, mirroring the reference engine.
"""
def __init__(self, charting_settings: Any) -> None:
"""Initialize the backend with the resolved charting settings."""
... # pragma: no cover - Protocol stub, never executed
def send_table(self, *args: Any, **kwargs: Any) -> Any:
"""Display an interactive table."""
... # pragma: no cover - Protocol stub, never executed
def send_url(self, *args: Any, **kwargs: Any) -> Any:
"""Display a URL."""
... # pragma: no cover - Protocol stub, never executed

View File

@@ -0,0 +1,53 @@
"""Resolve a charting rendering backend from entry points / configuration.
A backend is an opt-in override for the engine's built-in renderer (for example
the reference engine's PyWry-backed window). Register one under the
``openbb_charting_backend`` entry-point group::
[project.entry-points."openbb_charting_backend"]
my_backend = "my_pkg.backend:MyBackend"
The override is explicit: ``system_settings.charting_backend`` selects which
registered backend to use by entry-point name. Without a selection the engine
keeps its built-in backend.
"""
from __future__ import annotations
from importlib_metadata import entry_points
CHARTING_BACKEND_GROUP = "openbb_charting_backend"
def _configured_backend() -> str | None:
"""Return the configured backend override name, if any."""
from openbb_core.app.service.system_service import SystemService
return SystemService().system_settings.charting_backend
def get_charting_backend_class(name: str | None = None) -> type | None:
"""Return the selected charting backend class, or ``None`` to fall back.
Parameters
----------
name : str | None
Explicit entry-point name to resolve. Defaults to
``system_settings.charting_backend``. When neither is set, no override is
applied and the engine keeps its built-in backend.
Returns
-------
type | None
The backend class, or ``None`` when nothing is selected or the selected
name is not registered.
"""
target = name or _configured_backend()
if not target:
return None
entry_point = next(
(ep for ep in entry_points(group=CHARTING_BACKEND_GROUP) if ep.name == target),
None,
)
return entry_point.load() if entry_point is not None else None

View File

@@ -0,0 +1,184 @@
"""Chart lifecycle hooks shared across every Platform interface.
Hooks are discovered through the ``openbb_charting_hooks`` entry-point group and
dispatched inside the engine's chart creation, so they run identically whether a
chart is requested from the Python interface, the REST API, the CLI, or MCP.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from importlib_metadata import entry_points
CHARTING_HOOKS_GROUP = "openbb_charting_hooks"
class ChartLifecycle(str, Enum):
"""Stages a chart passes through during creation."""
RESOLVE_DATA = "resolve_data"
PRE_FIGURE = "pre_figure"
POST_FIGURE = "post_figure"
PRE_RENDER = "pre_render"
POST_RENDER = "post_render"
@dataclass
class HookContext:
"""Mutable state passed to lifecycle hooks.
A hook may mutate the relevant field(s) in place and return ``None``, or
return a (possibly new) ``HookContext`` to replace it for later hooks.
Parameters
----------
route : str
The command route the chart is created for, e.g. ``/equity/price/historical``.
stage : ChartLifecycle
The lifecycle stage currently dispatching.
data : Any
The resolved input data (available from ``RESOLVE_DATA`` onward).
figure : Any
The engine figure object (available from ``POST_FIGURE`` onward).
content : dict | None
The serialized chart content (available from ``POST_FIGURE`` onward).
settings : Any
The resolved ``ChartingSettings`` for the engine.
standard_params : dict
The command's standard parameters.
extra_params : dict
The command's provider/extra parameters.
provider : str | None
The provider the OBBject was created with.
extra : dict
The OBBject ``extra`` mapping.
kwargs : dict
Remaining keyword arguments passed to the chart function.
"""
route: str
stage: ChartLifecycle
data: Any = None
figure: Any = None
content: dict | None = None
settings: Any = None
standard_params: dict = field(default_factory=dict)
extra_params: dict = field(default_factory=dict)
provider: str | None = None
extra: dict = field(default_factory=dict)
kwargs: dict = field(default_factory=dict)
class ChartingHook:
"""Base class for a chart lifecycle hook.
Subclass and override the stage method(s) you care about, then register the
subclass under the ``openbb_charting_hooks`` entry-point group::
[project.entry-points."openbb_charting_hooks"]
my_hook = "my_pkg.hooks:MyHook"
Set ``routes`` to restrict the hook to specific command routes (empty means
all routes) and ``priority`` to control ordering (lower runs first).
"""
name: str = ""
routes: tuple[str, ...] = ()
priority: int = 100
def resolve_data(self, context: HookContext) -> HookContext | None:
"""Inspect or replace the input data before figure construction."""
def pre_figure(self, context: HookContext) -> HookContext | None:
"""Run immediately before the figure is built."""
def post_figure(self, context: HookContext) -> HookContext | None:
"""Inspect or mutate the figure and content after construction."""
def pre_render(self, context: HookContext) -> HookContext | None:
"""Run immediately before the figure is rendered by the backend."""
def post_render(self, context: HookContext) -> HookContext | None:
"""Run after the figure has been rendered."""
class ChartingHooksManager:
"""Discover and dispatch registered chart lifecycle hooks."""
def __init__(self, hooks: list[ChartingHook] | None = None) -> None:
"""Initialize the manager, loading hooks from entry points if not given.
Hooks are always ordered by ``priority`` (lower runs first), whether
loaded from entry points or supplied explicitly.
"""
loaded = hooks if hooks is not None else self._load_hooks()
self._hooks: list[ChartingHook] = sorted(
loaded, key=lambda hook: getattr(hook, "priority", 100)
)
@staticmethod
def _load_hooks() -> list[ChartingHook]:
"""Load and instantiate hooks from the entry-point group."""
hooks: list[ChartingHook] = []
for entry_point in entry_points(group=CHARTING_HOOKS_GROUP):
try:
obj = entry_point.load()
except Exception: # noqa: BLE001, S112
# A broken third-party hook must not break charting for everyone.
continue
hooks.append(obj() if isinstance(obj, type) else obj)
return hooks
@property
def hooks(self) -> list[ChartingHook]:
"""Return the ordered list of registered hooks."""
return self._hooks
def dispatch(self, stage: ChartLifecycle, context: HookContext) -> HookContext:
"""Run every hook registered for ``stage`` against ``context``.
Parameters
----------
stage : ChartLifecycle
The lifecycle stage to dispatch.
context : HookContext
The current context; mutated in place and/or replaced by hooks.
Returns
-------
HookContext
The resulting context after all applicable hooks have run.
"""
context.stage = stage
for hook in self._hooks:
routes = getattr(hook, "routes", ())
if routes and context.route not in routes:
continue
method = getattr(hook, stage.value, None)
if method is None:
continue
result = method(context)
if isinstance(result, HookContext):
context = result
return context
_HOOKS_MANAGER: ChartingHooksManager | None = None
def get_hooks_manager(reload: bool = False) -> ChartingHooksManager:
"""Return the process-wide hooks manager, loading hooks once.
Parameters
----------
reload : bool
Force a fresh scan of the entry-point group. Primarily for tests that
install hook packages after the first resolution.
"""
global _HOOKS_MANAGER # noqa: PLW0603
if _HOOKS_MANAGER is None or reload:
_HOOKS_MANAGER = ChartingHooksManager()
return _HOOKS_MANAGER

View File

@@ -0,0 +1,117 @@
"""Resolve the active OBBject charting engine for every Platform interface."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from openbb_core.app.charting.abstract import ChartingExtension
from openbb_core.app.model.extension import Extension
DEFAULT_CHARTING_ACCESSOR = "charting"
class ChartingManager:
"""Resolve and query the active charting engine.
The engine is the OBBject extension whose accessor name matches
``system_settings.charting_extension`` when set, otherwise the extension
registered under the default ``charting`` accessor. This indirection lets a
developer ship a drop-in replacement for ``openbb-charting`` without any
interface (Python, API, CLI, MCP) hard-coding the package name.
"""
@staticmethod
def _configured_name() -> str | None:
"""Return the configured charting extension override, if any."""
from openbb_core.app.service.system_service import SystemService
return SystemService().system_settings.charting_extension
@classmethod
def get_extension(cls) -> Extension | None:
"""Return the resolved charting ``Extension``, honoring the override.
Returns
-------
Extension | None
The resolved extension, or ``None`` when no charting engine is
installed (or the configured override cannot be found).
"""
from openbb_core.app.extension_loader import ExtensionLoader
objects = ExtensionLoader().obbject_objects
configured = cls._configured_name()
if configured:
if configured in objects:
return objects[configured]
return next(
(
ext
for ext in objects.values()
if getattr(ext, "name", None) == configured
),
None,
)
return next(
(
ext
for ext in objects.values()
if getattr(ext, "name", None) == DEFAULT_CHARTING_ACCESSOR
),
None,
)
@classmethod
def accessor_name(cls) -> str:
"""Return the OBBject accessor name the engine is registered under."""
ext = cls.get_extension()
return (
getattr(ext, "name", None)
or cls._configured_name()
or DEFAULT_CHARTING_ACCESSOR
)
@classmethod
def get_charting_class(cls) -> type[ChartingExtension] | None:
"""Return the engine accessor class (e.g. ``Charting``), if installed.
Class-level attribute access returns the registered accessor class
rather than instantiating it, so this is cheap and side-effect free.
"""
ext = cls.get_extension()
if ext is None:
return None
from openbb_core.app.model.obbject import OBBject
return getattr(OBBject, ext.name, None)
@classmethod
def is_installed(cls) -> bool:
"""Return whether a charting engine is available."""
return cls.get_extension() is not None
@classmethod
def functions(cls) -> list[str]:
"""Return the route names the resolved engine can chart."""
charting = cls.get_charting_class()
if charting is None or not hasattr(charting, "functions"):
return []
try:
return list(charting.functions())
except Exception: # noqa: BLE001
return []
@classmethod
def has_chart(cls, route: str) -> bool:
"""Return whether a command route has a registered chart view.
Parameters
----------
route : str
The command route, e.g. ``/equity/price/historical``.
"""
return route.replace("/", "_")[1:] in cls.functions()

View File

@@ -263,10 +263,14 @@ class StaticCommandRunner:
**kwargs,
) -> None:
"""Create a chart from the command output."""
from openbb_core.app.charting import ChartingManager
try:
if "charting" not in obbject.accessors:
accessor = ChartingManager.accessor_name()
if accessor not in obbject.accessors:
raise OpenBBError(
"Charting is not installed. Please install `openbb-charting`."
"No charting extension is installed. Install `openbb-charting`"
" or set `system_settings.charting_extension` to your engine."
)
# Here we will pop the chart_params kwargs and flatten them into the kwargs.
chart_params = {}
@@ -288,7 +292,7 @@ class StaticCommandRunner:
if chart_params:
kwargs.update(chart_params)
obbject.charting.show(render=False, **kwargs) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
getattr(obbject, accessor).show(render=False, **kwargs)
except Exception as e:
if Env().DEBUG_MODE:
raise OpenBBError(e) from e

View File

@@ -58,6 +58,8 @@ class SystemSettings(Tagged):
headless: bool = False
allow_mutable_extensions: bool = getattr(Env(), "ALLOW_MUTABLE_EXTENSIONS", False)
allow_on_command_output: bool = getattr(Env(), "ALLOW_ON_COMMAND_OUTPUT", False)
charting_extension: str | None = getattr(Env(), "CHARTING_EXTENSION", None)
charting_backend: str | None = getattr(Env(), "CHARTING_BACKEND", None)
model_config = ConfigDict(validate_assignment=True, frozen=True)

View File

@@ -23,6 +23,8 @@ class SystemService(metaclass=SingletonMeta):
"logging_suppress",
"allow_mutable_extensions",
"allow_on_command_output",
"charting_extension",
"charting_backend",
}
PRO_VALIDATION_HASH = "300ac59fdcc8f899e0bc5c18cda8652220735da1a00e2af365efe9d8e5fe8306" # pragma: allowlist secret

View File

@@ -29,9 +29,10 @@ if TYPE_CHECKING:
from pandas import DataFrame, Series # noqa
from openbb_core.provider.abstract.data import Data # noqa
from importlib.util import find_spec
from openbb_core.app.charting import ChartingManager
CHARTING_INSTALLED = find_spec("openbb_charting") is not None
# Resolved through the charting manager so a drop-in engine override is honored
CHARTING_INSTALLED = ChartingManager.is_installed()
try:
_HAS_FCNTL = True

View File

@@ -31,10 +31,6 @@ if TYPE_CHECKING:
from pandas import DataFrame, Series # noqa
from openbb_core.provider.abstract.data import Data # noqa
from importlib.util import find_spec
CHARTING_INSTALLED = find_spec("openbb_charting") is not None
try:
_HAS_FCNTL = True
except Exception: # noqa # pragma: no cover

View File

@@ -10,10 +10,6 @@ if TYPE_CHECKING:
from pandas import DataFrame, Series # noqa
from openbb_core.provider.abstract.data import Data # noqa
from importlib.util import find_spec
CHARTING_INSTALLED = find_spec("openbb_charting") is not None
try:
import fcntl

View File

@@ -17,10 +17,6 @@ if TYPE_CHECKING:
from pandas import DataFrame, Series # noqa
from openbb_core.provider.abstract.data import Data # noqa
from importlib.util import find_spec
CHARTING_INSTALLED = find_spec("openbb_charting") is not None
try:
_HAS_FCNTL = True
except Exception: # pragma: no cover # noqa

View File

@@ -38,12 +38,13 @@ if TYPE_CHECKING:
from pandas import DataFrame, Series # noqa
from openbb_core.provider.abstract.data import Data # noqa
try:
from openbb_charting import Charting # type: ignore
from openbb_core.app.charting import ChartingManager
CHARTING_INSTALLED = True # pragma: no cover
except ImportError: # pragma: no cover
CHARTING_INSTALLED = False
# Resolved through the charting manager so a drop-in engine override is honored
# instead of importing ``openbb_charting`` by name. ``Charting`` is the resolved
# engine accessor class (or ``None`` when no engine is installed).
Charting = ChartingManager.get_charting_class()
CHARTING_INSTALLED = Charting is not None
try:
_HAS_FCNTL = True
@@ -131,7 +132,7 @@ class MethodDefinition:
bool
True if charting is installed and exposes a matching function.
"""
if not CHARTING_INSTALLED:
if not CHARTING_INSTALLED or Charting is None:
return False
try:
return path.replace("/", "_")[1:] in Charting.functions()
@@ -668,7 +669,11 @@ class MethodDefinition:
path_params = PathHandler.extract_path_parameters(path)
# we need to add the chart parameter here bc of the docstring generation
if MethodDefinition._is_charting_command(path):
if (
CHARTING_INSTALLED
and Charting is not None
and path.replace("/", "_")[1:] in Charting.functions()
):
parameter_map["chart"] = Parameter(
name="chart",
kind=Parameter.POSITIONAL_OR_KEYWORD,
@@ -1341,7 +1346,11 @@ class MethodDefinition:
if dependency_calls:
code += "\n".join(dependency_calls) + "\n\n"
if MethodDefinition._is_charting_command(path):
if (
CHARTING_INSTALLED
and Charting is not None
and path.replace("/", "_")[1:] in Charting.functions()
):
parameter_map["chart"] = Parameter(
name="chart",
kind=Parameter.POSITIONAL_OR_KEYWORD,

View File

@@ -10,10 +10,6 @@ if TYPE_CHECKING:
from pandas import DataFrame, Series # noqa
from openbb_core.provider.abstract.data import Data # noqa
from importlib.util import find_spec
CHARTING_INSTALLED = find_spec("openbb_charting") is not None
try:
_HAS_FCNTL = True
except Exception: # pragma: no cover # noqa

View File

@@ -16,10 +16,6 @@ if TYPE_CHECKING:
from pandas import DataFrame, Series # noqa
from openbb_core.provider.abstract.data import Data # noqa
from importlib.util import find_spec
CHARTING_INSTALLED = find_spec("openbb_charting") is not None
try:
_HAS_FCNTL = True
except Exception: # pragma: no cover # noqa

View File

@@ -28,10 +28,6 @@ if TYPE_CHECKING:
from pandas import DataFrame, Series # noqa
from openbb_core.provider.abstract.data import Data # noqa
from importlib.util import find_spec
CHARTING_INSTALLED = find_spec("openbb_charting") is not None
try:
_HAS_FCNTL = True
except Exception: # noqa # pragma: no cover

View File

@@ -1,7 +1,6 @@
"""Targeted tests for ``openbb_core.api.router.commands`` helpers."""
import runpy
import sys
from inspect import signature
from typing import Annotated
from unittest.mock import MagicMock, patch
@@ -34,8 +33,8 @@ def test_build_new_annotation_map_includes_return():
assert out["return"] is bool
def test_commands_module_imports_charting_when_installed(monkeypatch):
from openbb_core.app import utils_optional
def test_commands_module_resolves_charting_engine(monkeypatch):
from openbb_core.app.charting import manager as charting_manager
class _Charting:
@staticmethod
@@ -44,10 +43,9 @@ def test_commands_module_imports_charting_when_installed(monkeypatch):
with pytest.MonkeyPatch.context() as m:
m.setattr(
utils_optional, "is_installed", lambda name: name == "openbb_charting"
)
m.setitem(
sys.modules, "openbb_charting", type("M", (), {"Charting": _Charting})()
charting_manager.ChartingManager,
"get_charting_class",
classmethod(lambda cls: _Charting),
)
module_ns = runpy.run_module(
"openbb_core.api.router.commands", run_name="__test_commands_charting__"

View File

@@ -0,0 +1 @@
"""Tests for the engine-agnostic charting package."""

View File

@@ -0,0 +1,233 @@
"""A real installable fixture extension for charting-resolution tests.
These tests exercise the *production* plugin-resolution path: one small but real
OpenBB extension package is written to disk, ``pip install``ed **once** for the
whole ``charting`` test package, and discovered through real entry points by the
real ``ExtensionLoader`` / ``importlib.metadata``. No service or entry point is
mocked — scenarios are driven through a real ``SystemSettings`` on the real
``SystemService`` singleton.
The fixture engine registers under its own accessor name (``fake_charting``), so
its mere presence never changes default resolution; tests opt into it via the
``charting_extension`` / ``charting_backend`` config.
"""
from __future__ import annotations
import contextlib
import importlib
import subprocess
import sys
import pytest
from openbb_core.app.extension_loader import ExtensionLoader
from openbb_core.app.model.abstract.singleton import SingletonMeta
from openbb_core.app.model.obbject import OBBject
from openbb_core.app.model.system_settings import SystemSettings
from openbb_core.app.service.system_service import SystemService
PACKAGE_NAME = "openbb_charting_fake"
DIST_NAME = "openbb-charting-fake"
ENGINE_ACCESSORS = ("fake_charting", "fake_boom_charting")
ENGINE_INIT = '''\
"""Real fixture charting engine extension."""
from openbb_core.app.model.extension import Extension
class FakeCharting:
"""Drop-in alternate charting engine accessor."""
def __init__(self, obbject):
self._obbject = obbject
@classmethod
def functions(cls):
return ["fake_alt_chart"]
@classmethod
def get_backend_class(cls):
from openbb_charting_fake.backends import PrimaryBackend
return PrimaryBackend
def show(self, render=True, **kwargs):
return None
class FakeBoomCharting:
"""Engine whose ``functions()`` raises, to exercise error handling."""
def __init__(self, obbject):
self._obbject = obbject
@classmethod
def functions(cls):
raise RuntimeError("functions boom")
def show(self, render=True, **kwargs):
return None
engine_ext = Extension(name="fake_charting", description="fake engine")
FakeCharting = engine_ext.obbject_accessor(FakeCharting)
boom_ext = Extension(name="fake_boom_charting", description="fake boom engine")
FakeBoomCharting = boom_ext.obbject_accessor(FakeBoomCharting)
'''
ENGINE_HOOK = '''\
"""Real lifecycle hook for the fixture extension."""
from openbb_core.app.charting import ChartingHook
class RecordingHook(ChartingHook):
"""Patches chart content so dispatch can be observed."""
name = "recording"
priority = 5
def post_figure(self, context):
context.content = {"hooked": True}
return context
'''
ENGINE_BACKENDS = '''\
"""Real rendering backends for the fixture extension."""
class PrimaryBackend:
"""Minimal backend constructed with charting settings."""
def __init__(self, charting_settings):
self.charting_settings = charting_settings
def send_table(self, *args, **kwargs):
return "table"
def send_url(self, *args, **kwargs):
return "url"
class SecondaryBackend:
"""A second backend, so config can disambiguate between several."""
def __init__(self, charting_settings):
self.charting_settings = charting_settings
def send_table(self, *args, **kwargs):
return "table"
def send_url(self, *args, **kwargs):
return "url"
'''
ENGINE_PYPROJECT = """\
[project]
name = "openbb-charting-fake"
version = "0.0.1"
requires-python = ">=3.10,<4"
dependencies = ["openbb-core"]
[project.entry-points."openbb_obbject_extension"]
fake_engine = "openbb_charting_fake:engine_ext"
fake_boom_engine = "openbb_charting_fake:boom_ext"
[project.entry-points."openbb_charting_hooks"]
fake_hook = "openbb_charting_fake.hook:RecordingHook"
fake_broken_hook = "openbb_charting_fake.hook:DoesNotExist"
[project.entry-points."openbb_charting_backend"]
fake_backend = "openbb_charting_fake.backends:PrimaryBackend"
fake_secondary_backend = "openbb_charting_fake.backends:SecondaryBackend"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["openbb_charting_fake"]
"""
def _pip(*args: str) -> None:
subprocess.run( # noqa: S603
[sys.executable, "-m", "pip", *args],
check=True,
capture_output=True,
text=True,
)
def _reset_extension_loader() -> None:
"""Drop the ExtensionLoader singleton so it re-scans real entry points."""
SingletonMeta._instances.pop(ExtensionLoader, None) # type: ignore[arg-type]
importlib.invalidate_caches()
@pytest.fixture(scope="package", autouse=True)
def fake_charting_extension(tmp_path_factory):
"""Build and install the real fixture package once for the whole package.
The fixture engine, hooks, and backends register through genuine entry
points; individual tests select them via ``charting_config``.
"""
work = tmp_path_factory.mktemp("charting_fixture")
pkg_dir = work / PACKAGE_NAME
pkg_dir.mkdir()
(pkg_dir / "__init__.py").write_text(ENGINE_INIT, encoding="utf-8")
(pkg_dir / "hook.py").write_text(ENGINE_HOOK, encoding="utf-8")
(pkg_dir / "backends.py").write_text(ENGINE_BACKENDS, encoding="utf-8")
(work / "pyproject.toml").write_text(ENGINE_PYPROJECT, encoding="utf-8")
pristine_accessors = set(OBBject.accessors)
src = str(work)
_pip("install", "--no-deps", "-e", src)
# An editable install added mid-session is not on the running interpreter's
# ``sys.path``; add it so ``entry_point.load()`` can import the package.
if src not in sys.path:
sys.path.insert(0, src)
_reset_extension_loader()
try:
yield {
"engine": "fake_engine",
"accessor": "fake_charting",
"boom_accessor": "fake_boom_charting",
"backend": "fake_backend",
"secondary_backend": "fake_secondary_backend",
}
finally:
with contextlib.suppress(subprocess.CalledProcessError):
_pip("uninstall", "-y", DIST_NAME)
with contextlib.suppress(ValueError):
sys.path.remove(src)
for name in [m for m in sys.modules if m.startswith(PACKAGE_NAME)]:
del sys.modules[name]
for accessor in ENGINE_ACCESSORS:
if accessor in OBBject.accessors and accessor not in pristine_accessors:
OBBject.accessors.discard(accessor)
with contextlib.suppress(AttributeError):
delattr(OBBject, accessor)
_reset_extension_loader()
@pytest.fixture
def charting_config():
"""Drive real charting config on the real SystemService singleton.
Yields a setter; the original ``SystemSettings`` is restored on teardown.
"""
service = SystemService()
original = service.system_settings
def _set(**overrides):
service.system_settings = SystemSettings(logging_suppress=True, **overrides)
try:
yield _set
finally:
service.system_settings = original

View File

@@ -0,0 +1,41 @@
"""Tests for charting backend override resolution.
Backends are discovered through real entry points from a genuine installed
package (see ``conftest.py``); the ``openbb_charting_backend`` group and the
``SystemService`` config are never mocked.
"""
from openbb_core.app.charting import get_charting_backend_class
class TestChartingBackend:
"""Resolve an opt-in rendering-backend override via real entry points."""
def test_no_selection_returns_none(self):
"""Without a configured selection, no override is applied."""
assert get_charting_backend_class() is None
def test_config_selection_by_name(self, charting_config):
"""``charting_backend`` selects a backend by entry-point name."""
charting_config(charting_backend="fake_backend")
assert get_charting_backend_class().__name__ == "PrimaryBackend"
def test_missing_config_returns_none(self, charting_config):
"""A configured backend name that isn't registered resolves to None."""
charting_config(charting_backend="does_not_exist")
assert get_charting_backend_class() is None
def test_config_disambiguates_between_backends(self, charting_config):
"""Config selects one of several registered backends by name."""
charting_config(charting_backend="fake_secondary_backend")
assert get_charting_backend_class().__name__ == "SecondaryBackend"
def test_explicit_name_argument_selects_backend(self):
"""An explicit name argument selects among registered backends."""
assert (
get_charting_backend_class(name="fake_secondary_backend").__name__
== "SecondaryBackend"
)
assert (
get_charting_backend_class(name="fake_backend").__name__ == "PrimaryBackend"
)

View File

@@ -0,0 +1,160 @@
"""Tests for chart lifecycle hooks: dispatch and real entry-point discovery.
Dispatch is driven with real ``ChartingHook`` objects through the public
``ChartingHooksManager`` constructor. Discovery is driven through a genuine
installed package (see ``conftest.py``); ``entry_points`` is never mocked.
"""
from openbb_core.app.charting import get_hooks_manager
from openbb_core.app.charting.hooks import (
ChartingHook,
ChartingHooksManager,
ChartLifecycle,
HookContext,
)
ALL_STAGES = list(ChartLifecycle)
class _RecordingHook(ChartingHook):
name = "recording"
priority = 10
def __init__(self):
self.seen: list[tuple[str, str]] = []
def pre_figure(self, context):
self.seen.append(("pre_figure", context.route))
def post_figure(self, context):
self.seen.append(("post_figure", context.route))
context.content = {"patched": True}
return context
class _ScopedHook(ChartingHook):
name = "scoped"
routes = ("/only/this",)
def __init__(self):
self.fired = False
def pre_figure(self, context):
self.fired = True
class TestChartingHookDispatch:
"""Dispatch ordering, scoping, and context handling with real hooks."""
def test_dispatch_runs_matching_stage_methods(self):
hook = _RecordingHook()
manager = ChartingHooksManager(hooks=[hook])
ctx = HookContext(
route="/equity/price/historical", stage=ChartLifecycle.PRE_FIGURE
)
ctx = manager.dispatch(ChartLifecycle.PRE_FIGURE, ctx)
ctx = manager.dispatch(ChartLifecycle.POST_FIGURE, ctx)
assert hook.seen == [
("pre_figure", "/equity/price/historical"),
("post_figure", "/equity/price/historical"),
]
# A returned context replaces the running one.
assert ctx.content == {"patched": True}
assert ctx.stage is ChartLifecycle.POST_FIGURE
def test_route_scoped_hook_only_fires_for_its_route(self):
scoped = _ScopedHook()
manager = ChartingHooksManager(hooks=[scoped])
manager.dispatch(
ChartLifecycle.PRE_FIGURE,
HookContext(route="/other/route", stage=ChartLifecycle.PRE_FIGURE),
)
assert scoped.fired is False
manager.dispatch(
ChartLifecycle.PRE_FIGURE,
HookContext(route="/only/this", stage=ChartLifecycle.PRE_FIGURE),
)
assert scoped.fired is True
def test_hooks_run_in_priority_order(self):
order: list[str] = []
class _First(ChartingHook):
priority = 1
def pre_figure(self, context):
order.append("first")
class _Second(ChartingHook):
priority = 99
def pre_figure(self, context):
order.append("second")
manager = ChartingHooksManager(hooks=[_Second(), _First()])
manager.dispatch(
ChartLifecycle.PRE_FIGURE,
HookContext(route="/r", stage=ChartLifecycle.PRE_FIGURE),
)
assert order == ["first", "second"]
def test_base_hook_methods_are_noops_for_every_stage(self):
"""A hook using the base implementations runs (and no-ops) at every stage."""
class _BaseHook(ChartingHook):
pass
manager = ChartingHooksManager(hooks=[_BaseHook()])
ctx = HookContext(route="/r", stage=ChartLifecycle.RESOLVE_DATA)
for stage in ALL_STAGES:
# Base methods return None, so the same context object is preserved.
assert manager.dispatch(stage, ctx) is ctx
def test_duck_typed_hook_without_stage_method_is_skipped(self):
"""A hook object lacking the dispatched stage method is skipped cleanly."""
class _PartialHook:
priority = 50
def pre_figure(self, context):
context.data = "touched"
return context
# Deliberately no ``post_render`` attribute at all.
manager = ChartingHooksManager(hooks=[_PartialHook()])
ctx = HookContext(route="/r", stage=ChartLifecycle.POST_RENDER)
# ``getattr(hook, "post_render", None)`` is None -> the hook is skipped.
assert manager.dispatch(ChartLifecycle.POST_RENDER, ctx) is ctx
# The stage method it does define still runs for its own stage.
assert manager.dispatch(ChartLifecycle.PRE_FIGURE, ctx).data == "touched"
class TestChartingHookDiscovery:
"""Discovery of hooks from the real installed extension package."""
def test_hook_discovered_and_dispatched(self):
"""A hook registered via entry points is discovered and fires.
The fixture also registers a deliberately broken hook entry point, so
this exercises the resilient load path too.
"""
manager = get_hooks_manager(reload=True)
assert any(type(h).__name__ == "RecordingHook" for h in manager.hooks)
ctx = HookContext(
route="/equity/price/historical", stage=ChartLifecycle.POST_FIGURE
)
ctx = manager.dispatch(ChartLifecycle.POST_FIGURE, ctx)
assert ctx.content == {"hooked": True}
def test_hooks_manager_is_cached(self):
"""``get_hooks_manager`` returns the same instance until reloaded."""
first = get_hooks_manager(reload=True)
assert get_hooks_manager() is first
assert get_hooks_manager(reload=True) is not first

View File

@@ -0,0 +1,66 @@
"""Tests for ``ChartingManager`` engine resolution.
Every assertion goes through the real ``ExtensionLoader`` / ``importlib.metadata``
discovery path and a real ``SystemService``. The fixture engine package is
installed once for this test package (see ``conftest.py``); nothing is mocked.
"""
from openbb_core.app.charting import ChartingManager
class TestChartingManager:
"""Resolve the active charting engine, with and without overrides."""
def test_default_resolution_targets_charting_accessor(self):
"""With no override, resolution targets the canonical ``charting`` accessor.
The fixture engines register under other accessor names, so a default
(no-config) resolution returns an engine only when a real ``charting``
engine is installed. The accessor-name contract holds either way.
"""
assert ChartingManager.accessor_name() == "charting"
ext = ChartingManager.get_extension()
if ext is not None:
assert ext.name == "charting"
assert isinstance(ChartingManager.is_installed(), bool)
assert isinstance(ChartingManager.has_chart("/equity/price/historical"), bool)
def test_override_by_entry_point_name(self, charting_config):
"""``charting_extension`` may name the alternate engine's entry point."""
charting_config(charting_extension="fake_engine")
ext = ChartingManager.get_extension()
assert ext is not None
assert ext.name == "fake_charting"
assert ChartingManager.accessor_name() == "fake_charting"
engine = ChartingManager.get_charting_class()
assert engine.__name__ == "FakeCharting"
assert ChartingManager.functions() == ["fake_alt_chart"]
assert ChartingManager.has_chart("/fake/alt/chart") is True
def test_override_by_accessor_name(self, charting_config):
"""``charting_extension`` may name the alternate engine's accessor."""
charting_config(charting_extension="fake_charting")
engine = ChartingManager.get_charting_class()
assert engine.__name__ == "FakeCharting"
def test_unknown_override_resolves_to_none(self, charting_config):
"""An override that matches nothing yields no engine."""
charting_config(charting_extension="does_not_exist")
assert ChartingManager.get_extension() is None
assert ChartingManager.is_installed() is False
assert ChartingManager.get_charting_class() is None
assert ChartingManager.functions() == []
assert ChartingManager.has_chart("/equity/price/historical") is False
def test_engine_functions_error_is_swallowed(self, charting_config):
"""A failing engine ``functions()`` resolves to an empty list, not a crash."""
charting_config(charting_extension="fake_boom_charting")
engine = ChartingManager.get_charting_class()
assert engine.__name__ == "FakeBoomCharting"
assert ChartingManager.functions() == []
assert ChartingManager.has_chart("/anything") is False

View File

@@ -460,17 +460,25 @@ def test_static_command_runner_chart():
OBBject.accessors.discard("charting")
def test_static_command_runner_chart_raises_when_charting_not_installed():
def test_static_command_runner_chart_raises_when_charting_not_installed(monkeypatch):
"""``_chart`` raises ``OpenBBError`` when the charting accessor is not registered."""
from openbb_core.app.charting.manager import ChartingManager
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.env import Env
# Resolve to an accessor guaranteed not registered, so the test is
# deterministic whether or not a charting engine is installed.
monkeypatch.setattr(
ChartingManager,
"accessor_name",
classmethod(lambda cls: "not_a_charting_accessor"),
)
obbject = OBBject(results=[{"x": 1}], provider="mock_provider")
# Force DEBUG_MODE so the suppressed exception is re-raised
with (
patch.object(Env, "DEBUG_MODE", new=True),
pytest.raises(OpenBBError, match="Charting is not installed"),
pytest.raises(OpenBBError, match="No charting extension is installed"),
):
StaticCommandRunner._chart(obbject)
@@ -725,10 +733,17 @@ def test_chart_merges_nested_kwargs_chart_params():
OBBject.accessors.discard("charting")
def test_chart_warns_in_non_debug_mode_on_failure():
def test_chart_warns_in_non_debug_mode_on_failure(monkeypatch):
"""Without DEBUG_MODE, a chart failure should be downgraded to an OpenBBWarning."""
from openbb_core.app.charting.manager import ChartingManager
monkeypatch.setattr(
ChartingManager,
"accessor_name",
classmethod(lambda cls: "not_a_charting_accessor"),
)
obb = OBBject(results=[{"x": 1}], provider="mock")
with pytest.warns(OpenBBWarning, match="Charting is not installed"):
with pytest.warns(OpenBBWarning, match="No charting extension is installed"):
StaticCommandRunner._chart(obb)

View File

@@ -1,157 +1,106 @@
# OpenBB Charting extension
# OpenBB Charting
This extension provides a charting library for OpenBB Platform.
The library includes:
- a charting infrastructure based on Plotly
- 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 which means you'll have the functionality it exposes on every command result.
Plotly-based charting for the OpenBB Platform. Installing this package registers a `charting` accessor on every `OBBject` command result and enables the `chart=True` argument on Platform endpoints that have a chart.
## Installation
To install the extension, run the following command in this folder:
```bash
pip install openbb-charting
```
To enable display in a native OS window, install the PyWry extra.
To display charts in a native desktop window, install the PyWry extra:
```bash
pip install "openbb-charting[pywry]"
```
### PyWry dependency on Linux
On Linux, PyWry requires system WebKit/GTK libraries:
When using Linux distributions, the PyWry dependency requires certain dependencies to be installed first.
- Debian-based / Ubuntu / Mint:
`sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev`
- Arch Linux / Manjaro:
`sudo pacman -S webkit2gtk`
- Fedora:
`sudo dnf install gtk3-devel webkit2gtk3-devel`
- Debian / Ubuntu / Mint: `sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev`
- Arch / Manjaro: `sudo pacman -S webkit2gtk`
- Fedora: `sudo dnf install gtk3-devel webkit2gtk3-devel`
## Usage
To use the extension, run any of the OpenBB Platform endpoints with the `chart` argument set to `True`.
Here's an example of how it would look like in a python interface:
Pass `chart=True` to an endpoint that has a chart, then call `show()` on the result:
```python
from openbb import obb
equity_data = obb.equity.price.historical(symbol="TSLA", chart=True)
data = obb.equity.price.historical("AAPL", provider="yfinance", chart=True)
data.show()
```
This results in a `OBBject` object containing a `chart` attribute, which contains Plotly JSON data.
In order to display the chart, you need to call the `show()` method:
The same chart is reachable through the `charting` accessor on any result, without `chart=True`:
```python
equity_data.show()
```
Alternatively, `openbb-charting` is an `OBBject` accessor and its methods can be used directly from any instance.
```python
from openbb import obb
res = obb.equity.price.historical("AAPL")
res = obb.equity.price.historical("AAPL", provider="yfinance")
res.charting.show()
```
The above code will produce the same effect as the previous example.
### Which commands have a chart
## Custom Charts and Data
Various types of charts can be generated from Pandas DataFrames without needing to understand the Plotly library.
The methods are exposed as part of the `OBBject` accessor class. The `data` parameter can be passed as a list object from `OBBject.results`, or any Pandas DataFrame instance.
```python
res = obb.equity.price.historical("AAPL", chart=True)
surface_3d = res.charting.create_3d_surface
bar_chart = res.charting.create_bar_chart
line_chart = res.charting.create_line_chart
correlation_matrix = res.charting.create_correlation_matrix
```
### Discovering available charts
Not all the endpoints have dedicated charts. To discover which endpoints are supported, you can run the following command:
Charts are contributed by the installed extensions. List the registered routes:
```python
from openbb_charting import Charting
Charting.functions()
```
### Using the `to_chart` method
### Technical-analysis overlays — `to_chart`
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.
`to_chart` rebuilds the chart of a time-series (OHLCV) result with indicator overlays. Indicators and their parameters are passed through the `indicators` argument:
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),
```python
res = obb.equity.price.historical("AAPL", provider="yfinance")
res.charting.to_chart(
indicators=dict(
sma=dict(length=[20, 50]),
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})
)
res.show()
```
```
List every available indicator and its parameters:
- Get all the available indicators
```python
res.charting.indicators() # from a result
Charting.indicators() # standalone
```
```python
## Building charts directly from data
# if you have a command result already
res.charting.indicators
The accessor exposes builders that accept a DataFrame or a list of `Data` (such as `OBBject.results`) and return an `OpenBBFigure`. Their input signatures differ; `create_line_chart` and `create_bar_chart` also display the chart by default — pass `render=False` to build it without displaying.
# or if you want to know in standalone fashion
from openbb_charting import Charting
Charting.indicators()
```python
res = obb.equity.price.historical("AAPL", provider="yfinance")
df = res.to_dataframe()
```
fig = res.charting.create_line_chart(data=res.results, target="close", render=False)
fig = res.charting.create_bar_chart(data=res.results, x="date", y="volume", render=False)
fig = res.charting.create_correlation_matrix(data=res.results, method="pearson")
fig = res.charting.create_3d_surface(X=df["open"], Y=df["high"], Z=df["close"])
fig.show()
```
## Add a visualization to an existing Platform command
## Adding a chart to a command
To add a visualization to an existing command, you'll need to add a `poetry` plugin to your `pyproject.toml` file. The syntax should be the following:
Register a charting-view class through the `openbb_charting_extension` entry-point group in your `pyproject.toml`:
```toml
[tool.poetry.plugins."openbb_charting_extension"]
[project.entry-points."openbb_charting_extension"]
my_extension = "openbb_my_extension.my_extension_views:MyExtensionViews"
```
Where the `openbb_charting_extension` is **mandatory**, otherwise the charting extension won't be able to find the visualization.
And the suggested structure for the `my_extension_views` module is the following:
A view method is matched to an endpoint by replacing the route's slashes with underscores: `/equity/price/historical` → `equity_price_historical`, `/technical/ema` → `technical_ema`. Each method receives the command output as `**kwargs` and returns a `tuple[OpenBBFigure, dict[str, Any]]` — the interactive figure plus the JSON content the API serializes.
```python
"""Views for MyExtension."""
from typing import Any, Dict, Tuple
from typing import Any
from openbb_charting.charts.price_historical import price_historical
from openbb_charting.core.openbb_figure import OpenBBFigure
@@ -161,31 +110,61 @@ class MyExtensionViews:
"""MyExtension Views."""
@staticmethod
def my_extension_price_historical(
**kwargs,
) -> Tuple[OpenBBFigure, Dict[str, Any]]:
"""MyExtension Price Historical Chart."""
def my_extension_price_historical(**kwargs) -> tuple[OpenBBFigure, dict[str, Any]]:
"""My Extension Price Historical Chart."""
return price_historical(**kwargs)
```
> Note that `my_extension_views` lives under the `openbb_my_extension` package.
The chart is then produced by setting `chart=True` on the command, or via `result.charting.show()`.
Afterwards, you'll need to add the visualization to your new `MyExtensionViews` class. The convention to match the endpoint with the respective charting function is the following:
## Replacing the engine, hooks, and backend
- `/equity/price/historical` -> `equity_price_historical`
- `/technical/ema` -> `technical_ema`
- `/my_extension/price_historical` -> `my_extension_price_historical`
The Platform interfaces (Python, API, CLI, MCP) do not depend on `openbb-charting` by name; they resolve the active engine from whichever OBBject extension registers the `charting` accessor. Three things are pluggable through entry points alone.
When you spot the charting function on the charting router file, you can add the visualization to it.
### Replace the engine
The implementation should leverage the already existing classes and methods to do so, namely:
Ship an OBBject extension that registers the `charting` accessor:
- `OpenBBFigure`
- `PlotlyTA`
```toml
[project.entry-points."openbb_obbject_extension"]
my_charting = "openbb_my_charting:ext" # ext = Extension(name="charting")
```
Note that the return of each charting function should respect the already defined return types: `Tuple[OpenBBFigure, Dict[str, Any]]`.
If `openbb-charting` is uninstalled, your engine is used automatically. When both are installed, select one with `system_settings.charting_extension` (or the `OPENBB_CHARTING_EXTENSION` environment variable). An engine must expose the `functions()` classmethod, and `get_backend_class()` to be usable from the CLI.
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.
### Chart lifecycle hooks
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`.
Or accessing the `charting` attribute of any returned `OBBject` object: `my_obbject.charting.show()`.
Register a `ChartingHook` subclass under the `openbb_charting_hooks` group. Hooks run inside chart creation, so they fire for every interface:
```toml
[project.entry-points."openbb_charting_hooks"]
my_hook = "openbb_my_pkg.hooks:MyHook"
```
```python
from openbb_core.app.charting import ChartingHook
class MyHook(ChartingHook):
"""Watermark every figure after it is built."""
routes = () # empty matches all routes
priority = 100 # lower runs first
def post_figure(self, context):
context.figure.add_annotation(text="INTERNAL", opacity=0.1)
return context
```
Stages: `resolve_data`, `pre_figure`, `post_figure`, `pre_render`, `post_render`. A hook mutates `context` in place and/or returns a new one.
### Backend override
Swap the rendering backend (PyWry / browser / custom) without replacing the engine, via the `openbb_charting_backend` group:
```toml
[project.entry-points."openbb_charting_backend"]
my_backend = "openbb_my_pkg.backend:MyBackend"
```
A backend is constructed with a single `charting_settings` argument. The override is opt-in: set `system_settings.charting_backend` (or `OPENBB_CHARTING_BACKEND`) to the registered name. Without a selection, the engine keeps its built-in backend.

View File

@@ -0,0 +1 @@
"""Charting integration test suite."""

View File

@@ -0,0 +1,166 @@
"""Deterministic, self-contained fixtures for the charting integration suite."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from openbb_core.app.model.obbject import OBBject
from openbb_core.provider.abstract.data import Data
from openbb_charting.charting import Charting
from openbb_charting.core.backend import Backend
# pylint: disable=protected-access
def _ohlcv(seed: int, periods: int = 120, symbol: str | None = None) -> pd.DataFrame:
"""Build a deterministic OHLCV frame with a string ``date`` column."""
rng = np.random.default_rng(seed)
idx = pd.date_range("2023-01-01", periods=periods, freq="D")
close = 100 + np.cumsum(rng.normal(0, 1, periods))
frame = pd.DataFrame(
{
"date": idx.strftime("%Y-%m-%d"),
"open": close + rng.normal(0, 0.5, periods),
"high": np.maximum(close, close + 1),
"low": np.minimum(close, close - 1),
"close": close,
"volume": rng.integers(1_000_000, 5_000_000, periods),
}
)
if symbol is not None:
frame["symbol"] = symbol
return frame
def _records(frame: pd.DataFrame) -> list[Data]:
"""Convert a frame to a list of real ``Data`` records."""
return [Data.model_validate(row) for row in frame.to_dict(orient="records")]
def ohlcv_single() -> list[Data]:
"""Single-symbol OHLCV records (candlestick path)."""
return _records(_ohlcv(7))
def ohlcv_multi() -> list[Data]:
"""Long-format OHLCV records for three symbols (multi-series line path)."""
frames = [_ohlcv(i, symbol=sym) for i, sym in enumerate(("AAA", "BBB", "CCC"))]
return _records(pd.concat(frames, ignore_index=True))
_PERF_COLS = (
"one_day",
"one_week",
"one_month",
"three_month",
"six_month",
"ytd",
"one_year",
"two_year",
"three_year",
"four_year",
"five_year",
)
def performance_records() -> list[Data]:
"""Two-symbol price-performance records (bar path)."""
rows = []
for sym, sign in (("AAA", 1.0), ("BBB", -1.0)):
values = {
col: round(sign * (i + 1) / 100, 4) for i, col in enumerate(_PERF_COLS)
}
rows.append({"symbol": sym, **values})
return [Data.model_validate(row) for row in rows]
def long_close_records() -> list[Data]:
"""Long-format ``date``/``symbol``/``close`` records (correlation path)."""
rng = np.random.default_rng(0)
idx = pd.date_range("2023-01-01", periods=60, freq="D")
rows = []
for sym in ("AAA", "BBB", "CCC"):
close = 100 + np.cumsum(rng.normal(0, 1, 60))
for stamp, value in zip(idx, close):
rows.append(
{
"date": stamp.strftime("%Y-%m-%d"),
"symbol": sym,
"close": float(value),
}
)
return [Data.model_validate(row) for row in rows]
CASES = [
("single_symbol_candlestick", "/equity/price/historical", ohlcv_single, {}),
("multi_symbol_line", "/equity/price/historical", ohlcv_multi, {"target": "close"}),
(
"sma_indicator_overlay",
"/equity/price/historical",
ohlcv_single,
{"indicators": {"sma": {"length": [20]}}},
),
("price_performance_bar", "/equity/price/performance", performance_records, {}),
(
"correlation_matrix_heatmap",
"/econometrics/correlation_matrix",
long_close_records,
{},
),
]
class IntegrationViews:
"""A fixed, known charting view registered for the integration suite."""
@staticmethod
def equity_price_historical(**kwargs):
"""Delegate to the real price-historical builder."""
from openbb_charting.charts.price_historical import price_historical
return price_historical(**kwargs)
@staticmethod
def equity_price_performance(**kwargs):
"""Delegate to the real price-performance builder."""
from openbb_charting.charts.price_performance import price_performance
return price_performance(**kwargs)
@staticmethod
def econometrics_correlation_matrix(**kwargs):
"""Delegate to the real correlation-matrix builder."""
from openbb_charting.charts.correlation_matrix import correlation_matrix
return correlation_matrix(**kwargs)
@pytest.fixture(autouse=True)
def known_views():
"""Register the fixed, known view class for the duration of each test."""
original = Charting._extension_views_cache
Charting._extension_views_cache = [IntegrationViews]
try:
yield
finally:
Charting._extension_views_cache = original
@pytest.fixture(autouse=True)
def reset_backend():
"""Reset the ``Backend`` singleton around each test to avoid GUI/state leakage."""
Backend.instance = None
yield
Backend.instance = None
def make_obbject(route: str, results: list[Data], **standard_params) -> OBBject:
"""Build a real ``OBBject`` shaped as the given command route would return it."""
obj = OBBject(results=results, provider="test")
obj._route = route
obj._standard_params = standard_params or {}
obj._extra_params = {}
return obj

View File

@@ -1,820 +1,29 @@
"""Test charting extension."""
"""Deterministic integration tests for the charting extension (in-process)."""
from __future__ import annotations
import pytest
from openbb_charting.core.openbb_figure import OpenBBFigure
from openbb_core.app.model.obbject import OBBject
from .conftest import CASES, make_obbject
# pylint:disable=inconsistent-return-statements
@pytest.fixture(scope="session")
def obb(pytestconfig):
"""Fixture to setup obb."""
if pytestconfig.getoption("markexpr") != "not integration":
import openbb # pylint:disable=import-outside-toplevel
return openbb.obb
# pylint:disable=redefined-outer-name
data: dict = {}
def get_equity_data():
"""Get equity data."""
import openbb # pylint:disable=import-outside-toplevel
if "stocks_data" in data:
return data["stocks_data"]
symbol = "AAPL"
provider = "fmp"
data["stocks_data"] = openbb.obb.equity.price.historical(
symbol=symbol, provider=provider
).results
return data["stocks_data"]
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "yfinance",
"symbol": "AAPL",
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_equity_price_historical(params, obb):
"""Test chart equity price historical."""
result = obb.equity.price.historical(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "yfinance",
"symbol": "JPYUSD",
"chart": True,
}
),
],
"name, route, builder, chart_kwargs",
CASES,
ids=[case[0] for case in CASES],
)
@pytest.mark.integration
def test_charting_currency_price_historical(params, obb):
"""Test chart currency price historical."""
result = obb.currency.price.historical(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "yfinance",
"symbol": "BTCUSD",
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_crypto_price_historical(params, obb):
"""Test chart crypto price historical."""
result = obb.crypto.price.historical(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "yfinance",
"symbol": "NDX",
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_index_price_historical(params, obb):
"""Test chart index price historical."""
result = obb.index.price.historical(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "yfinance",
"symbol": "QQQ",
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_etf_historical(params, obb):
"""Test chart etf historical."""
result = obb.etf.historical(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"index": "date",
"length": "60",
"scalar": "90.0",
"drift": "2",
"chart": "True",
}
),
],
)
@pytest.mark.integration
def test_charting_technical_adx(params, obb):
"""Test chart ta adx."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_equity_data()
result = obb.technical.adx(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"index": "date",
"length": "30",
"scalar": "110",
"chart": "True",
}
),
],
)
@pytest.mark.integration
def test_charting_technical_aroon(params, obb):
"""Test chart ta aroon."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_equity_data()
result = obb.technical.aroon(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"target": "high",
"index": "",
"length": "60",
"offset": "10",
"chart": "True",
}
),
],
)
@pytest.mark.integration
def test_charting_technical_ema(params, obb):
"""Test chart ta ema."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_equity_data()
result = obb.technical.ema(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"target": "high",
"index": "date",
"length": "55",
"offset": "2",
"chart": "True",
}
),
],
)
@pytest.mark.integration
def test_charting_technical_hma(params, obb):
"""Test chart ta hma."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_equity_data()
result = obb.technical.hma(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"target": "high",
"index": "date",
"fast": "10",
"slow": "30",
"signal": "10",
"chart": "True",
}
),
],
)
@pytest.mark.integration
def test_charting_technical_macd(params, obb):
"""Test chart ta macd."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_equity_data()
result = obb.technical.macd(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"target": "high",
"index": "date",
"length": "16",
"scalar": "90.0",
"drift": "2",
"chart": "True",
}
),
],
)
@pytest.mark.integration
def test_charting_technical_rsi(params, obb):
"""Test chart ta rsi."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_equity_data()
result = obb.technical.rsi(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"target": "high",
"index": "date",
"length": "55",
"offset": "2",
"chart": "True",
}
),
],
)
@pytest.mark.integration
def test_charting_technical_sma(params, obb):
"""Test chart ta sma."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_equity_data()
result = obb.technical.sma(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"target": "high",
"index": "date",
"length": "60",
"offset": "10",
"chart": "True",
}
),
],
)
@pytest.mark.integration
def test_charting_technical_wma(params, obb):
"""Test chart ta wma."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_equity_data()
result = obb.technical.wma(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"target": "high",
"index": "date",
"length": "55",
"offset": "5",
"chart": "True",
}
),
],
)
@pytest.mark.integration
def test_charting_technical_zlma(params, obb):
"""Test chart ta zlma."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_equity_data()
result = obb.technical.zlma(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
{
"data": "",
"model": "yang_zhang",
"chart": True,
}
],
)
@pytest.mark.integration
def test_charting_technical_cones(params, obb):
"""Test chart ta cones."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_equity_data()
result = obb.technical.cones(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
{
"data": None,
"symbol": "DGS10",
"transform": "pc1",
"chart": True,
"provider": "fred",
}
],
)
@pytest.mark.integration
def test_charting_economy_fred_series(params, obb):
"""Test chart economy fred series."""
result = obb.economy.fred_series(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"study": "price",
"benchmark": "SPY",
"long_period": 252,
"short_period": 21,
"window": 21,
"trading_periods": 252,
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_technical_relative_rotation(params, obb):
"""Test charting technical relative rotation."""
params["data"] = obb.equity.price.historical(
"AAPL,MSFT,GOOGL,AMZN,SPY",
provider="yfinance",
start_date="2022-01-01",
end_date="2024-01-01",
).results
result = obb.technical.relative_rotation(
data=params["data"],
benchmark=params["benchmark"],
study=params["study"],
long_period=params["long_period"],
short_period=params["short_period"],
window=params["window"],
trading_periods=params["trading_periods"],
chart=params["chart"],
)
assert result
assert isinstance(result, OBBject)
assert len(result.results.rs_ratios) > 0 # type: ignore
assert result.chart.content # type: ignore
assert isinstance(result.chart.fig, OpenBBFigure) # type: ignore
@pytest.mark.parametrize(
"params",
[
{
"data": None,
"symbol": "XRT,XLB,XLI,XLH,XLC,XLY,XLU,XLK",
"chart": True,
"provider": "finviz",
"chart_params": {"limit": 4, "orientation": "h"},
}
],
)
@pytest.mark.integration
def test_charting_equity_price_performance(params, obb):
"""Test chart equity price performance."""
result = obb.equity.price.performance(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
{
"data": None,
"symbol": "XRT,XLB,XLI,XLH,XLC,XLY,XLU,XLK",
"chart": True,
"provider": "fmp",
"chart_params": {"orientation": "v"},
}
],
)
@pytest.mark.integration
def test_charting_etf_price_performance(params, obb):
"""Test chart etf price performance."""
result = obb.etf.price_performance(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
{
"data": None,
"symbol": "XRT",
"chart": True,
"provider": "fmp",
"chart_params": {"orientation": "v", "limit": 10},
}
],
)
@pytest.mark.integration
def test_charting_etf_holdings(params, obb):
"""Test chart etf holdings."""
result = obb.etf.holdings(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "econdb",
"country": "united_kingdom",
"date": None,
"chart": True,
}
),
(
{
"provider": "fred",
"date": "2023-05-10,2024-05-10",
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_fixedincome_government_yield_curve(params, obb):
"""Test chart fixedincome government yield curve."""
result = obb.fixedincome.government.yield_curve(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
{
"provider": "yfinance",
"symbol": "ES",
"start_date": "2022-01-01",
"end_date": "2022-02-01",
"chart": True,
}
],
)
@pytest.mark.integration
def test_charting_derivatives_futures_historical(params, obb):
"""Test chart derivatives futures historical."""
result = obb.derivatives.futures.historical(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "yfinance",
"symbol": "ES",
"date": None,
"chart": True,
}
),
(
{
"provider": "cboe",
"symbol": "VX_EOD",
"date": "2024-06-25",
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_derivatives_futures_curve(params, obb):
"""Test chart derivatives futures curve."""
result = obb.derivatives.futures.curve(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "fmp",
"symbol": "AAPL",
"start_date": "2024-01-01",
"end_date": "2024-06-30",
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_equity_historical_market_cap(params, obb):
"""Test chart equity historical market cap."""
result = obb.equity.historical_market_cap(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "bls",
"symbol": "APUS49D74714,APUS49D74715,APUS49D74716",
"start_date": "2014-01-01",
"end_date": "2024-07-01",
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_economy_survey_bls_series(params, obb):
"""Test chart economy survey bls series."""
result = obb.economy.survey.bls_series(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
{
"data": "",
"method": "pearson",
"chart": True,
}
],
)
@pytest.mark.integration
def test_charting_econometrics_correlation_matrix(params, obb):
"""Test chart econometrics correlation matrix."""
symbols = ["XRT", "XLB", "XLI", "XLH", "XLC", "XLY", "XLU", "XLK"]
params["data"] = (
obb.equity.price.historical(symbol=symbols, provider="yfinance")
.to_df()
.pivot(columns="symbol", values="close")
.filter(items=symbols, axis=1)
)
result = obb.econometrics.correlation_matrix(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "imf",
"country": "CRI",
"continent": None,
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_economy_shipping_port_info(params, obb):
"""Test chart economy shipping port info."""
result = obb.economy.shipping.port_info(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"provider": "imf",
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_economy_shipping_chokepoint_info(params, obb):
"""Test chart economy shipping chokepoint info."""
result = obb.economy.shipping.chokepoint_info(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
@pytest.mark.parametrize(
"params",
[
(
{
"data": "",
"moneyness": 20,
"dte_min": 5,
"dte_max": 60,
"chart": True,
}
),
],
)
@pytest.mark.integration
def test_charting_derivatives_options_surface(params, obb):
"""Test chart equity price historical."""
data = obb.derivatives.options.chains("AAPL", provider="cboe")
params["data"] = data.results
result = obb.derivatives.options.surface(**params)
assert result
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.chart.content
assert isinstance(result.chart.fig, OpenBBFigure)
def test_charting_integration(name, route, builder, chart_kwargs):
"""The charting accessor resolves the route and populates a real chart."""
obbject = make_obbject(route, builder())
obbject.charting.to_chart(render=False, **chart_kwargs)
chart = obbject.chart
assert chart is not None, f"{name}: charting produced no chart"
assert isinstance(chart.fig, OpenBBFigure), f"{name}: no OpenBBFigure"
assert chart.content, f"{name}: empty chart content"
assert "data" in chart.content, f"{name}: content missing 'data'"
assert "layout" in chart.content, f"{name}: content missing 'layout'"

View File

@@ -13,11 +13,9 @@ warnings.filterwarnings(
def get_charting_module():
"""Get the Charting module."""
# pylint: disable=import-outside-toplevel
import importlib
_Charting = importlib.import_module("openbb_charting.charting").Charting
return _Charting
return importlib.import_module("openbb_charting.charting").Charting
ext = Extension(name="charting", description="Create custom charts from OBBject data.")

View File

@@ -1,6 +1,6 @@
"""Charting Class implementation."""
# pylint: disable=too-many-arguments,unused-argument,too-many-positional-arguments
from __future__ import annotations
from collections.abc import Callable
from typing import (
@@ -8,7 +8,6 @@ from typing import (
Any,
ClassVar,
Literal,
Union,
)
from warnings import warn
@@ -24,52 +23,27 @@ from openbb_charting.charts.helpers import (
)
if TYPE_CHECKING:
from numpy import ndarray # noqa
from numpy import ndarray
from pandas import DataFrame, Series
from plotly.graph_objs import Figure
from openbb_charting.core.openbb_figure import OpenBBFigure
from openbb_charting.query_params import ChartParams
CHARTING_EXTENSION_GROUP = "openbb_charting_extension"
class Charting:
"""Charting extension.
"""Charting extension."""
Methods
-------
show
Display chart and save it to the OBBject.
to_chart
Redraw the chart and save it to the OBBject, with an optional entry point for Data.
functions
Return a list of Platform commands with charting functions.
get_params
Return the charting parameters for the function the OBBject was created from.
indicators
Return the list of the available technical indicators to use with the `to_chart` method and OHLC+V data.
table
Display an interactive table.
create_line_chart
Create a line chart from external data.
create_bar_chart
Create a bar chart, on a single x-axis with one or more values for the y-axis, from external data.
create_correlation_matrix
Create a correlation matrix from external data.
toggle_chart_style
Toggle the chart style, of an existing chart, between light and dark mode.
"""
_extension_views: ClassVar[list[type]] = [
entry_point.load()
for entry_point in entry_points(group="openbb_charting_extension")
]
_format = "plotly" # the charts computed by this extension will be in plotly format
_extension_views_cache: ClassVar[list[type] | None] = None
_format = "plotly"
def __init__(self, obbject):
"""Initialize Charting extension."""
# pylint: disable=import-outside-toplevel
import importlib # noqa
import importlib
from openbb_charting.core.backend import Backend
from openbb_core.app.charting import get_hooks_manager
charting_settings_module = importlib.import_module(
"openbb_core.app.model.charts.charting_settings", "ChartingSettings"
@@ -78,19 +52,42 @@ class Charting:
self._obbject: OBBject = obbject
self._charting_settings = ChartingSettings(
user_settings=self._obbject._user_settings, # type: ignore
system_settings=self._obbject._system_settings, # type: ignore
user_settings=self._obbject._user_settings,
system_settings=self._obbject._system_settings,
)
self._backend = Backend(self._charting_settings)
self._backend = self.get_backend_class()(self._charting_settings)
self._hooks = get_hooks_manager()
self._functions: dict[str, Callable] = self._get_functions()
@classmethod
def indicators(cls):
"""Return an instance of the IndicatorsParams class, containing all available indicators and their parameters.
def _get_extension_views(cls) -> list[type]:
"""Discover and cache the registered charting view classes."""
if cls._extension_views_cache is None:
cls._extension_views_cache = [
entry_point.load()
for entry_point in entry_points(group=CHARTING_EXTENSION_GROUP)
]
return cls._extension_views_cache
Without assigning to a variable, it will print the the information to the console.
"""
# pylint: disable=import-outside-toplevel
@classmethod
def get_backend_class(cls) -> type:
"""Return the rendering backend class for this engine."""
from openbb_core.app.charting import get_charting_backend_class
from openbb_charting.core.backend import Backend
return get_charting_backend_class() or Backend
@classmethod
def get_figure_class(cls) -> type:
"""Return the figure class this engine produces."""
from openbb_charting.core.openbb_figure import OpenBBFigure
return OpenBBFigure
@classmethod
def indicators(cls):
"""Return an instance of the IndicatorsParams class, containing all available indicators and their parameters."""
from openbb_charting.query_params import IndicatorsParams
return IndicatorsParams()
@@ -99,7 +96,7 @@ class Charting:
def functions(cls) -> list[str]:
"""Return a list of the available functions."""
functions: list[str] = []
for view in cls._extension_views:
for view in cls._get_extension_views():
functions.extend(get_charting_functions_list(view))
return functions
@@ -107,7 +104,7 @@ class Charting:
def _get_functions(self) -> dict[str, Callable]:
"""Return a dict with the available functions."""
functions: dict[str, Callable] = {}
for view in self._extension_views:
for view in self._get_extension_views():
functions.update(get_charting_functions(view))
return functions
@@ -123,49 +120,65 @@ class Charting:
)
return self._functions[adjusted_route]
def get_params(self) -> Union["ChartParams", None]:
"""Return the ChartQueryParams class for the function the OBBject was created from.
def _make_hook_context(self, stage, **overrides):
"""Build a :class:`HookContext` for the bound OBBject."""
from openbb_core.app.charting import HookContext
Without assigning to a variable, it will print the docstring to the console.
If the class is not defined, the help for the function will be returned.
"""
# pylint: disable=import-outside-toplevel
context = HookContext(
route=self._obbject._route or "",
stage=stage,
data=self._obbject.results,
settings=self._charting_settings,
standard_params=self._obbject._standard_params or {},
extra_params=self._obbject._extra_params or {},
provider=self._obbject.provider,
extra=self._obbject.extra,
)
for key, value in overrides.items():
setattr(context, key, value)
return context
def _dispatch_hook(self, stage, context=None, **overrides):
"""Dispatch a lifecycle ``stage`` through the registered hooks."""
if context is None:
context = self._make_hook_context(stage, **overrides)
else:
context.stage = stage
for key, value in overrides.items():
setattr(context, key, value)
return self._hooks.dispatch(stage, context)
def get_params(self) -> ChartParams | None:
"""Return the ChartQueryParams class for the function the OBBject was created from."""
from openbb_charting.query_params import ChartParams
if self._obbject._route is None: # pylint: disable=protected-access
if self._obbject._route is None:
raise ValueError("OBBject was initialized with no function route.")
charting_function = (
self._obbject._route # pylint: disable=protected-access
).replace("/", "_")[1:]
charting_function = (self._obbject._route).replace("/", "_")[1:]
if hasattr(ChartParams, charting_function):
return getattr(ChartParams, charting_function)()
return help( # type: ignore
self._get_chart_function( # pylint: disable=protected-access
self._obbject.extra[
"metadata"
].route # pylint: disable=protected-access
)
)
return help(self._get_chart_function(self._obbject.extra["metadata"].route))
def _prepare_data_as_df(
self, data: Union["DataFrame", "Series"] | None
) -> tuple["DataFrame", bool]:
self, data: DataFrame | Series | None
) -> tuple[DataFrame, bool]:
"""Convert supplied data to a DataFrame."""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import basemodel_to_df, convert_to_basemodel
from pandas import DataFrame, Series
has_data = (isinstance(data, (Data, DataFrame, Series)) and not data.empty) or (bool(data)) # type: ignore
has_data = (isinstance(data, (Data, DataFrame, Series)) and not data.empty) or (
bool(data)
)
index = (
data.index.name
if has_data and isinstance(data, (DataFrame, Series))
else None
)
data_as_df: DataFrame = (
basemodel_to_df(convert_to_basemodel(data), index=index) # type: ignore
basemodel_to_df(convert_to_basemodel(data), index=index) # ty: ignore[invalid-argument-type]
if has_data
else self._obbject.to_dataframe(index=index) # type: ignore
else self._obbject.to_dataframe(index=index) # ty: ignore[invalid-argument-type]
)
if "date" in data_as_df.columns:
data_as_df = data_as_df.set_index("date")
@@ -173,19 +186,18 @@ class Charting:
data_as_df.drop(columns="provider", inplace=True)
return data_as_df, has_data
# pylint: disable=too-many-locals
def create_line_chart(
self,
data: Union[
list,
dict,
"DataFrame",
list["DataFrame"],
"Series",
list["Series"],
"ndarray",
Data,
],
data: (
list
| dict
| DataFrame
| list[DataFrame]
| Series
| list[Series]
| ndarray
| Data
),
index: str | None = None,
target: str | None = None,
title: str | None = None,
@@ -202,37 +214,37 @@ class Charting:
same_axis: bool = False,
render: bool = True,
**kwargs,
) -> Union["OpenBBFigure", "Figure", None]:
) -> OpenBBFigure | Figure | None:
"""Create a line chart from external data and render a chart or return the OpenBBFigure.
Parameters
----------
data : Union[Data, DataFrame, Series]
data : Data | DataFrame | Series
Data to be plotted (OHLCV data).
index : Optional[str], optional
index : str | None, optional
Index column, by default None
target : Optional[str], optional
target : str | None, optional
Target column to be plotted, by default None
title : Optional[str], optional
title : str | None, optional
Chart title, by default None
x : Optional[str], optional
x : str | None, optional
X-axis column, by default None
xtitle : Optional[str], optional
xtitle : str | None, optional
X-axis title, by default None
y : Optional[Union[str, List[str]]], optional
y : str | list[str] | None, optional
Y-axis column(s), by default None
If None are supplied, the layout is optimized for the contents of data.
Where many units/scales are present,
it will attempt to divide based on the range of values.
ytitle : Optional[str], optional
ytitle : str | None, optional
Y-axis title, by default None
y2 : Optional[Union[str, List[str]]], optional
y2 : str | list[str] | None, optional
Y2-axis column(s), by default None
y2title : Optional[str], optional
y2title : str | None, optional
Y2-axis title, by default None
layout_kwargs : Optional[dict], optional
layout_kwargs : dict | None, optional
Additional Plotly Layout parameters for `fig.update_layout`, by default None
scatter_kwargs : Optional[dict], optional
scatter_kwargs : dict | None, optional
Additional Plotly parameters applied on creation of each scatter plot, by default None
normalize : bool, optional
Normalize the data with Z-Score Standardization, by default False
@@ -242,10 +254,9 @@ class Charting:
If True, forces all data onto the same Y-axis, by default False
render: bool, optional
If True, the chart will be rendered, by default True
**kwargs: Dict[str, Any]
**kwargs: dict[str, Any]
Extra parameters to be passed to `figure.show()`
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.charts.generic_charts import line_chart
fig = line_chart(
@@ -268,22 +279,22 @@ class Charting:
)
fig = self._set_chart_style(fig)
if render:
return fig.show(**kwargs)
fig.show(**kwargs)
return fig
def create_bar_chart(
self,
data: Union[
list,
dict,
"DataFrame",
list["DataFrame"],
"Series",
list["Series"],
"ndarray",
Data,
],
data: (
list
| dict
| DataFrame
| list[DataFrame]
| Series
| list[Series]
| ndarray
| Data
),
x: str,
y: str | list[str],
barmode: Literal["group", "stack", "relative", "overlay"] = "group",
@@ -299,16 +310,16 @@ class Charting:
bar_kwargs: dict[str, Any] | None = None,
render: bool = True,
**kwargs,
) -> Union["OpenBBFigure", "Figure", None]:
) -> OpenBBFigure | Figure | None:
"""Create a bar chart on a single x-axis with one or more values for the y-axis.
Parameters
----------
data : Union[list, dict, DataFrame, List[DataFrame], Series, List[Series], ndarray, Data]
data : list | dict | DataFrame | list[DataFrame] | Series | list[Series] | ndarray | Data
Data to plot.
x : str
The x-axis column name.
y : Union[str, List[str]]
y : str | list[str]
The y-axis column name(s).
barmode : Literal["group", "stack", "relative", "overlay"], optional
The bar mode, by default "group".
@@ -320,18 +331,18 @@ class Charting:
The x-axis title, by default None.
ytitle : str, optional
The y-axis title, by default None.
colors: List[str], optional
colors: list[str], optional
Manually set the colors to cycle through for each column in 'y', by default None.
bar_kwargs : Dict[str, Any], optional
bar_kwargs : dict[str, Any], optional
Additional keyword arguments to apply with figure.add_bar(), by default None.
layout_kwargs : Dict[str, Any], optional
layout_kwargs : dict[str, Any], optional
Additional keyword arguments to apply with figure.update_layout(), by default None.
Returns
-------
OpenBBFigure
The OpenBBFigure object.
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.charts.generic_charts import bar_chart
fig = bar_chart(
@@ -350,15 +361,15 @@ class Charting:
)
fig = self._set_chart_style(fig)
if render:
return fig.show(**kwargs)
fig.show(**kwargs)
return fig
def create_3d_surface(
self,
X: "Series",
Y: "Series",
Z: "Series",
X: Series,
Y: Series,
Z: Series,
xtitle: str | None = "DTE",
ytitle: str | None = "Strike",
ztitle: str | None = "IV",
@@ -366,7 +377,7 @@ class Charting:
title: str | None = None,
layout_kwargs: dict[str, Any] | None = None,
theme: Literal["dark", "light"] | None = None,
) -> Union["OpenBBFigure", "Figure"]:
) -> OpenBBFigure | Figure:
"""Create a 3D surface chart.
Parameters
@@ -383,11 +394,11 @@ class Charting:
The title for the y-axis, by default "Strike".
ztitle : str, optional
The title for the z-axis, by default "IV".
colorscale : Union[str, list], optional
colorscale : str | list, optional
The colorscale to use for the surface, by default None.
title : str, optional
The title of the chart, by default None.
layout_kwargs : Optional[dict[str, Any]], optional
layout_kwargs : dict[str, Any] | None, optional
Additional keyword arguments to apply with figure.update_layout(), by default None.
Returns
@@ -395,7 +406,6 @@ class Charting:
OpenBBFigure
The OpenBBFigure object.
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.charts.generic_charts import surface3d
fig = surface3d(
@@ -415,10 +425,7 @@ class Charting:
def create_correlation_matrix(
self,
data: Union[
list[Data],
"DataFrame",
],
data: list[Data] | DataFrame,
method: Literal["pearson", "kendall", "spearman"] = "pearson",
colorscale: str = "RdBu",
title: str = "Asset Correlation Matrix",
@@ -428,7 +435,7 @@ class Charting:
Parameters
----------
data : Union[list[Data], DataFrame]
data : list[Data] | DataFrame
Input dataset.
method : Literal["pearson", "kendall", "spearman"]
Method to use for correlation calculation. Default is "pearson".
@@ -439,7 +446,7 @@ class Charting:
Plotly colorscale to use for the heatmap. Default is "RdBu".
title : str
Title of the chart. Default is "Asset Correlation Matrix".
layout_kwargs : Dict[str, Any]
layout_kwargs : dict[str, Any]
Additional keyword arguments to apply with figure.update_layout(), by default None.
Returns
@@ -447,7 +454,6 @@ class Charting:
OpenBBFigure
The OpenBBFigure object.
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.charts.correlation_matrix import correlation_matrix
kwargs = {
@@ -461,104 +467,110 @@ class Charting:
fig = self._set_chart_style(fig)
return fig
def show(self, render: bool = True, **kwargs):
"""Display chart and save it to the OBBject."""
# pylint: disable=import-outside-toplevel
def _normalize_chart_response(
self, chart_response: Any, kwargs: dict[str, Any]
) -> tuple[Any, Chart]:
"""Normalize a chart function's output into a ``(figure, Chart)`` pair."""
from openbb_charting.core.openbb_figure import OpenBBFigure
if isinstance(chart_response, Chart):
return getattr(chart_response, "fig", None), chart_response
if isinstance(chart_response, OpenBBFigure):
fig = chart_response
content = fig.show(external=True, **kwargs).to_plotly_json()
return fig, Chart(fig=fig, content=content, format=self._format)
if isinstance(chart_response, tuple) and len(chart_response) == 2:
fig, content = chart_response
if isinstance(fig, OpenBBFigure):
content = fig.show(external=True, **kwargs).to_plotly_json()
return fig, Chart(fig=fig, content=content, format=self._format)
return fig, Chart(fig=fig, content=content, format=type(fig).__name__)
return chart_response, Chart(fig=chart_response, content=None, format="unknown")
def show(self, render: bool = True, **kwargs):
"""Display chart and save it to the OBBject."""
from openbb_core.app.charting import ChartLifecycle
try:
charting_function = self._get_chart_function(
self._obbject._route or "" # pylint: disable=protected-access
)
charting_function = self._get_chart_function(self._obbject._route or "")
kwargs["obbject_item"] = self._obbject.results
kwargs["charting_settings"] = self._charting_settings
kwargs["standard_params"] = (
self._obbject._standard_params # pylint: disable=protected-access
)
# If the provider interface isn't used, endpoint kwargs are already here.
# Don't overwrite them.
obb_kwargs = (
self._obbject._extra_params or {} # pylint: disable=protected-access
)
kwargs["standard_params"] = self._obbject._standard_params
obb_kwargs = self._obbject._extra_params or {}
if obb_kwargs:
for k, v in obb_kwargs.items():
kwargs["extra_params"].update({k: v})
kwargs["provider"] = self._obbject.provider
kwargs["extra"] = self._obbject.extra
kwargs.setdefault(
"command_location",
self._obbject._route or "", # pylint: disable=protected-access
)
kwargs.setdefault("command_location", self._obbject._route or "")
context = self._dispatch_hook(ChartLifecycle.RESOLVE_DATA)
context = self._dispatch_hook(ChartLifecycle.PRE_FIGURE, context=context)
# Handle different types of output from the charting endpoint.
chart_response: Any = charting_function(**kwargs)
fig, chart = self._normalize_chart_response(chart_response, kwargs)
# If returned a Chart object, set as-is.
if isinstance(chart_response, Chart):
self._obbject.chart = chart_response
# If just an OpenBBFigure gets returned, create the serialized version for the API.
elif isinstance(chart_response, OpenBBFigure):
fig = chart_response
content = fig.show(external=True, **kwargs).to_plotly_json()
self._obbject.chart = Chart(
fig=fig, content=content, format=self._format
)
# Current functions return this.
elif isinstance(chart_response, tuple) and len(chart_response) == 2:
fig, content = chart_response
context = self._dispatch_hook(
ChartLifecycle.POST_FIGURE,
context=context,
figure=fig,
content=chart.content,
)
if context.figure is not None:
chart.fig = context.figure
fig = context.figure
if context.content is not None:
chart.content = context.content
if isinstance(fig, OpenBBFigure):
content = fig.show(external=True, **kwargs).to_plotly_json() # type: ignore
self._obbject.chart = Chart(
fig=fig, content=content, format=self._format
)
else:
self._obbject.chart = Chart(
fig=fig, content=content, format=type(fig).__name__
)
else:
self._obbject.chart = Chart(
fig=chart_response, content=None, format="unknown"
)
self._obbject.chart = chart
if render and hasattr(fig, "show"):
self._dispatch_hook(
ChartLifecycle.PRE_RENDER, context=context, figure=fig
)
fig.show(**kwargs)
self._dispatch_hook(
ChartLifecycle.POST_RENDER, context=context, figure=fig
)
except (RuntimeError, OpenBBError) as e:
raise e from e
except Exception: # pylint: disable=W0718
except Exception:
try:
fig = self.create_line_chart(data=self._obbject.results, render=False, **kwargs) # type: ignore
fig = self._set_chart_style(fig) # type: ignore
content = fig.show(external=True, **kwargs).to_plotly_json() # type: ignore
fig = self.create_line_chart(
data=self._obbject.results, # ty: ignore[invalid-argument-type]
render=False,
**kwargs,
)
fig = self._set_chart_style(fig) # ty: ignore[invalid-argument-type]
content = fig.show(external=True, **kwargs).to_plotly_json()
self._obbject.chart = Chart(
fig=fig, content=content, format=self._format
)
if render:
fig.show(**kwargs) # type: ignore
fig.show(**kwargs)
except Exception as e:
raise RuntimeError(
"Failed to automatically create a generic chart with the data provided."
+ f" -> {e} -> {e.args}"
) from e
# pylint: disable=too-many-locals,inconsistent-return-statements
def to_chart(
self,
data: (
Union[
list,
dict,
"DataFrame",
list["DataFrame"],
"Series",
list["Series"],
"ndarray",
Data,
]
list
| dict
| DataFrame
| list[DataFrame]
| Series
| list[Series]
| ndarray
| Data
| None
) = None,
target: str | None = None,
@@ -573,15 +585,11 @@ class Charting:
):
"""Create an OpenBBFigure with user customizations (if any) and save it to the OBBject.
This function is used to populate, or re-populate, the OBBject with a chart using the data within
the OBBject or external data supplied via the `data` parameter.
This function modifies the original OBBject by overwriting the existing chart.
Parameters
----------
data : Union[Data, DataFrame, Series]
data : Data | DataFrame | Series
Data to be plotted.
indicators : Dict[str, Dict[str, Any]], optional
indicators : dict[str, dict[str, Any]], optional
Indicators to be plotted, by default None
symbol : str, optional
Symbol to be plotted. This is used for labels and titles, by default ""
@@ -593,33 +601,10 @@ class Charting:
Volume ticks, by default 7
render : bool, optional
If True, the chart will be rendered, by default True
kwargs: Dict[str, Any]
kwargs: dict[str, Any]
Extra parameters to be passed to the chart constructor.
Examples
--------
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})
Get all the available indicators
>>> res = obb.equity.price.historical("AAPL")
>>> indicators = res.charting.indicators()
>>> indicators?
"""
data_as_df, has_data = self._prepare_data_as_df(data) # type: ignore
data_as_df, has_data = self._prepare_data_as_df(data) # ty: ignore[invalid-argument-type]
if target is not None:
data_as_df = data_as_df[[target]]
kwargs["candles"] = candles
@@ -631,41 +616,37 @@ class Charting:
kwargs["index"] = index
kwargs["obbject_item"] = self._obbject.results
kwargs["charting_settings"] = self._charting_settings
kwargs["standard_params"] = (
self._obbject._standard_params # pylint: disable=protected-access
)
kwargs["extra_params"] = (
self._obbject._extra_params # pylint: disable=protected-access
)
kwargs["provider"] = self._obbject.provider # pylint: disable=protected-access
kwargs["extra"] = self._obbject.extra # pylint: disable=protected-access
kwargs["standard_params"] = self._obbject._standard_params
kwargs["extra_params"] = self._obbject._extra_params
kwargs["provider"] = self._obbject.provider
kwargs["extra"] = self._obbject.extra
try:
if has_data:
self.show(data=data_as_df, render=render, **kwargs)
else:
self.show(**kwargs, render=render)
except Exception: # pylint: disable=W0718
except Exception:
try:
fig = self.create_line_chart(data=data_as_df, render=False, **kwargs)
fig = self._set_chart_style(fig) # type: ignore
content = fig.show(external=True, **kwargs).to_plotly_json() # type: ignore
fig = self._set_chart_style(fig) # ty: ignore[invalid-argument-type]
content = fig.show(external=True, **kwargs).to_plotly_json()
self._obbject.chart = Chart(
fig=fig, content=content, format=self._format
)
if render:
return fig.show(**kwargs) # type: ignore
except Exception as e: # pylint: disable=W0718
return fig.show(**kwargs)
except Exception as e:
raise RuntimeError(
"Failed to automatically create a generic chart with the data provided."
) from e
def _set_chart_style(self, figure: "Figure"):
def _set_chart_style(self, figure: Figure):
"""Set the user preference for light or dark mode."""
return figure
def toggle_chart_style(self):
"""Toggle the chart style between light and dark mode."""
import plotly.io as pio # pylint: disable=import-outside-toplevel
import plotly.io as pio
if not hasattr(self._obbject.chart, "fig"):
raise ValueError(
@@ -675,17 +656,14 @@ class Charting:
new = "light" if current == "dark" else "dark"
self._charting_settings.chart_style = new
template_name = "plotly_white" if new == "light" else "plotly_dark"
figure = self._obbject.chart.fig # type: ignore[union-attr]
figure.update_layout(template=pio.templates[template_name]) # type: ignore[union-attr]
self._obbject.chart.fig = figure # type: ignore[union-attr]
self._obbject.chart.content = figure.show( # type: ignore[union-attr]
external=True
).to_plotly_json() # type: ignore[union-attr]
figure = self._obbject.chart.fig
figure.update_layout(template=pio.templates[template_name]) # ty: ignore[unresolved-attribute]
self._obbject.chart.fig = figure
self._obbject.chart.content = figure.show(external=True).to_plotly_json() # ty: ignore[unresolved-attribute]
@staticmethod
def _convert_to_string(x):
"""Sanitize the data for the table."""
# pylint: disable=import-outside-toplevel
from numpy import isnan
if isinstance(x, (float, int)) and not isnan(x):
@@ -710,7 +688,7 @@ class Charting:
def table(
self,
data: Union["DataFrame", "Series"] | None = None,
data: DataFrame | Series | None = None,
title: str = "",
include_query_toolbar: bool = True,
):
@@ -718,7 +696,7 @@ class Charting:
Parameters
----------
data : Optional[Union[DataFrame, Series]], optional
data : DataFrame | Series | None, optional
Data to be plotted, by default None.
If no data is provided the OBBject results will be used.
title : str, optional
@@ -726,7 +704,6 @@ class Charting:
include_query_toolbar : bool, optional
Whether to include the Pandas Query toolbar, by default True.
"""
# pylint: disable=import-outside-toplevel
from pandas import RangeIndex
data_as_df, _ = self._prepare_data_as_df(data)
@@ -741,21 +718,17 @@ class Charting:
if include_query_toolbar:
send_table(
df_table=data_as_df,
title=title
or self._obbject._route # pylint: disable=protected-access
or "",
theme=self._charting_settings.table_style, # pylint: disable=protected-access
title=title or self._obbject._route or "",
theme=self._charting_settings.table_style,
)
else:
send_table(
df_table=data_as_df,
title=title
or self._obbject._route # pylint: disable=protected-access
or "",
theme=self._charting_settings.table_style, # pylint: disable=protected-access
title=title or self._obbject._route or "",
theme=self._charting_settings.table_style,
include_query_toolbar=False,
)
except Exception as e: # pylint: disable=W0718
except Exception as e:
warn(f"Failed to show table with backend. {e}")
def url(
@@ -768,5 +741,5 @@ class Charting:
"""Return the URL of the chart."""
try:
self._backend.send_url(url=url, title=title, width=width, height=height)
except Exception as e: # pylint: disable=W0718
except Exception as e:
warn(f"Failed to show figure with backend. {e}")

View File

@@ -11,7 +11,6 @@ def correlation_matrix( # noqa: PLR0912
**kwargs,
) -> tuple[Union["OpenBBFigure", "Figure"], dict[str, Any]]:
"""Correlation Matrix Chart."""
# pylint: disable=import-outside-toplevel
from numpy import ones_like, triu # noqa
from openbb_core.app.utils import basemodel_to_df # noqa
from openbb_charting.core.openbb_figure import OpenBBFigure
@@ -22,11 +21,11 @@ def correlation_matrix( # noqa: PLR0912
if "data" in kwargs and isinstance(kwargs["data"], DataFrame):
corr = kwargs["data"]
elif "data" in kwargs and isinstance(kwargs["data"], list):
corr = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "date")) # type: ignore
corr = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "date"))
else:
corr = basemodel_to_df(
kwargs["obbject_item"],
index=kwargs.get("index", "date"), # type: ignore
index=kwargs.get("index", "date"),
)
if (
"symbol" in corr.columns
@@ -106,6 +105,6 @@ def correlation_matrix( # noqa: PLR0912
if layout_kwargs:
figure.update_layout(**layout_kwargs)
content = figure.show(external=True).to_plotly_json() # type: ignore
content = figure.show(external=True).to_plotly_json()
return figure, content

View File

@@ -1,7 +1,5 @@
"""Generic Charts Module."""
# pylint: disable=too-many-arguments,unused-argument,too-many-locals, too-many-branches, too-many-lines, too-many-statements, use-dict-literal, broad-exception-caught, too-many-nested-blocks, too-many-positional-arguments
from typing import TYPE_CHECKING, Any, Literal, Union
from openbb_core.app.utils import basemodel_to_df, convert_to_basemodel
@@ -50,7 +48,6 @@ def line_chart( # noqa: PLR0912
**kwargs,
) -> Union["OpenBBFigure", "Figure"]:
"""Create a line chart."""
# pylint: disable=import-outside-toplevel
from pandas import DataFrame, Series, to_datetime # noqa
from openbb_charting.core.openbb_figure import OpenBBFigure
@@ -58,10 +55,14 @@ def line_chart( # noqa: PLR0912
raise ValueError("Error: Data is a required field.")
auto_layout = False
index = ( # type: ignore
index = ( # ty: ignore[invalid-assignment]
data.index.name
if isinstance(data, (DataFrame, Series))
else index if index is not None else x if x is not None else "date"
else index
if index is not None
else x
if x is not None
else "date"
)
df: DataFrame = (basemodel_to_df(convert_to_basemodel(data), index=index)).dropna(
how="all", axis=1
@@ -74,17 +75,19 @@ def line_chart( # noqa: PLR0912
else:
found_index = False
for col in df.columns:
if df[col].dtype == "object":
if (
df[col].dtype == "object"
): # pragma: no cover # basemodel_to_df emits StringDtype, never object
try:
df[col] = df[col].apply(to_datetime)
index = df[col].name # type: ignore
index = df[col].name
df.set_index(col, inplace=True)
df.index.name = "date"
found_index = True
except Exception as _: # noqa: S112
continue
if found_index is True:
break
break # pragma: no cover # found_index only set in unreachable object branch
if found_index is False:
df.set_index(df.iloc[:, 0], inplace=True)
@@ -94,7 +97,7 @@ def line_chart( # noqa: PLR0912
df = df.pivot(columns="symbol", values=target)
if "symbol" not in df.columns and target in df.columns:
df = df[[target]] # type: ignore
df = df[[target]]
y = y.split(",") if isinstance(y, str) else y
@@ -106,11 +109,11 @@ def line_chart( # noqa: PLR0912
auto_layout = False
if returns is True:
df = df.apply(calculate_returns) # type: ignore
df = df.apply(calculate_returns)
auto_layout = False
if normalize is True:
df = df.apply(z_score_standardization) # type: ignore
df = df.apply(z_score_standardization)
auto_layout = False
if layout_kwargs is None:
@@ -121,7 +124,9 @@ def line_chart( # noqa: PLR0912
try:
fig = OpenBBFigure()
except Exception as _:
except (
Exception
) as _: # pragma: no cover # no-arg OpenBBFigure() has no triggerable failure
fig = OpenBBFigure(create_backend=True)
text_color = "white" if ChartStyle().plt_style == "dark" else "black"
@@ -132,26 +137,23 @@ def line_chart( # noqa: PLR0912
y2 = y2 if y2 else []
yaxis_num = 1
yaxis = f"y{yaxis_num}"
first_y = y[0] # type: ignore[index]
first_y = y[0]
second_y = None
third_y = None
add_scatter = False
# Attempt to layout the chart automatically with multiple y-axis.
mode = scatter_kwargs.pop("mode", "lines")
hovertemplate = scatter_kwargs.pop("hovertemplate", None)
if auto_layout is True:
# Sort columns by the difference between the max and min values.
# This is to help determine which columns should share the same y-axis.
diff = df.max(numeric_only=True) - df.min(numeric_only=True)
sorted_columns = diff.sort_values(ascending=False).index
if sorted_columns is None or len(sorted_columns) == 0:
raise ValueError("Error: expected data with numeric values.")
df = df[sorted_columns] # type: ignore
df = df[sorted_columns]
for i, col in enumerate(df.columns):
if col in y: # type: ignore[operator]
if col in y:
hovertemplate = (
hovertemplate
if hovertemplate
@@ -192,7 +194,7 @@ def line_chart( # noqa: PLR0912
if auto_layout is False:
color = 0
for i, col in enumerate(y): # type: ignore[arg-type]
for i, col in enumerate(y):
hovertemplate = (
hovertemplate
if hovertemplate
@@ -390,12 +392,13 @@ def bar_chart( # noqa: PLR0912
OpenBBFigure
The OpenBBFigure object.
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.core.openbb_figure import OpenBBFigure
try:
figure = OpenBBFigure()
except Exception as _:
except (
Exception
) as _: # pragma: no cover # no-arg OpenBBFigure() has no triggerable failure
figure = OpenBBFigure(create_backend=True)
figure = figure.create_subplots(
@@ -416,7 +419,7 @@ def bar_chart( # noqa: PLR0912
if isinstance(data, (Data, list, dict)):
data = basemodel_to_df(convert_to_basemodel(data), index=None)
bar_df = data.copy().set_index(x) # type: ignore
bar_df = data.copy().set_index(x) # ty: ignore[unresolved-attribute]
y = y.split(",") if isinstance(y, str) else y
hovertemplate = bar_kwargs.pop("hovertemplate", None)
width = bar_kwargs.pop("width", None)
@@ -440,7 +443,9 @@ def bar_chart( # noqa: PLR0912
width=(
width
if width
else 0.95 / len(y) * 0.75 if barmode == "group" and len(y) > 1 else 0.95
else 0.95 / len(y) * 0.75
if barmode == "group" and len(y) > 1
else 0.95
),
**bar_kwargs,
)
@@ -506,7 +511,7 @@ def bar_chart( # noqa: PLR0912
return figure
def bar_increasing_decreasing( # pylint: disable=W0102
def bar_increasing_decreasing(
keys: list[str],
values: list[int | float],
title: str | None = None,
@@ -545,13 +550,14 @@ def bar_increasing_decreasing( # pylint: disable=W0102
OpenBBFigure
The OpenBBFigure object.
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.core.openbb_figure import OpenBBFigure # noqa
from pandas import Series
try:
figure = OpenBBFigure()
except Exception as _:
except (
Exception
) as _: # pragma: no cover # no-arg OpenBBFigure() has no triggerable failure
figure = OpenBBFigure(create_backend=True)
figure = figure.create_subplots(
@@ -566,25 +572,25 @@ def bar_increasing_decreasing( # pylint: disable=W0102
try:
data = Series(data=values, index=keys)
increasing_data = data[data > 0] # type: ignore
decreasing_data = data[data < 0] # type: ignore
increasing_data = data[data > 0]
decreasing_data = data[data < 0]
except Exception as e:
raise ValueError(f"Error: {e}") from e
if not increasing_data.empty: # type: ignore
if not increasing_data.empty:
figure.add_bar(
x=increasing_data.index if orientation == "v" else increasing_data, # type: ignore
y=increasing_data if orientation == "v" else increasing_data.index, # type: ignore
x=increasing_data.index if orientation == "v" else increasing_data,
y=increasing_data if orientation == "v" else increasing_data.index,
marker=dict(color=colors[0]),
orientation=orientation,
showlegend=False,
width=0.95 / len(keys) * 0.75 if barmode == "group" else 0.95,
hoverinfo="y" if orientation == "v" else "x",
)
if not decreasing_data.empty: # type: ignore
if not decreasing_data.empty:
figure.add_bar(
x=decreasing_data.index if orientation == "v" else decreasing_data, # type: ignore
y=decreasing_data if orientation == "v" else decreasing_data.index, # type: ignore
x=decreasing_data.index if orientation == "v" else decreasing_data,
y=decreasing_data if orientation == "v" else decreasing_data.index,
marker=dict(color=colors[1]),
orientation=orientation,
showlegend=False,
@@ -670,7 +676,6 @@ def surface3d(
OpenBBFigure
The OpenBBFigure object.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.model.abstract.error import OpenBBError # noqa
from openbb_charting.core.openbb_figure import OpenBBFigure
from numpy import vstack

View File

@@ -1,7 +1,5 @@
"""Helper functions for charting."""
# pylint: disable=R0917
from collections.abc import Callable
from typing import TYPE_CHECKING
@@ -11,7 +9,6 @@ if TYPE_CHECKING:
def get_charting_functions(view: type) -> dict[str, Callable]:
"""Discover charting functions."""
# pylint: disable=import-outside-toplevel
from inspect import getmembers, getsource, isfunction
implemented_functions: dict[str, Callable] = {}
@@ -46,7 +43,6 @@ def should_share_axis(
df: "DataFrame", col1: str, col2: str, threshold: float = 0.15
) -> bool:
"""Determine whether two columns should share an axis."""
# pylint: disable=import-outside-toplevel
from pandas import Series
try:
@@ -54,12 +50,12 @@ def should_share_axis(
df = df.to_frame()
range1 = df[col1].max() - df[col1].min()
range2 = df[col2].max() - df[col2].min()
# Calculate the ratio of the two ranges
ratio = max(range1, range2) / min(range1, range2)
# If the ratio is less than the threshold, the two columns can share an axis
if ratio == 1:
return True
return ratio < threshold
# Coerce to a built-in bool: callers use ``is True`` / ``is False``
# identity checks, which a numpy bool would always fail.
return bool(ratio < threshold)
except Exception:
return False
@@ -77,7 +73,6 @@ def heikin_ashi(data: "DataFrame") -> "DataFrame":
DataFrame
DataFrame copy with Heikin Ashi candle calculations.
"""
# pylint: disable=import-outside-toplevel
from pandas_ta import candles
df = data.copy()
@@ -115,7 +110,7 @@ def duration_sorter(durations: list) -> list:
for i in range(0, len(parts), 2):
number = int(parts[i + 1])
if parts[i] == "year":
number *= 12 # Convert years to months
number *= 12
months += number
return months

View File

@@ -1,7 +1,5 @@
"""Price historical charting utility."""
# pylint: disable=too-many-branches, too-many-locals, unused-argument
from typing import TYPE_CHECKING, Any
from openbb_charting.styles.colors import LARGE_CYCLER
@@ -14,7 +12,6 @@ def price_historical( # noqa: PLR0912
**kwargs,
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Equity Price Historical Chart."""
# pylint: disable=import-outside-toplevel
from pandas import DataFrame # noqa
from openbb_core.app.utils import basemodel_to_df # noqa
from openbb_charting.core.openbb_figure import OpenBBFigure # noqa
@@ -30,11 +27,11 @@ def price_historical( # noqa: PLR0912
if "data" in kwargs and isinstance(kwargs["data"], DataFrame):
data = kwargs["data"]
elif "data" in kwargs and isinstance(kwargs["data"], list):
data = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "date")) # type: ignore
data = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "date"))
else:
data = basemodel_to_df(
kwargs["obbject_item"],
index=kwargs.get("index", "date"), # type: ignore
index=kwargs.get("index", "date"),
)
if "date" in data.columns:
@@ -92,7 +89,7 @@ def price_historical( # noqa: PLR0912
multi_symbol = True
candles = False
volume = False
if ( # pylint: disable = R0916
if (
multi_symbol is False
and normalize is False
and returns is False
@@ -106,18 +103,18 @@ def price_historical( # noqa: PLR0912
data = heikin_ashi(data)
title = f"{title} - Heikin Ashi"
_volume = False
if "atr" in indicators: # type: ignore
if "atr" in indicators:
_volume = volume
volume = False
ta = PlotlyTA()
fig = ta.plot( # type: ignore
fig = ta.plot(
data,
indicators=indicators if indicators else {}, # type: ignore
indicators=indicators if indicators else {},
symbol=target if candles is False else "",
candles=candles,
volume=volume, # type: ignore
volume=volume,
)
if _volume is True and "atr" in indicators: # type: ignore
if _volume is True and "atr" in indicators:
fig.add_inchart_volume(data)
fig.update_layout(
showlegend=True,
@@ -182,10 +179,11 @@ def price_historical( # noqa: PLR0912
if "symbol" not in data.columns and target in data.columns:
data = data[[target]]
if "symbol" in data.columns:
# pragma: no cover # unreachable: a "symbol" column forces multi_symbol=True (line 60), so the earlier block already pivots it away when target is a column; otherwise this pivot would KeyError on the missing target.
if "symbol" in data.columns: # pragma: no cover
data = data.pivot(columns="symbol", values=target)
title: str = kwargs.get("title", "Historical Prices") # type: ignore
title: str = kwargs.get("title", "Historical Prices")
y1title = data.iloc[:, 0].name
y2title = ""
@@ -201,8 +199,8 @@ def price_historical( # noqa: PLR0912
else:
title = title + " - Normalized"
data = data.apply(z_score_standardization)
y1title = None # type: ignore
y2title = None # type: ignore
y1title = None
y2title = None
fig = OpenBBFigure()
text_color = "white" if ChartStyle().plt_style == "dark" else "black"
@@ -214,7 +212,7 @@ def price_historical( # noqa: PLR0912
yaxis = (
(
"y1"
if should_share_axis(data, col, y1title) # type: ignore
if should_share_axis(data, col, y1title)
or col == y1title
or normalize is True
or returns is True
@@ -238,12 +236,12 @@ def price_historical( # noqa: PLR0912
)
if normalize is True or returns is True:
y1title = "Percent" if returns is True else None # type: ignore
y2title = None # type: ignore
y1title = "Percent" if returns is True else None
y2title = None
if same_axis is True:
y1title = None # type: ignore
y2title = None # type: ignore
y1title = None
y2title = None
fig.update_layout(
legend=(

View File

@@ -11,7 +11,6 @@ def price_performance(
**kwargs,
) -> tuple[Union["OpenBBFigure", "Figure"], dict[str, Any]]: # noqa: PLR0912
"""Equity Price Performance Chart."""
# pylint: disable=import-outside-toplevel
from pandas import DataFrame # noqa
from openbb_core.app.utils import basemodel_to_df # noqa
from openbb_charting.charts.generic_charts import bar_chart # noqa
@@ -19,11 +18,11 @@ def price_performance(
if "data" in kwargs and isinstance(kwargs["data"], DataFrame):
data = kwargs["data"]
elif "data" in kwargs and isinstance(kwargs["data"], list):
data = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "symbol")) # type: ignore
data = basemodel_to_df(kwargs["data"], index=kwargs.get("index", "symbol"))
else:
data = basemodel_to_df(
kwargs["obbject_item"],
index=kwargs.get("index", "symbol"), # type: ignore
index=kwargs.get("index", "symbol"),
)
cols = [
@@ -66,7 +65,7 @@ def price_performance(
if "limit" in kwargs and isinstance(kwargs.get("limit"), int):
limit = kwargs.pop("limit", 10)
chart_df = chart_df.head(limit) # type: ignore
chart_df = chart_df.head(limit)
layout_kwargs: dict[str, Any] = kwargs.get("layout_kwargs", {})
@@ -83,8 +82,8 @@ def price_performance(
xtitle = None
if orientation == "h":
xtitle = ytitle # type: ignore
ytitle = None # type: ignore
xtitle = ytitle
ytitle = None
fig = bar_chart(
chart_df.reset_index(),
@@ -93,7 +92,7 @@ def price_performance(
title=title,
xtitle=xtitle,
ytitle=ytitle,
orientation=orientation, # type: ignore
orientation=orientation,
)
fig.update_traces(
hovertemplate=(
@@ -104,6 +103,6 @@ def price_performance(
)
fig.update_layout(**layout_kwargs)
content = fig.show(external=True).to_plotly_json() # type: ignore
content = fig.show(external=True).to_plotly_json()
return fig, content

View File

@@ -1,7 +1,5 @@
"""Relative Rotation Chart Helpers."""
# pylint: disable=R0917
from datetime import date as dateType
from typing import TYPE_CHECKING, Literal
from warnings import warn
@@ -104,7 +102,6 @@ def create_rrg_with_tails(
Figure
Plotly GraphObjects Figure.
"""
# pylint: disable=import-outside-toplevel
from pandas import to_datetime
from plotly import graph_objects as go
@@ -128,11 +125,10 @@ def create_rrg_with_tails(
x_max = ratios_data.max().max()
y_min = momentum_data.min().min()
y_max = momentum_data.max().max()
# Create an empty list to store the scatter traces
frames: list = []
x_data = ratios_data
y_data = momentum_data
for i, date in enumerate(ratios_data.index): # pylint: disable=unused-variable
for i, date in enumerate(ratios_data.index):
frame_data: list = []
for j, symbol in enumerate(symbols):
@@ -187,14 +183,12 @@ def create_rrg_with_tails(
frames.append(go.Frame(data=frame_data, name=f"Frame {i}"))
# Define the initial trace for the figure
initial_trace = frames[0]["data"]
padding = 0.1
y_range = [y_min - padding * abs(y_min) - 0.3, y_max + padding * abs(y_max) + 0.3]
x_range = [x_min - padding * abs(x_min) - 0.3, x_max + padding * abs(x_max) + 0.3]
# Create the layout for the figure
layout = go.Layout(
title={
"text": (
@@ -424,7 +418,6 @@ def create_rrg_with_tails(
],
)
# Create the figure and add the initial trace
fig = go.Figure(data=initial_trace, layout=layout, frames=frames)
return fig
@@ -458,7 +451,6 @@ def create_rrg_without_tails(
Figure
Plotly GraphObjects Figure.
"""
# pylint: disable=import-outside-toplevel
from plotly import graph_objects as go # noqa
from pandas import to_datetime # noqa
@@ -468,26 +460,21 @@ def create_rrg_without_tails(
if date is None:
date = ratios_data.index[-1]
# Select a single row from each dataframe
row_x = ratios_data.loc[to_datetime(date).date()] # type: ignore
row_y = momentum_data.loc[to_datetime(date).date()] # type: ignore
row_x = ratios_data.loc[to_datetime(date).date()]
row_y = momentum_data.loc[to_datetime(date).date()]
x_max = row_x.max() + 0.5
x_min = row_x.min() - 0.5
y_max = row_y.max() + 0.5
y_min = row_y.min() - 0.5
# Create an empty list to store the scatter traces
traces = []
# Loop through each column in the row_x dataframe
for i, (column_name, value_x) in enumerate(row_x.items()):
# Retrieve the corresponding value from the row_y dataframe
value_y = row_y[column_name] # type: ignore
marker_name = column_name.upper().replace("^", "").replace(":US", "") # type: ignore
value_y = row_y[column_name]
marker_name = column_name.upper().replace("^", "").replace(":US", "")
special_name = "-" in marker_name or len(marker_name) > 5
marker_size = 38 if special_name else 30
# Create a scatter trace for each column
trace = go.Scatter(
x=[value_x],
y=[value_y],
@@ -507,7 +494,6 @@ def create_rrg_without_tails(
+ "RS-Momentum: %{y:.4f}"
+ "<extra></extra>",
)
# Add the trace to the list
traces.append(trace)
padding = 0.1
@@ -518,7 +504,7 @@ def create_rrg_without_tails(
title={
"text": (
f"RS-Ratio vs RS-Momentum of {study.capitalize()} "
f"Against {benchmark_symbol.replace('^', '')} - {to_datetime(row_x.name).strftime('%Y-%m-%d')}" # type: ignore
f"Against {benchmark_symbol.replace('^', '')} - {to_datetime(row_x.name).strftime('%Y-%m-%d')}"
),
"x": 0.5,
"xanchor": "center",

View File

@@ -1,7 +1,5 @@
"""Backend for Plotly."""
# pylint: disable=R0915,R0917,W0613,C0415
from typing import TYPE_CHECKING, ClassVar
if TYPE_CHECKING:
@@ -36,7 +34,7 @@ class Backend:
from pywry import (
PyWry,
ThemeMode,
) # pylint: disable=import-outside-toplevel
)
theme = ThemeMode.LIGHT if chart_style == "light" else ThemeMode.DARK
self._app = PyWry(
@@ -48,7 +46,7 @@ class Backend:
except ImportError:
from .dummy_backend import (
DummyBackend,
) # pylint: disable=import-outside-toplevel
)
self._app = DummyBackend()
@@ -58,12 +56,12 @@ class Backend:
@staticmethod
def _register_templates() -> tuple[dict, dict]:
"""Bake OpenBB styles into plotly_dark/plotly_white and return the raw dicts."""
import plotly.graph_objects as go # pylint: disable=import-outside-toplevel
import plotly.io as pio # pylint: disable=import-outside-toplevel
import plotly.graph_objects as go
import plotly.io as pio
from openbb_charting.core.chart_style import (
ChartStyle,
) # pylint: disable=import-outside-toplevel
)
cs = ChartStyle()
dark = {}
@@ -203,7 +201,7 @@ class Backend:
Button,
Div,
Toolbar,
) # pylint: disable=import-outside-toplevel
)
icon_label = "☀" if self._is_dark else "☾"
@@ -304,7 +302,7 @@ class Backend:
return
try:
result = eval( # noqa: S307 # pylint: disable=W0123
result = eval( # noqa: S307
query_str,
{"__builtins__": {}},
{"df": self._original_df, "pd": pd},
@@ -460,7 +458,7 @@ class Backend:
height: int | None = None,
):
"""Send a URL to the PyWry window."""
import html as html_module # pylint: disable=import-outside-toplevel
import html as html_module
safe_url = html_module.escape(url, quote=True)
content = f'<meta http-equiv="refresh" content="0;url={safe_url}">'

View File

@@ -1,6 +1,5 @@
"""Chart and style helpers for Plotly."""
# pylint: disable=C0302,R0902,W3301
import json
import sys
from pathlib import Path
@@ -21,12 +20,7 @@ from openbb_charting.core.config.openbb_styles import (
class ChartStyle:
"""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.
"""
"""The class that helps with handling of style configurations."""
STYLES_REPO = Path(__file__).parent.parent / "styles"
user_styles_directory: Path = STYLES_REPO
@@ -49,10 +43,10 @@ class ChartStyle:
initialized: bool = False
instance: ClassVar["ChartStyle | None"] = None
def __new__(cls, *args, **kwargs): # pylint: disable=W0613
def __new__(cls, *args, **kwargs):
"""Create a singleton."""
if cls.instance is None:
cls.instance = super().__new__(cls) # pylint: disable=E1120
cls.instance = super().__new__(cls)
return cls.instance
def __init__(
@@ -69,7 +63,6 @@ class ChartStyle:
console_style : `str`, optional
The name of the Rich style to use, by default ""
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.service.user_service import UserService
if self.initialized:
@@ -93,7 +86,7 @@ class ChartStyle:
if style != self.plt_style:
self.load_style(style)
style = style.lower().replace("light", "white") # type: ignore
style = style.lower().replace("light", "white")
if self.plt_style and self.plotly_template:
self.plotly_template.setdefault("layout", {}).setdefault(
@@ -105,7 +98,9 @@ class ChartStyle:
try:
pio.templates["openbb"] = go.layout.Template(self.plotly_template)
except ValueError as err:
if "plotly.graph_objs.Layout: 'legend2'" in str(err):
if (
"plotly.graph_objs.Layout: 'legend2'" in str(err)
): # pragma: no cover - plotly<5.15 only; installed plotly supports legend2
warn(
"[red]Warning: Plotly multiple legends are "
"not supported in currently installed version.[/]\n\n"
@@ -128,11 +123,6 @@ class ChartStyle:
def load_available_styles_from_folder(self, folder: Path | str) -> 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

View File

@@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any, Optional
if TYPE_CHECKING:
from pandas import DataFrame
# Vsurf Plot Settings
PLT_3DMESH_COLORSCALE = "Jet"
PLT_3DMESH_SCENE = dict(
xaxis=dict(
@@ -30,7 +29,6 @@ PLT_3DMESH_SCENE = dict(
)
PLT_3DMESH_HOVERLABEL = dict(bgcolor="gold")
# Chart Plots Settings
PLT_STYLE_TEMPLATE = "plotly_dark"
PLT_STYLE_INCREASING = "#00ACFF"
PLT_STYLE_DECREASING = "#e4003a"
@@ -59,15 +57,15 @@ PLT_COLORWAY = [
]
PLT_FIB_COLORWAY: list[Any] = [
"rgb(195, 50, 69)", # 0
"rgb(130, 38, 96)", # 0.235
"rgb(120, 70, 200)", # 0.382
"rgb(0, 93, 168)", # 0.5
"rgb(173, 0, 95)", # 0.618
"rgb(235, 184, 0)", # 0.65 Golden Pocket
"rgb(162, 115, 206)", # 1
dict(family="Arial Black", size=10), # Fib's Text
dict(color="rgb(0, 230, 195)", width=0.9, dash="dash"), # Fib Trendline
"rgb(195, 50, 69)",
"rgb(130, 38, 96)",
"rgb(120, 70, 200)",
"rgb(0, 93, 168)",
"rgb(173, 0, 95)",
"rgb(235, 184, 0)",
"rgb(162, 115, 206)",
dict(family="Arial Black", size=10),
dict(color="rgb(0, 230, 195)", width=0.9, dash="dash"),
]
PLT_INCREASING_COLORWAY = [
@@ -131,7 +129,6 @@ PLT_DECREASING_COLORWAY_RED = [
]
# Table Plots Settings
PLT_TBL_HEADER = dict(
fill_color="rgb(30, 30, 30)",
font_color="white",
@@ -158,8 +155,6 @@ def de_increasing_color_list(
) -> list[str]:
"""Make a colorlist for decrease/increase if value in df_column.
Contains "{contains_str}" default is "-"
Parameters
----------
df_column : DataFrame, optional
@@ -179,7 +174,7 @@ def de_increasing_color_list(
List of colors for df_column
"""
if df_column is None:
colorlist = [decreasing_color if contains_str in text else increasing_color] # type: ignore
colorlist = [decreasing_color if contains_str in text else increasing_color] # ty: ignore[unsupported-operator]
else:
colorlist = [
decreasing_color if boolv else increasing_color
@@ -189,7 +184,6 @@ def de_increasing_color_list(
PLOTLY_THEME = dict(
# Layout
layout=dict(
colorway=PLT_COLORWAY,
font=PLOTLY_FONT,

View File

@@ -1,6 +1,5 @@
"""OpenBB Figure Class."""
# pylint: disable=C0302,R0902,W3301,R0917
import json
import textwrap
from datetime import datetime, timedelta
@@ -30,14 +29,14 @@ if TYPE_CHECKING:
from pandas import DataFrame, Series
try: # noqa: SIM105
# pylint: disable=W0611 # noqa: F401
from darts import TimeSeries # type: ignore
# noqa: F401
from darts import TimeSeries
except ImportError:
pass
TimeSeriesT = TypeVar("TimeSeriesT", bound="TimeSeries")
pio.default_renderers = "notebook" # type: ignore
pio.default_renderers = "notebook" # ty: ignore[unresolved-attribute]
class OpenBBFigure(go.Figure):
@@ -54,33 +53,10 @@ class OpenBBFigure(go.Figure):
----------
has_subplots : `bool`
Whether the figure has subplots
Class Methods
-------------
create_subplots(rows: `int`, cols: `int`, **kwargs) -> `OpenBBFigure`
Creates a subplots figure
to_table(data: `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
"""
def __init__(self, fig: go.Figure | None = None, **kwargs) -> None:
"""Initialize the OpenBBFigure."""
# pylint: disable=import-outside-toplevel
from openbb_charting.core.chart_style import ChartStyle
super().__init__()
@@ -160,7 +136,7 @@ class OpenBBFigure(go.Figure):
self._cmd_xshift = value
@classmethod
def create_subplots( # pylint: disable=too-many-arguments
def create_subplots(
cls,
rows: int = 1,
cols: int = 1,
@@ -193,7 +169,6 @@ class OpenBBFigure(go.Figure):
specs : `List[List[dict]]`, optional
Subplot specs, by default `[[{}] * cols] * rows` (all subplots are the same size)
"""
# We save the original kwargs to store them in the figure for later use
subplots_kwargs = dict(
rows=rows,
cols=cols,
@@ -206,7 +181,7 @@ class OpenBBFigure(go.Figure):
**kwargs,
)
fig = make_subplots(**subplots_kwargs) # type: ignore
fig = make_subplots(**subplots_kwargs)
kwargs = {
"multi_rows": rows > 1,
@@ -265,7 +240,7 @@ class OpenBBFigure(go.Figure):
except Exception as e:
raise ValueError(f"Error adding trend line: {e}") from e
def add_histplot( # pylint: disable=too-many-arguments,too-many-locals
def add_histplot(
self,
dataset: Union["ndarray", "Series", TimeSeriesT],
name: str | list[str] | None = None,
@@ -306,7 +281,6 @@ class OpenBBFigure(go.Figure):
col : `int`, optional
Column of the subplot, by default 1
"""
# pylint: disable=import-outside-toplevel
from numpy import linspace, mean, ndarray, std
from pandas import Series
from scipy import stats
@@ -314,14 +288,14 @@ class OpenBBFigure(go.Figure):
callback = stats.norm if curve == "normal" else stats.gaussian_kde
def _validate_x(data: ndarray | Series | type[TimeSeriesT]):
if forecast:
data = data.univariate_values() # type: ignore
if forecast: # pragma: no cover - darts forecast path (darts is an optional dependency)
data = data.univariate_values() # ty: ignore[unresolved-attribute]
if isinstance(data, Series):
data = data.to_numpy()
if isinstance(data, ndarray):
data = data.tolist()
if isinstance(data, list):
data = [data] # type: ignore
data = [data] # ty: ignore[invalid-assignment]
return data
@@ -333,12 +307,12 @@ class OpenBBFigure(go.Figure):
if isinstance(colors, str):
colors = [colors]
if not name:
name = [None] * len(valid_x) # type: ignore
name = [None] * len(valid_x) # ty: ignore[invalid-assignment]
if not colors:
colors = [None] * len(valid_x) # type: ignore
colors = [None] * len(valid_x) # ty: ignore[invalid-assignment]
max_y = 0
for i, (x_i, name_i, color_i) in enumerate(zip(valid_x, name, colors)): # type: ignore
for i, (x_i, name_i, color_i) in enumerate(zip(valid_x, name, colors)): # ty: ignore[invalid-argument-type, not-iterable]
if not color_i:
color_i = ( # noqa: PLW2901
self._theme.up_color if i % 2 == 0 else self._theme.down_color
@@ -348,16 +322,16 @@ class OpenBBFigure(go.Figure):
res_min, res_max = min(x_i), max(x_i)
x = 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
if forecast: # pragma: no cover - darts forecast path (darts is an optional dependency)
components = list(dataset.components[:4]) # ty: ignore[unresolved-attribute]
values = (
dataset[components].all_values(copy=False).flatten(order="F")
)
n_components = len(components)
n_entries = len(values) // n_components
for i2, label in zip(range(n_components), components):
self.add_histogram(
x=values[
i2 * n_entries : (i2 + 1) * n_entries
], # noqa: E203 # noqa: E203
x=values[i2 * n_entries : (i2 + 1) * n_entries], # noqa: E203 # noqa: E203
name=label,
marker_color=color_i,
nbinsx=bins,
@@ -382,7 +356,7 @@ class OpenBBFigure(go.Figure):
self.add_scatter(
x=x_i,
y=[0.00002] * len(x_i),
name=name_i if len(name) < 2 else name[1], # type: ignore
name=name_i if len(name) < 2 else name[1], # ty: ignore[invalid-argument-type, not-subscriptable]
mode="markers",
marker=dict(
color=self._theme.down_color,
@@ -393,20 +367,21 @@ class OpenBBFigure(go.Figure):
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] = [res_min + xx * (res_max - res_min) / 500 for xx in range(500)] # type: ignore
curve_x[index] = [
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
x=curve_x[index],
y=curve_y[index],
name=name_i,
mode="lines",
showlegend=False,
@@ -414,11 +389,11 @@ class OpenBBFigure(go.Figure):
row=row,
col=col,
)
max_y = max(max_y, max(curve_y[index]) * 1.2) # type: ignore
max_y = max(max_y, max(curve_y[index]) * 1.2) # ty: ignore[invalid-argument-type]
else:
y = (
callback(res_mean, res_std).pdf(x) # type: ignore
callback(res_mean, res_std).pdf(x)
* len(valid_x[0])
* (res_max - res_min)
/ bins
@@ -646,11 +621,10 @@ class OpenBBFigure(go.Figure):
Dict[str, list]
{"range": volume_range, "ticks": tickvals}
"""
# pylint: disable=import-outside-toplevel
from pandas import Series, to_numeric
df_volume = df_volume.apply(lambda x: f"{x:.1f}") # type: ignore
df_volume = to_numeric(df_volume.astype(float)) # type: ignore
df_volume = df_volume.apply(lambda x: f"{x:.1f}")
df_volume = to_numeric(df_volume.astype(float))
if isinstance(df_volume, Series):
df_volume = df_volume.to_frame()
@@ -700,7 +674,6 @@ class OpenBBFigure(go.Figure):
volume_ticks_x : int, optional
Number to multiply volume, by default 7
"""
# pylint: disable=import-outside-toplevel
from numpy import where
colors = where(
@@ -708,7 +681,7 @@ class OpenBBFigure(go.Figure):
self._theme.up_color,
self._theme.down_color,
)
vol_scale = self.chart_volume_scaling(df_stock[volume_col], volume_ticks_x) # type: ignore
vol_scale = self.chart_volume_scaling(df_stock[volume_col], volume_ticks_x)
self.add_bar(
x=df_stock.index,
y=df_stock[volume_col],
@@ -784,16 +757,20 @@ class OpenBBFigure(go.Figure):
"""
if trace:
for trace_ in self.data:
if trace_.name == trace: # type: ignore
if trace_.name == trace:
for arg, default in zip(
[label, mode, marker, line_dash],
[trace, trace_.mode, trace_.marker, trace_.line_dash], # type: ignore
[trace, trace_.mode, trace_.marker, trace_.line_dash],
):
if not arg and default:
if (
not arg and default
): # pragma: no cover - dead: trace_.line_dash raises AttributeError above
arg = default # noqa: PLW2901
kwargs.update(dict(yaxis=trace_.yaxis)) # type: ignore
break
kwargs.update(
dict(yaxis=trace_.yaxis)
) # pragma: no cover - dead: trace_.line_dash raises AttributeError above
break # pragma: no cover - dead: trace_.line_dash raises AttributeError above
else:
raise ValueError(f"Trace '{trace}' not found")
@@ -815,7 +792,7 @@ class OpenBBFigure(go.Figure):
self,
*args,
external: bool = False,
export_image: Path | str | None = "", # pylint: disable=W0613
export_image: Path | str | None = "",
**kwargs,
) -> "OpenBBFigure":
"""Show the figure.
@@ -860,10 +837,10 @@ class OpenBBFigure(go.Figure):
)
if external:
return self # type: ignore
return self
if getattr(self._charting_settings, "headless", False):
return self.to_json() # type: ignore
return self.to_json()
command_location = kwargs.pop("command_location", "")
try:
@@ -874,7 +851,7 @@ class OpenBBFigure(go.Figure):
except Exception as e:
warn(f"Failed to show figure with backend. {e}")
return pio.show(self, *args, **kwargs) # type: ignore
return pio.show(self, *args, **kwargs)
def _xaxis_tickformatstops(self) -> None:
"""Set the datetickformatstops for the xaxis if the x data is datetime."""
@@ -889,8 +866,6 @@ class OpenBBFigure(go.Figure):
]
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 (
not hasattr(dateindex[-1], "time")
or dateindex[-1].time() == dateindex[-2].time()
@@ -927,7 +902,7 @@ class OpenBBFigure(go.Figure):
if not self.has_subplots:
return subplots
grid_ref = self._validate_get_grid_ref() # pylint: disable=protected-access
grid_ref = self._validate_get_grid_ref()
for r, plot_row in enumerate(grid_ref):
for c, plot_refs in enumerate(plot_row):
if not plot_refs:
@@ -953,7 +928,6 @@ class OpenBBFigure(go.Figure):
`list`
The dateindex
"""
# pylint: disable=import-outside-toplevel
from numpy import datetime64
from pandas import DatetimeIndex, to_datetime
@@ -994,7 +968,6 @@ class OpenBBFigure(go.Figure):
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], datetime64):
output = (
to_datetime(output).to_pydatetime().astype("datetime64[ms]").tolist()
@@ -1019,26 +992,20 @@ class OpenBBFigure(go.Figure):
col : `int`, optional
The column of the subplot to hide the gaps, by default None
"""
# pylint: disable=import-outside-toplevel
from pandas import date_range, to_datetime
# We get the min and max dates
dt_start, dt_end = df_data.index.min(), df_data.index.max()
rangebreaks: list[dict[str, Any]] = []
# if weekly or monthly data, we don't need to hide gaps
# this prevents distortions in the plot
check_freq = df_data.index.to_series().diff(-5).dt.days.abs().mode().iloc[0]
if check_freq > 7:
return
# We get the missing days
is_daily = df_data.index[-1].time() == df_data.index[-2].time()
dt_days = 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")) - set(df_data.index.strftime("%Y-%m-%d")) # type: ignore
set(dt_days.strftime("%Y-%m-%d")) - set(df_data.index.strftime("%Y-%m-%d")) # ty: ignore[unresolved-attribute]
)
dt_missing_days = to_datetime(dt_missing_days)
@@ -1046,8 +1013,6 @@ class OpenBBFigure(go.Figure):
rangebreaks = [dict(values=dt_missing_days)]
df_data = df_data.sort_index()
# 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 not is_daily:
for i in range(len(df_data) - 1):
if df_data.index[i + 1] - df_data.index[i] > timedelta(hours=2):
@@ -1066,7 +1031,6 @@ class OpenBBFigure(go.Figure):
def add_rangebreaks(self) -> None:
"""Add rangebreaks to hide datetime gaps on the xaxis."""
# pylint: disable=import-outside-toplevel
from numpy import concatenate
from pandas import DataFrame, to_datetime
@@ -1076,7 +1040,7 @@ class OpenBBFigure(go.Figure):
for row, row_dict in self._subplot_xdates.items():
for col, values in row_dict.items():
try:
x_values = to_datetime(concatenate(values)).to_pydatetime()
x_values = to_datetime(concatenate(values)).to_pydatetime() # ty: ignore[unresolved-attribute]
self.hide_date_gaps(
DataFrame(index=x_values.tolist()),
row=row,
@@ -1113,7 +1077,7 @@ class OpenBBFigure(go.Figure):
"""
for trace in self.data:
if kwargs:
trace.update(**kwargs) # type: ignore
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)
@@ -1184,8 +1148,6 @@ class OpenBBFigure(go.Figure):
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
@@ -1255,13 +1217,10 @@ class OpenBBFigure(go.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,
@@ -1271,7 +1230,6 @@ class OpenBBFigure(go.Figure):
),
)
# we add a percentage of max to the min column width
columnwidth = [
int(x + (max(columnwidth) - min(columnwidth)) * 0.2)
for x in columnwidth
@@ -1327,7 +1285,6 @@ class OpenBBFigure(go.Figure):
self._margin_adjusted = True
# pylint: disable=import-outside-toplevel
def _add_cmd_source(self, command_location: str | None = "") -> None:
"""Set the watermark for OpenBB Terminal."""
if command_location:
@@ -1369,7 +1326,6 @@ class OpenBBFigure(go.Figure):
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:
@@ -1382,8 +1338,8 @@ 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
bg_color = "#000000" if self._theme.mapbox_style == "dark" else "#FFFFFF"
font_color = "#FFFFFF" if self._theme.mapbox_style == "dark" else "#000000"
self.update_layout(
xaxis=dict(
rangeslider=dict(visible=False),
@@ -1443,7 +1399,7 @@ class OpenBBFigure(go.Figure):
],
)
def add_corr_plot( # pylint: disable=too-many-arguments
def add_corr_plot(
self,
series: "DataFrame",
max_lag: int = 20,
@@ -1476,7 +1432,6 @@ class OpenBBFigure(go.Figure):
pacf : bool, optional
Flag to indicate whether to use partial autocorrelation or not, by default False
"""
# pylint: disable=import-outside-toplevel
import statsmodels.api as sm # noqa
from numpy import arange, asanyarray, ceil, log10, isscalar # noqa
@@ -1487,15 +1442,14 @@ class OpenBBFigure(go.Figure):
zero = True
irregular = False
if lags is None:
# GH 4663 - use a sensible default value
nobs = x.shape[0]
lim = min(int(ceil(10 * log10(nobs))), nobs - 1)
lags = arange(not zero, lim + 1)
elif isscalar(lags):
lags = arange(
not zero,
int(lags) + 1, # type: ignore
) # +1 for zero lag
int(lags) + 1, # ty: ignore[invalid-argument-type]
)
else:
irregular = True
lags = asanyarray(lags).astype(int)
@@ -1510,13 +1464,13 @@ class OpenBBFigure(go.Figure):
kwargs.update(dict(fft=False))
acf_x = callback(
series, # type: ignore
series, # ty: ignore[invalid-argument-type]
nlags=nlags,
alpha=alpha,
**kwargs,
)
acf_x, confint = acf_x[:2] if not pacf else acf_x # type: ignore
acf_x, confint = acf_x[:2] if not pacf else acf_x
if irregular:
acf_x = acf_x[lags]
@@ -1534,7 +1488,6 @@ class OpenBBFigure(go.Figure):
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),
@@ -1578,5 +1531,5 @@ class OpenBBFigure(go.Figure):
)
self.update_traces(showlegend=False)
except ValueError:
except ValueError: # pragma: no cover - defensive; real statsmodels acf/pacf output is consistent
pass

View File

@@ -53,6 +53,7 @@ class PluginMeta(type):
"""Create a new instance of the class."""
name, bases, attrs = args
indicators: dict[str, Indicator] = {}
static_methods: list[str] = []
cls_attrs: dict[str, list] = {
"__ma_mode__": [],
"__inchart__": [],
@@ -73,25 +74,24 @@ class PluginMeta(type):
f"Indicator {value.name} can't be a static method"
)
indicators[value.name] = value
elif is_static_method and elem not in new_cls.__static_methods__:
new_cls.__static_methods__.append(elem)
elif is_static_method and elem not in static_methods:
static_methods.append(elem)
if elem in ["__ma_mode__", "__inchart__", "__subplots__"]:
cls_attrs[elem].extend(value)
new_cls.__indicators__ = list(indicators.values())
new_cls.__static_methods__ = list(set(new_cls.__static_methods__))
new_cls.__static_methods__ = static_methods
new_cls.__ma_mode__ = list(set(cls_attrs["__ma_mode__"]))
new_cls.__inchart__ = list(set(cls_attrs["__inchart__"]))
new_cls.__subplots__ = list(set(cls_attrs["__subplots__"]))
return new_cls
def __iter__(cls: type["PluginMeta"]) -> Iterator[Indicator]: # type: ignore
def __iter__(cls: type["PluginMeta"]) -> Iterator[Indicator]:
"""Iterate over the indicators."""
return iter(cls.__indicators__)
# pylint: disable=unused-argument
def __init__(cls, *args: Any, **kwargs: Any) -> None:
"""Initialize the class."""
super().__init__(*args, **kwargs)
@@ -116,24 +116,15 @@ class PltTA(metaclass=PluginMeta):
__inchart__: list[str] = []
__subplots__: list[str] = []
# pylint: disable=unused-argument
def __new__(cls, *args: Any, **kwargs: Any) -> "PltTA":
"""Create a new instance of the class."""
if cls is PltTA:
raise TypeError("Can't instantiate abstract class Plugin directly")
self = super().__new__(cls)
static_methods = cls.__static_methods__
indicators = cls.__indicators__
for item in indicators:
# we make sure that the indicator is bound to the instance
for item in cls.__indicators__:
if not hasattr(self, item.name):
setattr(self, item.name, item.func.__get__(self, cls))
for static_method in static_methods:
if not hasattr(self, static_method):
setattr(self, static_method, staticmethod(getattr(self, static_method)))
setattr(self, item.name, item.func.__get__(self, cls)) # ty: ignore[unresolved-attribute]
for attr, value in cls.__dict__.items():
if attr in [
@@ -158,16 +149,14 @@ class PltTA(metaclass=PluginMeta):
"""Add plugins to current instance."""
for plugin in plugins:
for item in plugin.__indicators__:
# pylint: disable=unnecessary-dunder-call
if not hasattr(self, item.name):
setattr(self, item.name, item.func.__get__(self, type(self)))
setattr(self, item.name, item.func.__get__(self, type(self))) # ty: ignore[unresolved-attribute]
self.__indicators__.append(item)
for static_method in plugin.__static_methods__:
if not hasattr(self, static_method):
setattr(
self, static_method, staticmethod(getattr(self, static_method))
)
setattr(self, static_method, getattr(plugin, static_method))
for attr, value in plugin.__dict__.items():
if attr in [
"__ma_mode__",
@@ -184,7 +173,8 @@ class PltTA(metaclass=PluginMeta):
self.__indicators__.remove(item)
for static_method in plugin.__static_methods__:
delattr(self, static_method)
if static_method in self.__dict__:
delattr(self, static_method)
def __iter__(self) -> Iterator[Indicator]:
"""Iterate over the indicators."""
@@ -204,13 +194,8 @@ def indicator(
**attrs: Any,
) -> Callable:
"""Use this decorator for adding indicators to a plugin class."""
attrs["name"] = name
def decorator(func: Callable) -> Indicator:
if not attrs.pop("name", ""):
name = func.__name__
# pylint: disable=possibly-used-before-assignment
return Indicator(func, name, **attrs)
return Indicator(func, name or func.__name__, **attrs) # ty: ignore[unresolved-attribute]
return decorator

View File

@@ -1,7 +1,5 @@
"""Dataclasses for the charting extension."""
# pylint: disable=C0302,R0915,R0914,R0913,R0903,R0904
import sys
import warnings
from dataclasses import dataclass
@@ -10,7 +8,6 @@ from typing import TYPE_CHECKING, Any, Literal, Union
if TYPE_CHECKING:
from pandas import DataFrame, Series
# pylint: disable=E1123
datacls_kwargs = {"slots": True} if sys.version_info >= (3, 10) else {}
@@ -95,7 +92,7 @@ class ChartIndicators:
def get_indicator(self, name: str) -> TAIndicator | None:
"""Return indicator with given name."""
output = None
for indicator in self.indicators: # type: ignore
for indicator in self.indicators: # ty: ignore[not-iterable]
if indicator.name == name:
output = indicator
return output
@@ -146,40 +143,22 @@ class ChartIndicators:
for opt in options:
output[opt] = self.get_indicator_args(name, opt)
return output
return output # ty: ignore[invalid-return-type]
@staticmethod
def get_available_indicators() -> tuple[str, ...]:
"""Return tuple of available indicators."""
return tuple(
TAIndicator.__annotations__["name"].__args__
) # pylint: disable=E1101
return tuple(TAIndicator.__annotations__["name"].__args__)
@classmethod
def from_dict(
cls, indicators: dict[str, dict[str, list[dict[str, Any]]]]
) -> "ChartIndicators":
"""Return ChartIndicators from dictionary.
Example
-------
ChartIndicators.from_dict(
{
"ad": {
"args": [
{
"label": "AD_LABEL",
"values": [1, 2, 3],
}
]
}
}
)
"""
"""Return ChartIndicators from dictionary."""
return cls(
indicators=[
TAIndicator(
name=name, # type: ignore[arg-type]
name=name, # ty: ignore[invalid-argument-type]
args=[
Arguments(label=label, values=values)
for label, values in args.items()
@@ -226,8 +205,7 @@ class TA_DataException(Exception):
class TA_Data:
"""
Process technical analysis data.
"""Process technical analysis data.
Parameters
----------
@@ -242,13 +220,6 @@ class TA_Data:
macd=dict(fast=12, slow=26, signal=9),
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__(
@@ -258,7 +229,6 @@ class TA_Data:
ma_mode: list[str] | None = None,
):
"""Initialize."""
# pylint: disable=import-outside-toplevel
from pandas import DataFrame, Series # noqa
from openbb_charting.core.plotly_ta.ta_helpers import check_columns # noqa
@@ -273,7 +243,9 @@ class TA_Data:
self.ma_mode: list[str] = ma_mode or ["sma", "ema", "wma", "hma", "zlma", "rma"]
self.close_col = check_columns(df_ta)
if self.close_col is None:
raise ValueError("No close column found in dataframe")
raise ValueError( # pragma: no cover # check_columns returns a str or raises, never None
"No close column found in dataframe"
)
self.columns: dict[str, list[str]] = {
"ad": ["high", "low", self.close_col, "volume"],
@@ -293,8 +265,7 @@ 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) -> "DataFrame":
"""
Return dataframe with indicator data.
"""Return dataframe with indicator data.
Parameters
----------
@@ -302,13 +273,7 @@ class TA_Data:
TAIndicator object
args : dict
Arguments for given indicator
Return
-------
DataFrame
Dataframe with indicator data
"""
# pylint: disable=import-outside-toplevel
import pandas_ta as ta
from pandas import DataFrame
@@ -335,14 +300,14 @@ class TA_Data:
elif indicator.name == "vwap":
ta_columns = self.columns[indicator.name]
ta_columns = [self.df_ta[col] for col in ta_columns] # type: ignore
ta_columns = [self.df_ta[col] for col in ta_columns]
output = getattr(ta, indicator.name)(
*ta_columns,
)
elif indicator.name in self.columns:
ta_columns = self.columns[indicator.name]
ta_columns = [self.df_ta[col] for col in ta_columns] # type: ignore
ta_columns = [self.df_ta[col] for col in ta_columns]
if indicator.get_argument_values("use_open") is True:
ta_columns.append(self.df_ta["open"])
@@ -351,20 +316,19 @@ class TA_Data:
else:
output = getattr(ta, indicator.name)(self.df_ta[self.close_col], **args)
# Drop NaN values from output and return None if empty
if output is not None:
output.dropna(inplace=True)
if output.empty:
output = None
return output
return output # ty: ignore[invalid-return-type]
def to_dataframe(self) -> "DataFrame":
"""Return dataframe with all indicators."""
active_indicators = self.indicators.get_indicators()
if not active_indicators:
return None
return None # ty: ignore[invalid-return-type]
output = self.df_ta
for indicator in active_indicators:

View File

@@ -17,21 +17,33 @@ class Custom(PltTA):
@indicator()
def plot_srlines(self, fig: OpenBBFigure, df_ta: pd.DataFrame):
"""Add support and resistance lines to plotly figure."""
window = self.params["srlines"].get_argument_values("window") # type: ignore
window = self.params["srlines"].get_argument_values("window") # ty: ignore[not-subscriptable]
window = window[0] if isinstance(window, list) and len(window) > 0 else 200
def is_far_from_level(value, levels, df_stock):
def is_far_from_level(
value, levels, df_stock
): # pragma: no cover # only called from the support/resistance loop body, which is unreachable (see is_support)
ave = np.mean(df_stock["high"] - df_stock["low"])
return np.sum([abs(value - level) < ave for _, level in levels]) == 0
def is_support(df, i):
cond1 = df["low"][i] < df["low"][i - 1]
cond2 = df["low"][i] < df["low"][i + 1]
cond3 = df["low"][i + 1] < df["low"][i + 2]
cond4 = df["low"][i - 1] < df["low"][i - 2]
return cond1 and cond2 and cond3 and cond4
cond2 = (
df["low"][i] < df["low"][i + 1]
) # pragma: no cover # df["low"][i] is positional indexing that raises KeyError on the DatetimeIndex srlines always receives (pandas 3.x), so cond1 raises and cond2-4 never run
cond3 = (
df["low"][i + 1] < df["low"][i + 2]
) # pragma: no cover # unreachable: cond1 raises before here
cond4 = (
df["low"][i - 1] < df["low"][i - 2]
) # pragma: no cover # unreachable: cond1 raises before here
return (
cond1 and cond2 and cond3 and cond4
) # pragma: no cover # unreachable: cond1 raises before here
def is_resistance(df, i):
def is_resistance(
df, i
): # pragma: no cover # never called: is_support raises before the elif is reached
cond1 = df["high"][i] > df["high"][i - 1]
cond2 = df["high"][i] > df["high"][i + 1]
cond3 = df["high"][i + 1] > df["high"][i + 2]
@@ -51,7 +63,7 @@ class Custom(PltTA):
if interval <= 15:
cut_days = 1 if interval < 15 else 2
dt_unique_days = df_ta2.index.normalize().unique() # type: ignore
dt_unique_days = df_ta2.index.normalize().unique() # ty: ignore[unresolved-attribute]
df_ta2 = df_ta2.loc[
(df_ta2.index >= pd.to_datetime(dt_unique_days[-cut_days], unit="ns"))
& (df_ta2.index < today)
@@ -71,10 +83,18 @@ class Custom(PltTA):
for i in range(2, len(df_ta2) - 2):
if is_support(df_ta2, i):
lv = df_ta2["low"][i]
if is_far_from_level(lv, levels, df_ta2):
levels.append((i, lv))
fig.add_scatter(
lv = df_ta2[
"low"
][
i
] # pragma: no cover # unreachable: is_support raises (KeyError) for every i on the DatetimeIndex, so the loop body never runs
if is_far_from_level(
lv, levels, df_ta2
): # pragma: no cover # unreachable: is_support raises before here
levels.append(
(i, lv)
) # pragma: no cover # unreachable: is_support raises before here
fig.add_scatter( # pragma: no cover # unreachable: is_support raises before here
x=[df_ta.index[0], x_range],
y=[lv, lv],
opacity=0.8,
@@ -93,7 +113,9 @@ class Custom(PltTA):
col=1,
secondary_y=False,
)
elif is_resistance(df_ta2, i):
elif is_resistance(
df_ta2, i
): # pragma: no cover # unreachable: is_support raises before the elif is evaluated
lv = df_ta2["high"][i]
if is_far_from_level(lv, levels, df_ta2):
levels.append((i, lv))
@@ -123,7 +145,7 @@ class Custom(PltTA):
def plot_fib(self, fig: OpenBBFigure, df_ta: pd.DataFrame):
"""Add fibonacci to plotly figure."""
try:
from openbb_technical.helpers import ( # pylint: disable=import-outside-toplevel
from openbb_technical.helpers import (
calculate_fib_levels,
)
except ImportError:
@@ -133,10 +155,10 @@ class Custom(PltTA):
)
return fig
limit = self.params["fib"].get_argument_values("limit") or 120 # type: ignore
start_date = self.params["fib"].get_argument_values("start_date") or None # type: ignore
end_date = self.params["fib"].get_argument_values("end_date") or None # type: ignore
close = self.params["fib"].get_argument_values("close") or "close" # type: ignore
limit = self.params["fib"].get_argument_values("limit") or 120 # ty: ignore[not-subscriptable]
start_date = self.params["fib"].get_argument_values("start_date") or None # ty: ignore[not-subscriptable]
end_date = self.params["fib"].get_argument_values("end_date") or None # ty: ignore[not-subscriptable]
close = self.params["fib"].get_argument_values("close") or "close" # ty: ignore[not-subscriptable]
(
df_fib,
min_date,
@@ -146,10 +168,10 @@ class Custom(PltTA):
lvl_text,
) = calculate_fib_levels(
df_ta,
close,
limit,
close, # ty: ignore[invalid-argument-type]
limit, # ty: ignore[invalid-argument-type]
start_date,
end_date, # type: ignore
end_date,
)
levels = df_fib.Price
fibs = [
@@ -163,7 +185,7 @@ class Custom(PltTA):
]
min_date = pd.to_datetime(min_date).to_pydatetime()
max_date = pd.to_datetime(max_date).to_pydatetime()
self.df_fib = df_fib # pylint: disable=attribute-defined-outside-init
self.df_fib = df_fib
fig.add_scatter(
x=[min_date, max_date],
@@ -181,7 +203,7 @@ class Custom(PltTA):
interval = 1440
if df_ta2.index[-2].date() == df_ta2.index[-1].date():
interval = (df_ta2.index[1] - df_ta2.index[0]).seconds / 60
dt_unique_days = df_ta2.index.normalize().unique() # type: ignore
dt_unique_days = df_ta2.index.normalize().unique() # ty: ignore[unresolved-attribute]
if interval not in [15, 30, 60] and len(dt_unique_days) <= 3:
df_ta2 = df_ta2.loc[

View File

@@ -47,8 +47,8 @@ class Momentum(PltTA):
opacity=0.2,
layer="below",
line_width=0,
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig.add_hrect(
@@ -58,8 +58,8 @@ class Momentum(PltTA):
opacity=0.2,
layer="below",
line_width=0,
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig.add_hline(
@@ -67,8 +67,8 @@ class Momentum(PltTA):
opacity=1,
layer="below",
line=dict(width=2, color=fig.theme.down_color, dash="dash"),
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig.add_hline(
@@ -76,8 +76,8 @@ class Momentum(PltTA):
opacity=1,
layer="below",
line=dict(width=2, color=fig.theme.up_color, dash="dash"),
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
@@ -92,7 +92,7 @@ class Momentum(PltTA):
font_size=14,
font_color="#e0b700",
)
fig["layout"][f"yaxis{subplot_row + 1}"].update(nticks=5, autorange=True) # type: ignore
fig["layout"][f"yaxis{subplot_row + 1}"].update(nticks=5, autorange=True)
return fig, subplot_row + 1
@@ -145,7 +145,7 @@ class Momentum(PltTA):
font_size=14,
font_color="#ef7d00",
)
fig["layout"][f"yaxis{subplot_row + 1}"].update(nticks=5, autorange=True) # type: ignore
fig["layout"][f"yaxis{subplot_row + 1}"].update(nticks=5, autorange=True)
return fig, subplot_row + 1
@@ -153,7 +153,7 @@ class Momentum(PltTA):
def plot_clenow(self, fig: OpenBBFigure, df_ta: pd.DataFrame, inchart_index: int):
"""Add current close price to plotly figure."""
try:
from openbb_technical.helpers import ( # pylint: disable=import-outside-toplevel
from openbb_technical.helpers import (
clenow_momentum,
)
except ImportError:
@@ -163,11 +163,11 @@ class Momentum(PltTA):
)
return fig, inchart_index
window = self.params["clenow"].get_argument_values("window") or 90 # type: ignore
_, _, fit_data = clenow_momentum(df_ta[self.close_column], window=window) # type: ignore
window = self.params["clenow"].get_argument_values("window") or 90 # ty: ignore[not-subscriptable]
_, _, fit_data = clenow_momentum(df_ta[self.close_column], window=window) # ty: ignore[invalid-argument-type]
fig.add_scatter(
x=df_ta.index[-window:], # type: ignore
x=df_ta.index[-window:], # ty: ignore[unsupported-operator]
y=pow(np.e, fit_data),
name="CLenow",
mode="lines",
@@ -194,22 +194,19 @@ class Momentum(PltTA):
@indicator()
def plot_demark(self, fig: OpenBBFigure, df_ta: pd.DataFrame, inchart_index: int):
"""Add demark to plotly figure."""
min_val = self.params["demark"].get_argument_values("min_val") or 5 # type: ignore
min_val = self.params["demark"].get_argument_values("min_val") or 5 # ty: ignore[not-subscriptable]
demark = ta.td_seq(df_ta[self.close_column], asint=True)
demark = ta.td_seq(df_ta[self.close_column], asint=True) # ty: ignore[unresolved-attribute]
demark = demark.set_index(df_ta.index)
# pylint: disable=unsupported-assignment-operation
demark["up"] = demark.TD_SEQ_UPa.apply(
lambda x: f"<b>{x}</b>" if x >= min_val else None
)
# pylint: disable=unsupported-assignment-operation
demark["down"] = demark.TD_SEQ_DNa.apply(
lambda x: f"<b>{x}</b>" if x >= min_val else None
)
# we only keep the values that are not None in up/down columns
high = df_ta["high"][demark["up"].notnull()]
low = df_ta["low"][demark["down"].notnull()]
demark = demark[demark["up"].notnull() | demark["down"].notnull()]
@@ -314,8 +311,8 @@ class Momentum(PltTA):
opacity=0.2,
layer="below",
line_width=0,
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig.add_hrect(
@@ -325,8 +322,8 @@ class Momentum(PltTA):
opacity=0.2,
layer="below",
line_width=0,
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig.add_hline(
@@ -335,8 +332,8 @@ class Momentum(PltTA):
opacity=1,
layer="below",
line=dict(width=2, color=fig.theme.down_color, dash="dash"),
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig.add_hline(
@@ -345,11 +342,11 @@ class Momentum(PltTA):
opacity=1,
layer="below",
line=dict(width=2, color=fig.theme.up_color, dash="dash"),
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig["layout"][f"yaxis{subplot_row + 1}"].update(nticks=5, autorange=True) # type: ignore
fig["layout"][f"yaxis{subplot_row + 1}"].update(nticks=5, autorange=True)
return fig, subplot_row + 1
@@ -411,7 +408,7 @@ class Momentum(PltTA):
xshift=-3,
font_color="rgb(7, 166, 52)",
)
fig["layout"][f"yaxis{subplot_row + 1}"].update(autorange=True, nticks=5) # type: ignore
fig["layout"][f"yaxis{subplot_row + 1}"].update(autorange=True, nticks=5)
return fig, subplot_row + 1
@@ -449,8 +446,8 @@ class Momentum(PltTA):
opacity=0.2,
layer="below",
line_width=0,
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig.add_hrect(
@@ -460,8 +457,8 @@ class Momentum(PltTA):
opacity=0.2,
layer="below",
line_width=0,
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig.add_hline(
@@ -470,8 +467,8 @@ class Momentum(PltTA):
opacity=1,
layer="below",
line=dict(width=2, color=fig.theme.down_color, dash="dash"),
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig.add_hline(
@@ -480,11 +477,11 @@ class Momentum(PltTA):
opacity=1,
layer="below",
line=dict(width=2, color=fig.theme.up_color, dash="dash"),
row=subplot_row, # type: ignore
col=1, # type: ignore
row=subplot_row,
col=1,
secondary_y=False,
)
fig["layout"][f"yaxis{subplot_row + 1}"].update(tickvals=[0, 30, 70, 100]) # type: ignore
fig["layout"][f"yaxis{subplot_row + 1}"].update(tickvals=[0, 30, 70, 100])
return fig, subplot_row + 1
@@ -525,46 +522,43 @@ class Momentum(PltTA):
font_size=14,
font_color="#e0b700",
)
fig["layout"][f"yaxis{subplot_row + 1}"].update(nticks=5, autorange=True) # type: ignore
fig["layout"][f"yaxis{subplot_row + 1}"].update(nticks=5, autorange=True)
return fig, subplot_row + 1
@indicator()
def plot_ichimoku(self, fig: OpenBBFigure, df_ta: pd.DataFrame, inchart_index: int):
"""Calculate Ichimoku indicator."""
conversion_period = self.params["ichimoku"].get_argument_values("conversion_period") or 9 # type: ignore
base_period = self.params["ichimoku"].get_argument_values("base_period") or 26 # type: ignore
lagging_line_period = self.params["ichimoku"].get_argument_values("lagging_line_period") or 52 # type: ignore
displacement = self.params["ichimoku"].get_argument_values("displacement") or 26 # type: ignore
conversion_period = (
self.params["ichimoku"].get_argument_values("conversion_period") or 9 # ty: ignore[not-subscriptable]
)
base_period = self.params["ichimoku"].get_argument_values("base_period") or 26 # ty: ignore[not-subscriptable]
lagging_line_period = (
self.params["ichimoku"].get_argument_values("lagging_line_period") or 52 # ty: ignore[not-subscriptable]
)
displacement = self.params["ichimoku"].get_argument_values("displacement") or 26 # ty: ignore[not-subscriptable]
# Tenkan-sen (Conversion Line)
conversion_line = (
df_ta["high"].rolling(window=conversion_period).max() # type: ignore
+ df_ta["low"].rolling(window=conversion_period).min() # type: ignore
df_ta["high"].rolling(window=conversion_period).max()
+ df_ta["low"].rolling(window=conversion_period).min()
) / 2
# Kijun-sen (Base Line)
base_line = (
df_ta["high"].rolling(window=base_period).max() # type: ignore
+ df_ta["low"].rolling(window=base_period).min() # type: ignore
df_ta["high"].rolling(window=base_period).max()
+ df_ta["low"].rolling(window=base_period).min()
) / 2
# Senkou Span A (Leading Span A)
leading_span_a = ((conversion_line + base_line) / 2).shift(displacement) # type: ignore
leading_span_a = ((conversion_line + base_line) / 2).shift(displacement)
# Senkou Span B (Leading Span B)
lagging_line = df_ta[self.close_column].shift(-lagging_line_period) # type: ignore
lagging_line = df_ta[self.close_column].shift(-lagging_line_period) # ty: ignore[unsupported-operator]
leading_span_b = (
(
lagging_line.rolling(window=base_period).max() # type: ignore
+ lagging_line.rolling(window=base_period).min() # type: ignore
lagging_line.rolling(window=base_period).max()
+ lagging_line.rolling(window=base_period).min()
)
/ 2
).shift(
displacement # type: ignore
)
).shift(displacement)
# Plot Tenkan-sen and Kijun-sen
fig.add_scatter(
x=df_ta.index,
y=conversion_line,
@@ -584,7 +578,6 @@ class Momentum(PltTA):
opacity=1,
)
# Plot Senkou Span A and Senkou Span B as a filled area
fig.add_scatter(
x=df_ta.index,
y=leading_span_a,

View File

@@ -167,7 +167,7 @@ class Volatility(PltTA):
@indicator()
def plot_kc(self, fig: OpenBBFigure, df_ta: pd.DataFrame, inchart_index: int):
"""Add Keltner channels to plotly figure."""
mamode = (self.params["kc"].get_argument_values("mamode") or "ema").lower() # type: ignore
mamode = (self.params["kc"].get_argument_values("mamode") or "ema").lower() # ty: ignore[not-subscriptable, unresolved-attribute]
if fig.theme.plt_style == "light":
fillcolor = "rgba(239, 103, 137, 0.05)"

View File

@@ -17,10 +17,6 @@ class Volume(PltTA):
__subplots__ = ["ad", "adosc", "obv"]
# Useless super delegation
# def __init__(self, *args, **kwargs):
# super().__init__(*args, **kwargs)
@indicator()
def plot_ad(self, fig: OpenBBFigure, df_ta: pd.DataFrame, subplot_row: int):
"""Add ad to plotly figure."""

View File

@@ -1,7 +1,5 @@
"""Technical Analysis class for Plotly."""
# pylint: disable=R0902,R0916,R0912,R0917 # type: ignore[index, assignment]
import importlib
import inspect
import sys
@@ -28,11 +26,6 @@ if TYPE_CHECKING:
class PlotlyTA(PltTA):
"""Plotly Technical Analysis class.
This class is a singleton. It is created and then reused, to assure
the plugins are only loaded once. This is done by overriding the __new__
method. The __init__ method is overridden to do nothing, except to clear
the internal data structures.
Attributes
----------
inchart_colors (List[str]):
@@ -45,44 +38,6 @@ class PlotlyTA(PltTA):
List of available inchart indicators
subplots (List[str]):
List of available subplots
StaticMethods
-------------
plot(
df: pd.DataFrame,
indicators: ChartIndicators,
fig: Optional[OpenBBFigure] = None,
symbol: Optional[str] = "",
candles: bool = True,
volume: bool = True,
) -> OpenBBFigure:
Plots the chart with the given indicators
Examples
--------
>>> from openbb import obb
>>> from charting.core.plotly_ta.ta_class import PlotlyTA
>>> df = obb.equity.price.historical("SPY")
>>> indicators = dict(
>>> sma=dict(length=[20, 50, 100]),
>>> adx=dict(length=14),
>>> macd=dict(fast=12, slow=26, signal=9),
>>> rsi=dict(length=14),
>>> )
>>> fig = PlotlyTA.plot(df, indicators=indicators)
>>> fig.show()
If you want to plot the chart with the same indicators, you can
reuse the same instance of the class as follows:
>>> ta = PlotlyTA()
>>> fig = ta.plot(df, indicators=indicators)
>>> df2 = obb.equity.price.historical("AAPL")
>>> fig2 = ta.plot(df2)
>>> fig.show()
>>> fig2.show()
"""
inchart_colors: list[str] = []
@@ -96,10 +51,7 @@ class PlotlyTA(PltTA):
theme: ChartStyle | None = None
def __new__(cls, *args, **kwargs):
"""Create a new instance of the class.
Method is overridden to create a singleton instance of the class.
"""
"""Create a new instance of the class."""
cls.charting_settings = kwargs.pop("charting_settings", cls.charting_settings)
cls.theme = cls.setup_theme(
chart_style=getattr(cls.charting_settings, "chart_style", ""),
@@ -109,26 +61,22 @@ class PlotlyTA(PltTA):
)
cls.inchart_colors = cls.theme.get_colors()
global PLOTLY_TA # pylint: disable=global-statement # noqa
global PLOTLY_TA # noqa
if PLOTLY_TA is None:
# Creates the instance of the class and loads the plugins
# We set the global variable to the instance of the class so that
# the plugins are only loaded once
PLOTLY_TA = super().__new__(cls) # type: ignore[attr-defined, assignment]
PLOTLY_TA._locate_plugins(getattr(cls.charting_settings, "debug_mode", False)) # type: ignore[attr-defined]
PLOTLY_TA.add_plugins(PLOTLY_TA.plugins) # type: ignore[attr-defined, assignment]
PLOTLY_TA = super().__new__(cls) # ty: ignore[invalid-assignment]
PLOTLY_TA._locate_plugins( # ty: ignore[unresolved-attribute]
getattr(cls.charting_settings, "debug_mode", False)
)
PLOTLY_TA.add_plugins(PLOTLY_TA.plugins) # ty: ignore[invalid-argument-type, unresolved-attribute]
return PLOTLY_TA
def __init__(self, *args, **kwargs):
"""Initialize the class.
Method is overridden to do nothing, except to clear the internal data structures.
"""
"""Initialize the class."""
if not args and not kwargs:
self._clear_data()
else:
self.df_fib = None # type: ignore
self.df_fib = None # ty: ignore[invalid-assignment]
super().__init__(*args, **kwargs)
@staticmethod
@@ -166,7 +114,6 @@ class PlotlyTA(PltTA):
"""Set list of available subplots."""
self.__subplots__ = value
# pylint: disable=R0913
def __plot__(
self,
df_stock: Union["pd.DataFrame", "pd.Series"],
@@ -178,11 +125,7 @@ class PlotlyTA(PltTA):
fig: OpenBBFigure | None = None,
volume_ticks_x: int = 7,
) -> OpenBBFigure:
"""Do not call this directly.
Use the PlotlyTA.plot() static method instead.
"""
# pylint: disable=import-outside-toplevel
"""Do not call this directly."""
import pandas as pd
if isinstance(df_stock, pd.Series):
@@ -191,10 +134,9 @@ class PlotlyTA(PltTA):
if not isinstance(indicators, ChartIndicators):
indicators = ChartIndicators.from_dict(indicators or {})
# Apply to_datetime to the index in a way that handles daylight savings.
df_stock["date"] = df_stock.index # type: ignore
df_stock["date"] = df_stock.index
df_stock["date"] = df_stock["date"].apply(pd.to_datetime)
df_stock.index = df_stock["date"] # type: ignore
df_stock.index = df_stock["date"]
df_stock.drop(columns=["date"], inplace=True)
self.indicators = indicators
@@ -256,7 +198,7 @@ class PlotlyTA(PltTA):
if indicators is None and PLOTLY_TA is not None:
indicators = PLOTLY_TA.indicators
return PlotlyTA().__plot__( # type: ignore
return PlotlyTA().__plot__(
df_stock, indicators, symbol, candles, volume, prepost, fig, volume_ticks_x
)
@@ -297,7 +239,7 @@ class PlotlyTA(PltTA):
def _clear_data(self):
"""Clear and reset all data to default values."""
self.df_stock = None # type: ignore
self.df_stock = None # ty: ignore[invalid-assignment]
self.indicators = ChartIndicators.from_dict({})
self.params = None
self.intraday = False
@@ -305,7 +247,7 @@ class PlotlyTA(PltTA):
def calculate_indicators(self):
"""Return dataframe with all indicators."""
return self.indicators.to_dataframe(self.df_stock.copy(), self.ma_mode) # type: ignore
return self.indicators.to_dataframe(self.df_stock.copy(), self.ma_mode) # ty: ignore[invalid-argument-type]
def get_subplot(self, subplot: str) -> bool:
"""Return True if subplots will be able to be plotted with current data."""
@@ -329,15 +271,15 @@ class PlotlyTA(PltTA):
return False
output = self.indicators.get_indicator_data(
self.df_stock.copy(), # type: ignore
self.df_stock.copy(), # ty: ignore[invalid-argument-type]
indicator,
**self.indicators.get_options_dict(indicator.name) or {},
)
if not isinstance(output, bool):
output = output.dropna() # type: ignore
output = output.dropna()
if output is None or output.empty:
output = False
output = False # pragma: no cover # get_indicator_data already drops NaNs and returns None when empty
return True
@@ -374,7 +316,7 @@ class PlotlyTA(PltTA):
specs = [[{"secondary_y": True}]] + [[{"secondary_y": False}]] * check_rows
output = row_params.get(str(check_rows), dict(rows=1, row_width=[1]))
output.update(dict(cols=1, vertical_spacing=0.04, specs=specs))
output.update(dict(cols=1, vertical_spacing=0.04, specs=specs)) # ty: ignore[no-matching-overload]
return output
@@ -425,7 +367,7 @@ class PlotlyTA(PltTA):
else:
fig.add_scatter(
x=self.df_stock.index,
y=self.df_stock[self.close_column], # type: ignore
y=self.df_stock[self.close_column],
name=f"{symbol}",
connectgaps=True,
row=1,
@@ -466,7 +408,11 @@ class PlotlyTA(PltTA):
"""
self.df_ta = self.calculate_indicators()
symbol = self.df_stock.name if hasattr(self.df_stock, "name") and not symbol else symbol # type: ignore
symbol = ( # ty: ignore[invalid-assignment]
self.df_stock.name
if hasattr(self.df_stock, "name") and not symbol
else symbol
)
figure = self.init_plot(symbol, candles) if fig is None else fig
subplot_row, fig_new = 2, {}
@@ -474,14 +420,16 @@ class PlotlyTA(PltTA):
figure = self.process_fig(figure, volume_ticks_x)
# Aroon indicator is always plotted first since it has 2 subplot rows.
# ATR messes up the volume layout so we plot it last.
plot_indicators = sorted(
self.indicators.get_active_ids(),
key=lambda x: (
50
if x == "aroon"
else 1000 if x == "atr" else 999 if x in self.subplots else 1
else 1000
if x == "atr"
else 999
if x in self.subplots
else 1
),
)
@@ -537,13 +485,13 @@ class PlotlyTA(PltTA):
selector=dict(type="scatter", mode="lines"), connectgaps=True
)
if hasattr(figure, "hide_holidays"):
figure.hide_holidays(self.prepost) # type: ignore
figure.hide_holidays( # ty: ignore[call-non-callable]
self.prepost
) # pragma: no cover # OpenBBFigure has no hide_holidays method in this codebase
if not self.show_volume:
figure.update_layout(margin=dict(l=20))
# We remove xaxis labels from all but bottom subplot,
# and we make sure they all match the bottom one
xbottom = f"y{subplot_row + 1}"
xaxes = list(figure.select_xaxes())
for xa in xaxes:
@@ -601,7 +549,7 @@ class PlotlyTA(PltTA):
sr_legend_shown = True
if "annotations" in figure.layout:
for item in figure.layout.annotations: # type: ignore
for item in figure.layout.annotations: # ty: ignore[not-iterable]
item["font"]["size"] = 14
figure.update_layout(margin=dict(l=50, r=10, b=10, t=20))
return figure
@@ -626,11 +574,11 @@ class PlotlyTA(PltTA):
shared_xaxes=True, **self.get_fig_settings_dict()
)
subplots: dict[str, dict[str, list[Any]]] = {}
grid_ref = fig._validate_get_grid_ref() # pylint: disable=protected-access
grid_ref = fig._validate_get_grid_ref()
for r, plot_row in enumerate(grid_ref):
for c, plot_refs in enumerate(plot_row):
if not plot_refs:
continue
continue # pragma: no cover # init_plot always yields a single populated grid cell
for subplot_ref in plot_refs:
if subplot_ref.subplot_type == "xy":
xaxis, yaxis = subplot_ref.layout_keys
@@ -658,14 +606,14 @@ class PlotlyTA(PltTA):
layout
]["domain"]
fig.layout.update({layout: fig_json[layout]}) # type: ignore
new_subplot.layout.update({layout: fig.layout[layout]}) # type: ignore
fig.layout.update({layout: fig_json[layout]})
new_subplot.layout.update({layout: fig.layout[layout]})
if self.show_volume:
new_subplot.add_inchart_volume(
self.df_stock,
self.df_stock, # ty: ignore[invalid-argument-type]
self.close_column,
volume_ticks_x=volume_ticks_x, # type: ignore
volume_ticks_x=volume_ticks_x,
)
return new_subplot

View File

@@ -27,11 +27,9 @@ def check_columns(
Optional[str]
The name of the close column, none if df is invalid
"""
# pylint: disable=import-outside-toplevel
import re
close_regex = r"(Adj\sClose|adj_close|Close)"
# pylint: disable=too-many-boolean-expressions
if (
(re.findall(r"High", str(data.columns), re.IGNORECASE) is None and high)
or (re.findall(r"Low", str(data.columns), re.IGNORECASE) is None and low)
@@ -39,13 +37,12 @@ def check_columns(
is None
and close
):
raise ValueError(
raise ValueError( # pragma: no cover # re.findall never returns None, so this guard cannot fire
" Please make sure that the columns 'High', 'Low', and 'Close' are in the dataframe."
)
close_col = [col for col in close_col if col in data.columns]
# giving priority to the standard close column
if "close" in close_col:
return "close"

View File

@@ -8,7 +8,7 @@ if TYPE_CHECKING:
from pandas import DataFrame, Series # noqa
def to_chart( # pylint: disable=R0917
def to_chart(
data: Union["DataFrame", "Series"],
indicators: Union["ChartIndicators", dict[str, dict[str, Any]]] | None = None,
symbol: str = "",
@@ -19,10 +19,6 @@ def to_chart( # pylint: disable=R0917
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Return 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
----------
data : Union[DataFrame, Series]
@@ -45,12 +41,11 @@ def to_chart( # pylint: disable=R0917
Tuple[OpenBBFigure, Dict[str, Any]]
Tuple containing the OpenBBFigure and the plotly json representation of the chart.
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.core.plotly_ta.ta_class import PlotlyTA
try:
ta = PlotlyTA()
fig = ta.plot( # type: ignore
fig = ta.plot(
df_stock=data,
indicators=indicators,
symbol=symbol,

View File

@@ -1,7 +1,5 @@
"""Charting Extension Query Params."""
# pylint: disable=unused-variable,unused-argument
from typing import Any, Literal
from openbb_core.provider.abstract.data import Data
@@ -19,13 +17,13 @@ def _get_type_name(t):
if hasattr(t.__origin__, "__name__"):
return f"{t.__origin__.__name__}[{', '.join([_get_type_name(arg) for arg in t.__args__])}]"
if hasattr(t.__origin__, "_name"):
return f"{t.__origin__._name}[{', '.join([_get_type_name(arg) for arg in t.__args__])}]" # pylint: disable=W0212
return f"{t.__origin__._name}[{', '.join([_get_type_name(arg) for arg in t.__args__])}]"
if isinstance(t, str):
return t
if hasattr(t, "__name__"):
return t.__name__
if hasattr(t, "_name"):
return t._name # pylint: disable=W0212
return t._name
return str(t)

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,91 @@
[tool.poetry]
[project]
name = "openbb-charting"
version = "3.0.0"
description = "Charting extension for OpenBB"
authors = ["OpenBB Team <hello@openbb.co>"]
license = "AGPL-3.0-only"
authors = [{ name = "OpenBB Team", email = "hello@openbb.co" }]
license = { text = "AGPL-3.0-only" }
readme = "README.md"
packages = [{ include = "openbb_charting" }]
requires-python = ">=3.10,<4" # scipy forces python <4.0 explicitly
dependencies = [
"openbb-core[pandas]>=2.0.0",
"pandas-ta-openbb>=0.4.24",
"plotly>=6.3.1",
]
[tool.poetry.dependencies]
python = ">=3.10,<4" # scipy forces python <4.0 explicitly
openbb-core = ">=1.6.8"
pandas-ta-openbb = "^0.4.24"
plotly = "^6.3.1"
pywry = { version = ">=2.0.0", optional = true }
[project.optional-dependencies]
pywry = ["pywry>=2.0.0"]
[tool.poetry.extras]
pywry = ["pywry"]
[project.entry-points."openbb_obbject_extension"]
openbb_charting = "openbb_charting:ext"
[project.urls]
Homepage = "https://openbb.co"
Repository = "https://github.com/OpenBB-finance/OpenBB"
[dependency-groups]
dev = ["openbb-devtools>=2.0.0", "openbb-technical>=2.0.0"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[tool.poetry.plugins."openbb_obbject_extension"]
openbb_charting = "openbb_charting:ext"
[tool.hatch.build.targets.wheel]
packages = ["openbb_charting"]
[tool.uv.sources]
openbb-core = { path = "../../core", editable = true }
openbb-devtools = { path = "../../extensions/devtools", editable = true }
openbb-technical = { path = "../../extensions/technical", editable = true }
[tool.ruff]
line-length = 88
target-version = "py310"
fix = true
[tool.ruff.lint]
select = ["D", "E", "W", "F", "S", "UP", "I", "PLC", "PLE", "PLR", "PLW", "SIM", "T20"]
ignore = [
"PLR2004", "PLR0913", "PLR0915", "PLC0415",
"E402", "E501",
"D202", "D203", "D205", "D206", "D210", "D212", "D213", "D214",
"D215", "D300", "D400", "D404", "D405", "D406", "D407", "D408",
"D409", "D410", "D411", "D413", "D415", "D416", "D417",
]
[tool.ruff.lint.per-file-ignores]
# Charting internals carry plotting complexity that the refactor leaves intact.
"openbb_charting/charting.py" = ["PLR0911", "PLR0912", "PLR0914", "PLR0917", "SLF001", "BLE001"]
"openbb_charting/core/**" = ["PLR0911", "PLR0912", "PLR0914", "PLR0915", "PLR0917", "SLF001", "BLE001", "S110", "S112", "PLW2901", "SIM"]
"openbb_charting/charts/**" = ["PLR0911", "PLR0912", "PLR0914", "PLR0915", "PLR0917", "SLF001", "BLE001", "PLW2901", "SIM"]
"openbb_charting/styles/**" = ["PLR0912", "PLR0914", "PLR0915"]
"tests/**" = ["S101", "S108", "D", "SIM105", "SIM117"]
"integration/**" = ["S101", "S108", "D", "SIM105", "SIM117", "PLC0415", "ARG001"]
"**/__init__.py" = ["F401"]
[tool.ruff.lint.isort]
combine-as-imports = true
force-wrap-aliases = true
[tool.ruff.lint.pydocstyle]
convention = "numpy"
[tool.ty]
[tool.ty.environment]
python-version = "3.10"
[tool.ty.src]
include = ["openbb_charting"]
exclude = ["tests", "integration"]
[tool.ty.rules]
unresolved-import = "ignore"
# ty's pandas/plotly inference is environment-sensitive (pandas/numpy version,
# Python version), so a `# ty: ignore` needed under one interpreter can be
# unused under another. Tolerate that mismatch instead of churning per-line.
unused-ignore-comment = "ignore"
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: deterministic, self-contained charting integration tests",
]

View File

@@ -0,0 +1,136 @@
"""Shared deterministic real-data fixtures for the charting test suite."""
from __future__ import annotations
import sys
import types
import numpy as np
import pandas as pd
import pytest
from openbb_core.provider.abstract.data import Data
SYMBOLS = ("AAA", "BBB", "CCC", "DDD")
def _install_fake_pywry() -> None:
"""Install a minimal fake ``pywry`` so the GUI backend runs headlessly.
``pywry`` is an optional GUI dependency with no wheels for every supported
Python (e.g. 3.14), so CI cannot install it. It is the one true external
boundary — the window/websocket layer — so a faithful in-memory stand-in is
the correct test double. Installing it unconditionally keeps backend tests
deterministic across every environment.
"""
pywry = types.ModuleType("pywry")
pywry._OPENBB_FAKE = True # type: ignore[attr-defined]
class _Component:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class Toolbar(_Component):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.items = kwargs.get("items", [])
class _App:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
self.theme = kwargs.get("theme")
def show_plotly(self, **kwargs):
return None
def show_dataframe(self, **kwargs):
return None
def show(self, **kwargs):
return None
def emit(self, *args, **kwargs):
return None
def close(self, *args, **kwargs):
return None
class ThemeMode:
DARK = "dark"
LIGHT = "light"
pywry.PyWry = _App # type: ignore[attr-defined]
pywry.ThemeMode = ThemeMode # type: ignore[attr-defined]
pywry.Toolbar = Toolbar # type: ignore[attr-defined]
pywry.Button = _Component # type: ignore[attr-defined]
pywry.Div = _Component # type: ignore[attr-defined]
pywry.TextArea = _Component # type: ignore[attr-defined]
pywry.PlotlyConfig = _Component # type: ignore[attr-defined]
grid = types.ModuleType("pywry.grid")
class _GridData:
def __init__(self, frame):
self.columns = list(frame.columns)
self.index_columns = []
self.column_types = {col: "text" for col in frame.columns}
self.row_data = frame.to_dict(orient="records")
grid.normalize_data = _GridData # type: ignore[attr-defined]
grid.build_column_defs = lambda columns, index_columns, column_types: [ # type: ignore[attr-defined]
{"field": col} for col in columns
]
pywry.grid = grid # type: ignore[attr-defined]
sys.modules["pywry"] = pywry
sys.modules["pywry.grid"] = grid
_install_fake_pywry()
def _make_ohlcv(
seed: int, periods: int = 200, start: str = "2023-01-01"
) -> pd.DataFrame:
"""Build a deterministic, valid OHLCV frame with a ``date`` index."""
rng = np.random.default_rng(seed)
idx = pd.date_range(start, periods=periods, freq="D")
close = 100 + np.cumsum(rng.normal(0, 1, periods))
open_ = close + rng.normal(0, 0.5, periods)
high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.5, periods))
low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.5, periods))
volume = rng.integers(1_000_000, 5_000_000, periods)
frame = pd.DataFrame(
{"open": open_, "high": high, "low": low, "close": close, "volume": volume},
index=idx,
)
frame.index.name = "date"
return frame
@pytest.fixture
def ohlcv_df() -> pd.DataFrame:
"""A single-symbol OHLCV DataFrame indexed by date (200 rows)."""
return _make_ohlcv(seed=42)
@pytest.fixture
def ohlcv_records(ohlcv_df) -> list[dict]:
"""The OHLCV frame as a list of ``date``-stamped record dicts."""
out = ohlcv_df.reset_index()
out["date"] = out["date"].dt.strftime("%Y-%m-%d")
return out.to_dict(orient="records")
@pytest.fixture
def ohlcv_data(ohlcv_records) -> list[Data]:
"""The OHLCV records as real ``Data`` models."""
return [Data.model_validate(row) for row in ohlcv_records]
@pytest.fixture
def multi_close_df() -> pd.DataFrame:
"""A multi-symbol close-price frame (wide), indexed by date."""
frames = {sym: _make_ohlcv(seed=i)["close"] for i, sym in enumerate(SYMBOLS)}
wide = pd.DataFrame(frames)
wide.index.name = "date"
return wide

View File

@@ -0,0 +1,301 @@
"""Tests for ``openbb_charting.core.backend``."""
from __future__ import annotations
import builtins
import pandas as pd
import plotly.graph_objects as go
import pytest
from openbb_core.app.model.charts.charting_settings import ChartingSettings
from openbb_charting.core.backend import Backend
# pylint: disable=protected-access
class RecorderApp:
"""A real stand-in for the PyWry GUI app that records dispatched calls."""
def __init__(self):
"""Initialize the recorder with empty call logs."""
self.events: list[str] = []
self.calls: list[tuple[str, dict]] = []
self.theme = None
def emit(self, event, payload, label):
"""Record an emitted event name."""
self.events.append(event)
def show_plotly(self, **kwargs):
"""Record a ``show_plotly`` call and its kwargs."""
self.calls.append(("show_plotly", kwargs))
return "plotly"
def show_dataframe(self, **kwargs):
"""Record a ``show_dataframe`` call and its kwargs."""
self.calls.append(("show_dataframe", kwargs))
return "dataframe"
def show(self, **kwargs):
"""Record a ``show`` call and its kwargs."""
self.calls.append(("show", kwargs))
return "show"
def close(self, **kwargs):
"""Record a ``close`` call."""
self.calls.append(("close", kwargs))
@pytest.fixture
def reset_singleton():
"""Reset the ``Backend`` singleton around each test."""
Backend.instance = None
yield
Backend.instance = None
@pytest.fixture
def light_settings() -> ChartingSettings:
"""A ``ChartingSettings`` instance configured for light mode."""
settings = ChartingSettings()
settings.chart_style = "light"
return settings
@pytest.fixture
def dark_settings() -> ChartingSettings:
"""A ``ChartingSettings`` instance configured for dark mode."""
settings = ChartingSettings()
settings.chart_style = "dark"
return settings
@pytest.fixture
def backend_with_recorder(reset_singleton, dark_settings):
"""A real ``Backend`` whose GUI app is replaced with a recorder."""
backend = Backend(dark_settings)
backend._app = RecorderApp()
return backend
class TestBackendConstruction:
"""Tests for ``Backend`` construction and template registration."""
def test_singleton_returns_same_instance(self, reset_singleton, light_settings):
"""Two constructions return the same singleton instance."""
first = Backend(light_settings)
second = Backend(light_settings)
assert first is second
def test_light_mode_sets_is_dark_false(self, reset_singleton, light_settings):
"""Light chart style yields ``_is_dark`` False."""
backend = Backend(light_settings)
assert backend._is_dark is False
def test_dark_mode_sets_is_dark_true(self, reset_singleton, dark_settings):
"""Dark chart style yields ``_is_dark`` True."""
backend = Backend(dark_settings)
assert backend._is_dark is True
def test_templates_registered(self, reset_singleton, dark_settings):
"""Template dicts are produced during construction."""
backend = Backend(dark_settings)
assert isinstance(backend._template_dark, dict)
assert isinstance(backend._template_light, dict)
def test_register_templates_returns_two_dicts(self):
"""The static template registrar returns a dark/light dict pair."""
dark, light = Backend._register_templates()
assert isinstance(dark, dict)
assert isinstance(light, dict)
def test_import_error_falls_back_to_dummy_backend(
self, reset_singleton, light_settings
):
"""A failed ``pywry`` import swaps in the headless ``DummyBackend``."""
from openbb_charting.core.dummy_backend import DummyBackend
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "pywry":
raise ImportError("forced for test")
return real_import(name, *args, **kwargs)
builtins.__import__ = fake_import
try:
backend = Backend(light_settings)
assert isinstance(backend._app, DummyBackend)
finally:
builtins.__import__ = real_import
class TestToolbarsAndCallbacks:
"""Tests for the headless toolbar and callback builders."""
def test_header_toolbar_builds(self, backend_with_recorder):
"""The header toolbar builds and has items."""
toolbar = backend_with_recorder._header_toolbar()
assert toolbar.items
def test_query_toolbar_builds(self, backend_with_recorder):
"""The query toolbar builds and has items."""
toolbar = backend_with_recorder._query_toolbar()
assert toolbar.items
def test_callbacks_registers_theme_toggle(self, backend_with_recorder):
"""The base callbacks map registers the theme toggle handler."""
callbacks = backend_with_recorder._callbacks()
assert "app:toggle-theme" in callbacks
def test_table_callbacks_registers_query_handlers(self, backend_with_recorder):
"""The table callbacks map registers the query handlers."""
callbacks = backend_with_recorder._table_callbacks()
assert {"query:submit", "query:input", "query:reset"} <= set(callbacks)
class TestThemeToggle:
"""Tests for the theme-toggle handler."""
def test_toggle_without_current_figure(self, backend_with_recorder):
"""Toggling flips ``_is_dark`` and emits update events with no figure."""
backend_with_recorder._current_fig = None
before = backend_with_recorder._is_dark
backend_with_recorder._handle_theme_toggle({}, "event", "label")
assert backend_with_recorder._is_dark is not before
assert "pywry:update-theme" in backend_with_recorder._app.events
def test_toggle_with_current_figure(self, backend_with_recorder):
"""Toggling updates the layout of the current figure when present."""
fig = go.Figure()
fig.add_scatter(x=[1, 2], y=[1, 2])
backend_with_recorder._current_fig = fig
backend_with_recorder._handle_theme_toggle({}, "event", "label")
assert "toolbar:set-value" in backend_with_recorder._app.events
class TestQueryHandlers:
"""Tests for the Pandas query toolbar handlers."""
@pytest.fixture
def loaded_backend(self, backend_with_recorder):
"""A backend with a source DataFrame loaded for queries."""
backend_with_recorder._original_df = pd.DataFrame(
{"close": [1, 2, 3], "volume": [10, 20, 30]}
)
return backend_with_recorder
def test_empty_query_does_nothing(self, loaded_backend):
"""An empty pending query short-circuits without emitting."""
loaded_backend._pending_query = " "
loaded_backend._handle_query_submit({}, "event", "label")
assert loaded_backend._app.events == []
def test_blocked_token_emits_alert(self, loaded_backend):
"""A blocked token in the query emits an alert and aborts."""
loaded_backend._pending_query = "import os"
loaded_backend._handle_query_submit({}, "event", "label")
assert "pywry:alert" in loaded_backend._app.events
def test_valid_query_updates_grid(self, loaded_backend):
"""A valid DataFrame query updates the grid columns and data."""
loaded_backend._pending_query = "df.query('close > 1')"
loaded_backend._handle_query_submit({}, "event", "label")
assert "grid:update-columns" in loaded_backend._app.events
assert "grid:update-data" in loaded_backend._app.events
def test_query_with_named_index_resets_index(self, loaded_backend):
"""A result carrying a named index is reset before normalization."""
loaded_backend._original_df = loaded_backend._original_df.set_index("close")
loaded_backend._pending_query = "df"
loaded_backend._handle_query_submit({}, "event", "label")
assert "grid:update-data" in loaded_backend._app.events
def test_non_dataframe_result_emits_alert(self, loaded_backend):
"""A query that does not yield a DataFrame emits an alert."""
loaded_backend._pending_query = "df.close.sum()"
loaded_backend._handle_query_submit({}, "event", "label")
assert "pywry:alert" in loaded_backend._app.events
def test_query_error_emits_alert(self, loaded_backend):
"""A raising query expression emits an alert."""
loaded_backend._pending_query = "df.no_such_method()"
loaded_backend._handle_query_submit({}, "event", "label")
assert "pywry:alert" in loaded_backend._app.events
def test_query_input_records_value(self, loaded_backend):
"""The input handler records the pending query value."""
loaded_backend._handle_query_input({"value": "df.head()"}, "event", "label")
assert loaded_backend._pending_query == "df.head()"
def test_query_reset_restores_grid(self, loaded_backend):
"""The reset handler clears the query and restores the grid."""
loaded_backend._pending_query = "df.query('close > 2')"
loaded_backend._handle_query_reset({}, "event", "label")
assert loaded_backend._pending_query == ""
assert "grid:reset-state" in loaded_backend._app.events
class TestSendMethods:
"""Tests for the figure/table/url dispatch methods."""
def test_send_figure_strips_html_title(self, backend_with_recorder):
"""``send_figure`` strips HTML tags from the figure title and dispatches."""
fig = go.Figure()
fig.add_scatter(x=[1, 2], y=[1, 2])
fig.layout.title.text = "<b>Hello</b>"
backend_with_recorder.send_figure(fig, command_location="/equity/price")
assert fig.layout.title.text == "Hello"
assert ("show_plotly", backend_with_recorder._app.calls[0][1]) == (
"show_plotly",
backend_with_recorder._app.calls[0][1],
)
def test_send_figure_uses_default_title_when_missing(self, backend_with_recorder):
"""``send_figure`` falls back to the default title when none is set."""
fig = go.Figure()
backend_with_recorder.send_figure(fig)
assert backend_with_recorder._app.calls[0][0] == "show_plotly"
def test_send_table_with_query_toolbar(self, backend_with_recorder):
"""``send_table`` dispatches a dataframe with the query toolbar by default."""
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
backend_with_recorder.send_table(df, title="<b>My [/b]Title")
assert backend_with_recorder._app.calls[0][0] == "show_dataframe"
def test_send_table_without_query_toolbar(self, backend_with_recorder):
"""``send_table`` omits the query toolbar when disabled."""
df = pd.DataFrame({"a": [1, 2]})
backend_with_recorder.send_table(df, include_query_toolbar=False)
assert backend_with_recorder._app.calls[0][0] == "show_dataframe"
def test_send_table_empty_title(self, backend_with_recorder):
"""``send_table`` accepts an empty title without sanitization."""
df = pd.DataFrame({"a": [1, 2]})
backend_with_recorder.send_table(df)
assert backend_with_recorder._app.calls[0][0] == "show_dataframe"
def test_send_url_with_dimensions(self, backend_with_recorder):
"""``send_url`` dispatches a redirect document with explicit dimensions."""
backend_with_recorder.send_url(
"https://example.com", title="Docs", width=100, height=200
)
name, kwargs = backend_with_recorder._app.calls[0]
assert name == "show"
assert kwargs["width"] == 100
assert kwargs["height"] == 200
def test_send_url_escapes_and_uses_defaults(self, backend_with_recorder):
"""``send_url`` escapes the URL and falls back to default dimensions."""
backend_with_recorder.send_url('https://example.com/?a="b"&c=1')
name, kwargs = backend_with_recorder._app.calls[0]
assert name == "show"
assert kwargs["width"] == 1400
assert kwargs["height"] == 762
assert "&quot;" in kwargs["content"] or "%22" in kwargs["content"]
def test_close_delegates_to_app(self, backend_with_recorder):
"""``close`` delegates to the underlying app."""
backend_with_recorder.close()
assert backend_with_recorder._app.calls[0][0] == "close"

View File

@@ -0,0 +1,203 @@
"""Tests for ``openbb_charting.core.chart_style.ChartStyle``."""
import warnings
from pathlib import Path
import plotly.io as pio
import pytest
from openbb_charting.core.chart_style import ChartStyle
from openbb_charting.core.config.openbb_styles import (
PLT_COLORWAY,
PLT_DECREASING_COLORWAY,
PLT_INCREASING_COLORWAY,
)
@pytest.fixture
def style() -> ChartStyle:
"""Return the live ``ChartStyle`` singleton."""
return ChartStyle()
@pytest.fixture
def restore_style(style):
"""Snapshot mutable singleton state and restore it after a test."""
saved = {
"plt_style": style.plt_style,
"plotly_template": style.plotly_template,
"mapbox_style": style.mapbox_style,
"up_color": style.up_color,
"down_color": style.down_color,
"line_color": style.line_color,
"up_colorway": style.up_colorway,
"down_colorway": style.down_colorway,
"default": pio.templates.default,
}
yield
for attr in (
"plt_style",
"plotly_template",
"mapbox_style",
"up_color",
"down_color",
"line_color",
"up_colorway",
"down_colorway",
):
setattr(style, attr, saved[attr])
pio.templates.default = saved["default"]
class TestSingleton:
"""The class enforces a single shared instance."""
def test_new_returns_same_instance(self):
"""Repeated construction returns the same object."""
assert ChartStyle() is ChartStyle()
def test_already_initialized_init_is_noop(self):
"""Re-initializing an initialized singleton leaves it intact."""
style = ChartStyle()
previous = style.plt_style
ChartStyle("white")
assert style.plt_style == previous
assert style.initialized is True
class TestLoadStyles:
"""Loading available styles and individual style files."""
def test_available_styles_loaded(self, style):
"""The default folder styles are discoverable."""
assert "dark" in style.plt_styles_available
assert "light" in style.plt_styles_available
assert "tables" in style.plt_styles_available
def test_load_available_styles_from_folder_missing(self, style):
"""A non-existent folder is ignored without raising."""
before = dict(style.plt_styles_available)
style.load_available_styles_from_folder(Path("/no/such/folder/xyz"))
assert style.plt_styles_available == before
def test_load_available_styles_from_folder_non_path(self, style):
"""A non-Path argument short-circuits and is ignored."""
before = dict(style.plt_styles_available)
style.load_available_styles_from_folder("not-a-path-object") # type: ignore
assert style.plt_styles_available == before
def test_load_json_style(self, style):
"""Reading a known style file returns its dict contents."""
loaded = style.load_json_style(style.plt_styles_available["dark"])
assert isinstance(loaded, dict)
assert "layout" in loaded
def test_load_style_unknown_warns_and_falls_back(self, style, restore_style):
"""An unknown style name warns and falls back to ``dark``."""
with pytest.warns(UserWarning, match="not found"):
style.load_style("does-not-exist")
assert style.plt_style == "dark"
def test_load_style_default_uses_plt_style(self, style, restore_style):
"""Passing no style loads the currently set ``plt_style``."""
style.plt_style = "dark"
style.load_style("")
assert style.plt_style == "dark"
def test_load_plt_style_populates_colors(self, style, restore_style):
"""Loading a style populates the color attributes from the file."""
style.load_plt_style("dark")
assert style.up_color
assert style.down_color
assert style.line_color
assert style.up_colorway
assert style.down_colorway
class TestLoadPltStyleDefaults:
"""Color defaults fill in when a style file omits its ``line`` block."""
def test_defaults_used_when_line_block_absent(self, style, restore_style, tmp_path):
"""A style file lacking a ``line`` block falls back to module defaults."""
bare = tmp_path / "bare.pltstyle.json"
bare.write_text('{"layout": {"colorway": ["#123456"]}}')
style.plt_styles_available["bare"] = bare
try:
style.load_plt_style("bare")
assert style.up_color == "#00ACFF"
assert style.down_color == "#FF0000"
assert style.line_color == "#ffed00"
assert style.up_colorway == PLT_INCREASING_COLORWAY
assert style.down_colorway == PLT_DECREASING_COLORWAY
finally:
style.plt_styles_available.pop("bare", None)
class TestApplyStyle:
"""Exercise the branches of ``apply_style``."""
def test_apply_style_dark_sets_default(self, style, restore_style):
"""Applying the dark style sets the combined plotly+openbb default."""
style.apply_style("dark")
assert pio.templates.default == "plotly_dark+openbb"
def test_apply_style_light_maps_to_white(self, style, restore_style):
"""Applying ``light`` maps to the plotly white template default."""
style.apply_style("light")
assert pio.templates.default == "plotly_white+openbb"
def test_apply_style_reloads_when_style_differs(self, style, restore_style):
"""A style differing from ``plt_style`` triggers a reload."""
style.plt_style = "dark"
style.apply_style("light")
assert style.plt_style == "light"
def test_apply_style_custom_sets_openbb_default(self, style, restore_style):
"""A non dark/white style selects the bare ``openbb`` template."""
style.plt_style = "dark"
style.load_plt_style("dark")
style.plt_style = "custom"
style.apply_style("custom")
assert pio.templates.default == "openbb"
assert style.mapbox_style == "dark"
def test_apply_style_value_error_non_legend2_is_swallowed(
self, style, restore_style
):
"""A non-legend2 ValueError from the template is swallowed, not fatal."""
style.plt_style = "dark"
style.plotly_template = {"layout": {"not_a_real_property_xyz": 1}}
with warnings.catch_warnings():
warnings.simplefilter("ignore")
style.apply_style("dark")
assert pio.templates.default == "plotly_dark+openbb"
def test_apply_style_noop_when_template_empty(self, style, restore_style):
"""An empty template skips template registration entirely."""
style.plt_style = "dark"
style.plotly_template = {}
before = pio.templates.default
style.apply_style("dark")
assert pio.templates.default == before
class TestGetColors:
"""Exercise ``get_colors`` ordering."""
def test_get_colors_returns_copy(self, style):
"""Colors are returned and mutation does not affect the template."""
colors = style.get_colors()
assert isinstance(colors, list)
colors.append("x")
assert "x" not in style.get_colors()
def test_get_colors_reverse(self, style):
"""Reversing yields the colors in opposite order."""
forward = style.get_colors()
backward = style.get_colors(reverse=True)
assert backward == forward[::-1]
def test_get_colors_falls_back_to_default_colorway(self, style, restore_style):
"""A template lacking a colorway falls back to ``PLT_COLORWAY``."""
style.plotly_template = {"layout": {}}
assert style.get_colors() == PLT_COLORWAY

View File

@@ -1,178 +1,620 @@
"""Test Charting class."""
"""Tests for the ``openbb_charting.charting.Charting`` accessor class."""
from unittest.mock import MagicMock, patch
from __future__ import annotations
import warnings
from types import SimpleNamespace
import numpy as np
import pandas as pd
import pytest
from openbb_core.app.model.charts.chart import Chart
from openbb_core.app.model.obbject import OBBject
from openbb_core.provider.abstract.data import Data
from openbb_charting.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
from openbb_charting.core.backend import Backend
from openbb_charting.core.openbb_figure import OpenBBFigure
from openbb_charting.query_params import EquityPriceHistoricalChartQueryParams
# pylint: disable=redefined-outer-name, protected-access
# pylint: disable=protected-access,redefined-outer-name
class MockDataframe:
"""Mock Dataframe."""
class LocalChartingViews:
"""A real charting-view class registered for the duration of the tests."""
@staticmethod
def equity_price_historical(**kwargs):
"""Render the real price-historical chart (returns a (fig, content) tuple)."""
from openbb_charting.charts.price_historical import price_historical
return price_historical(**kwargs)
@staticmethod
def custom_help_view(**kwargs):
"""A charted route with no ChartParams entry, to drive the help fallback."""
from openbb_charting.charts.price_historical import price_historical
return price_historical(**kwargs)
@staticmethod
def raise_runtime(**kwargs):
"""A view that raises ``RuntimeError`` to drive the re-raise branch."""
raise RuntimeError("charting does not support test")
class RecorderApp:
"""A real stand-in for the PyWry GUI app that records dispatched calls."""
def __init__(self):
"""Mock Dataframe."""
self.columns = ["column1", "column2"]
"""Initialize the recorder with empty call logs."""
self.calls: list[str] = []
self.theme = None
def emit(self, *args, **kwargs):
"""Record an emit call."""
self.calls.append("emit")
def show_plotly(self, **kwargs):
"""Record a ``show_plotly`` call."""
self.calls.append("show_plotly")
return "plotly"
def show_dataframe(self, **kwargs):
"""Record a ``show_dataframe`` call."""
self.calls.append("show_dataframe")
return "dataframe"
def show(self, **kwargs):
"""Record a ``show`` call."""
self.calls.append("show")
return "show"
def close(self, **kwargs):
"""Record a ``close`` call."""
self.calls.append("close")
mock_dataframe = MockDataframe()
class RecorderBackend:
"""A real stand-in for ``Backend.send_table`` / ``send_url`` delegation."""
def __init__(self):
"""Initialize the recorder with empty call logs."""
self.tables: list[dict] = []
self.urls: list[dict] = []
def send_table(self, **kwargs):
"""Record a table dispatch."""
self.tables.append(kwargs)
def send_url(self, **kwargs):
"""Record a URL dispatch."""
self.urls.append(kwargs)
@pytest.fixture()
def obbject():
"""Mock OBBject."""
class RaisingBackend:
"""A real backend whose dispatch methods raise, to drive warn branches."""
class MockStdParams(BaseModel):
"""Mock Standard Parameters."""
def send_table(self, **kwargs):
"""Raise to trigger the table warn branch."""
raise RuntimeError("table boom")
param1: str
param2: str
def send_url(self, **kwargs):
"""Raise to trigger the URL warn branch."""
raise RuntimeError("url boom")
class MockOBBject:
"""Mock OBBject."""
def __init__(self):
"""Mock OBBject."""
self._user_settings = UserSettings()
self._system_settings = SystemSettings()
self._route = "mock/route"
self._standard_params = MockStdParams(
param1="mock_param1", param2="mock_param2"
def _make_ohlcv(seed: int = 7, periods: int = 60) -> pd.DataFrame:
"""Build a deterministic OHLCV frame indexed by date."""
rng = np.random.default_rng(seed)
idx = pd.date_range("2023-01-01", periods=periods, freq="D")
close = 100 + np.cumsum(rng.normal(0, 1, periods))
frame = pd.DataFrame(
{
"open": close + rng.normal(0, 0.5, periods),
"high": close + 1,
"low": close - 1,
"close": close,
"volume": rng.integers(1_000_000, 5_000_000, periods),
},
index=idx,
)
frame.index.name = "date"
return frame
def _df_to_data(frame: pd.DataFrame) -> list[Data]:
"""Convert an OHLCV frame to date-stamped ``Data`` records."""
records = frame.reset_index()
records["date"] = records["date"].dt.strftime("%Y-%m-%d")
return [Data.model_validate(row) for row in records.to_dict(orient="records")]
@pytest.fixture(autouse=True)
def local_views():
"""Register the local real view class for the duration of each test."""
original = Charting._extension_views_cache
Charting._extension_views_cache = [LocalChartingViews]
try:
yield
finally:
Charting._extension_views_cache = original
@pytest.fixture(autouse=True)
def reset_backend_singleton():
"""Reset the ``Backend`` singleton around each test to avoid state leakage."""
Backend.instance = None
yield
Backend.instance = None
def _obbject(route: str, results=None) -> OBBject:
"""Build a real ``OBBject`` with the given route and OHLCV results."""
obj = OBBject(
results=_df_to_data(_make_ohlcv()) if results is None else results,
provider="test",
)
obj._route = route
obj._standard_params = {"symbol": "AAA"}
obj._extra_params = {}
return obj
@pytest.fixture
def equity_obbject() -> OBBject:
"""A real ``OBBject`` shaped like equity price history with a resolvable view."""
return _obbject("/equity/price/historical")
class TestConstruction:
"""Tests for ``Charting`` construction and class accessors."""
def test_construct_from_real_obbject(self, equity_obbject):
"""It constructs from a real ``OBBject`` with a real backend."""
charting = Charting(equity_obbject)
assert isinstance(charting, Charting)
assert charting._functions
def test_get_backend_class(self):
"""``get_backend_class`` returns the built-in PyWry backend."""
assert Charting.get_backend_class() is Backend
def test_get_figure_class(self):
"""``get_figure_class`` returns ``OpenBBFigure``."""
assert Charting.get_figure_class() is OpenBBFigure
def test_functions_lists_registered_views(self):
"""``functions`` lists charting functions from the registered views."""
functions = Charting.functions()
assert "equity_price_historical" in functions
def test_extension_views_loaded_from_entry_points(self):
"""``_get_extension_views`` loads real entry points when uncached."""
Charting._extension_views_cache = None
views = Charting._get_extension_views()
assert isinstance(views, list)
def test_indicators_returns_params(self):
"""``indicators`` returns the aggregate indicators-params model."""
keys = list(Charting.indicators().model_dump().keys())
assert "sma" in keys
assert "macd" in keys
class TestChartFunctionResolution:
"""Tests for ``_get_chart_function`` route resolution."""
def test_resolves_known_route(self, equity_obbject):
"""It resolves a known route to a callable."""
charting = Charting(equity_obbject)
func = charting._get_chart_function("/equity/price/historical")
assert callable(func)
def test_none_route_raises(self, equity_obbject):
"""A ``None`` route raises a ``ValueError``."""
charting = Charting(equity_obbject)
with pytest.raises(ValueError, match="no function route"):
charting._get_chart_function(None)
def test_unknown_route_raises(self, equity_obbject):
"""An unknown route raises a ``ValueError``."""
charting = Charting(equity_obbject)
with pytest.raises(ValueError, match="Could not find the route"):
charting._get_chart_function("/does/not/exist")
class TestGetParams:
"""Tests for the ``get_params`` accessor."""
def test_known_route_returns_params_class(self, equity_obbject):
"""A registered route returns its instantiated query-params class."""
params = Charting(equity_obbject).get_params()
assert isinstance(params, EquityPriceHistoricalChartQueryParams)
def test_unregistered_route_returns_help(self):
"""A charted route with no ChartParams entry routes through ``help``."""
obj = _obbject("/custom/help/view")
obj.extra = {"metadata": SimpleNamespace(route="/custom/help/view")}
assert Charting(obj).get_params() is None
def test_none_route_raises(self, equity_obbject):
"""A ``None`` route raises a ``ValueError``."""
equity_obbject._route = None
with pytest.raises(ValueError, match="no function route"):
Charting(equity_obbject).get_params()
class TestPrepareDataAsDf:
"""Tests for ``_prepare_data_as_df``."""
def test_with_external_dataframe(self, equity_obbject):
"""It returns the supplied DataFrame and a True ``has_data`` flag."""
frame = _make_ohlcv()
data_df, has_data = Charting(equity_obbject)._prepare_data_as_df(frame)
assert has_data is True
assert "close" in data_df.columns
def test_without_data_uses_obbject(self, equity_obbject):
"""With no data it falls back to the OBBject contents."""
data_df, has_data = Charting(equity_obbject)._prepare_data_as_df(None)
assert has_data is False
assert "close" in data_df.columns
def test_drops_provider_column(self, equity_obbject):
"""A ``provider`` column present in the data is dropped."""
records = [
Data.model_validate({"date": "2023-01-01", "close": 1.0, "provider": "x"}),
Data.model_validate({"date": "2023-01-02", "close": 2.0, "provider": "x"}),
]
data_df, _ = Charting(equity_obbject)._prepare_data_as_df(records)
assert "provider" not in data_df.columns
class TestCreateCharts:
"""Tests for the generic chart constructors."""
def test_create_line_chart_returns_figure(self, equity_obbject):
"""``create_line_chart`` returns an ``OpenBBFigure`` when not rendering."""
frame = _make_ohlcv()
fig = Charting(equity_obbject).create_line_chart(frame, render=False)
assert isinstance(fig, OpenBBFigure)
def test_create_line_chart_render_dispatches(self, equity_obbject):
"""``create_line_chart`` with render dispatches through the GUI boundary."""
charting = Charting(equity_obbject)
charting._backend._app = RecorderApp()
frame = pd.DataFrame({"a": np.arange(10.0), "b": np.arange(10.0)})
charting.create_line_chart(frame, render=True)
assert "show_plotly" in charting._backend._app.calls
def test_create_bar_chart_returns_figure(self, equity_obbject):
"""``create_bar_chart`` returns an ``OpenBBFigure`` when not rendering."""
frame = pd.DataFrame(
{"label": ["a", "b", "c"], "value": [1, 2, 3], "other": [3, 2, 1]}
)
fig = Charting(equity_obbject).create_bar_chart(
frame, x="label", y=["value", "other"], render=False
)
assert isinstance(fig, OpenBBFigure)
def test_create_bar_chart_render_dispatches(self, equity_obbject):
"""``create_bar_chart`` with render dispatches through the GUI boundary."""
charting = Charting(equity_obbject)
charting._backend._app = RecorderApp()
frame = pd.DataFrame({"label": ["a", "b"], "value": [1, 2]})
charting.create_bar_chart(frame, x="label", y="value", render=True)
assert "show_plotly" in charting._backend._app.calls
def test_create_3d_surface(self, equity_obbject):
"""``create_3d_surface`` returns an ``OpenBBFigure``."""
x = pd.Series([1, 2, 3, 1, 2, 3])
y = pd.Series([1, 1, 1, 2, 2, 2])
z = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
fig = Charting(equity_obbject).create_3d_surface(x, y, z)
assert isinstance(fig, OpenBBFigure)
def test_create_correlation_matrix(self, equity_obbject):
"""``create_correlation_matrix`` returns an ``OpenBBFigure``."""
rng = np.random.default_rng(3)
frame = pd.DataFrame(
{
"a": rng.normal(0, 1, 30),
"b": rng.normal(0, 1, 30),
"c": rng.normal(0, 1, 30),
}
)
fig = Charting(equity_obbject).create_correlation_matrix(frame)
assert isinstance(fig, OpenBBFigure)
class TestNormalizeChartResponse:
"""Tests for ``_normalize_chart_response`` branch coverage."""
@pytest.fixture
def charting(self, equity_obbject) -> Charting:
"""A constructed ``Charting`` instance."""
return Charting(equity_obbject)
@pytest.fixture
def figure(self) -> OpenBBFigure:
"""A simple populated ``OpenBBFigure``."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[1, 2, 3])
return fig
def test_chart_passthrough(self, charting, figure):
"""A ``Chart`` instance is returned as-is."""
chart = Chart(fig=figure, content=None, format="plotly")
_, result = charting._normalize_chart_response(chart, {})
assert result is chart
def test_openbbfigure_serialized(self, charting, figure):
"""An ``OpenBBFigure`` is serialized into a plotly ``Chart``."""
fig, chart = charting._normalize_chart_response(figure, {})
assert fig is figure
assert chart.format == "plotly"
assert chart.content is not None
def test_tuple_with_openbbfigure(self, charting, figure):
"""A ``(OpenBBFigure, content)`` tuple is serialized to a plotly ``Chart``."""
_, chart = charting._normalize_chart_response((figure, {"x": 1}), {})
assert chart.format == "plotly"
def test_tuple_with_plain_figure(self, charting):
"""A ``(non-figure, content)`` tuple keeps the supplied content."""
_, chart = charting._normalize_chart_response(("not a fig", {"x": 1}), {})
assert chart.format == "str"
assert chart.content == {"x": 1}
def test_unknown_response(self, charting):
"""An unrecognized response yields an ``unknown`` format ``Chart``."""
_, chart = charting._normalize_chart_response("nonsense", {})
assert chart.format == "unknown"
class TestShow:
"""Tests for the ``show`` method."""
def test_show_resolves_real_view(self, equity_obbject):
"""``show`` resolves the registered view and stores a plotly chart."""
charting = Charting(equity_obbject)
charting.show(render=False)
assert equity_obbject.chart is not None
assert equity_obbject.chart.format == "plotly"
def test_show_render_dispatches(self, equity_obbject):
"""``show`` with render dispatches the figure through the GUI boundary."""
charting = Charting(equity_obbject)
charting._backend._app = RecorderApp()
charting.show(render=True)
assert "show_plotly" in charting._backend._app.calls
def test_show_applies_extra_params(self, equity_obbject):
"""``show`` merges OBBject extra params into the supplied extra_params."""
equity_obbject._extra_params = {"target": "close"}
charting = Charting(equity_obbject)
charting.show(render=False, extra_params={})
assert equity_obbject.chart is not None
def test_show_generic_fallback_for_unknown_route(self):
"""A route without a view falls back to the generic line chart."""
obj = _obbject("/made/up/route")
charting = Charting(obj)
charting.show(render=False)
assert obj.chart is not None
assert obj.chart.format == "plotly"
def test_show_reraises_runtime_error(self):
"""A ``RuntimeError`` raised by the view is re-raised unchanged."""
obj = _obbject("/raise/runtime")
charting = Charting(obj)
with pytest.raises(RuntimeError, match="does not support test"):
charting.show(render=False)
def test_show_fallback_failure_raises_runtime_error(self):
"""When even the generic fallback fails, a ``RuntimeError`` is raised."""
obj = _obbject("/made/up/route", results="not-a-time-series")
charting = Charting(obj)
with pytest.raises(RuntimeError, match="Failed to automatically create"):
charting.show(render=False)
def test_show_generic_fallback_render_dispatches(self):
"""The generic fallback with render dispatches through the GUI boundary."""
obj = _obbject("/made/up/route")
charting = Charting(obj)
charting._backend._app = RecorderApp()
charting.show(render=True)
assert "show_plotly" in charting._backend._app.calls
class TestHookContext:
"""Tests for hook-context construction."""
def test_make_hook_context_applies_overrides(self, equity_obbject):
"""Overrides supplied to ``_make_hook_context`` are set on the context."""
from openbb_core.app.charting import ChartLifecycle
charting = Charting(equity_obbject)
context = charting._make_hook_context(
ChartLifecycle.PRE_FIGURE, figure="FIG", content="CONTENT"
)
assert context.figure == "FIG"
assert context.content == "CONTENT"
class TestToChart:
"""Tests for the ``to_chart`` method."""
def test_to_chart_without_data(self, equity_obbject):
"""``to_chart`` with no data uses the OBBject contents."""
charting = Charting(equity_obbject)
charting.to_chart(render=False)
assert equity_obbject.chart is not None
assert equity_obbject.chart.format == "plotly"
def test_to_chart_with_external_data(self, equity_obbject):
"""``to_chart`` honors external data and a target column."""
charting = Charting(equity_obbject)
charting.to_chart(data=_make_ohlcv(), target="close", render=False)
assert equity_obbject.chart is not None
def test_to_chart_render_dispatches(self, equity_obbject):
"""``to_chart`` with render dispatches through the GUI boundary."""
charting = Charting(equity_obbject)
charting._backend._app = RecorderApp()
charting.to_chart(render=True)
assert "show_plotly" in charting._backend._app.calls
def test_to_chart_fallback(self):
"""``to_chart`` falls back to a generic line chart for a viewless route."""
obj = _obbject("/made/up/route")
charting = Charting(obj)
charting.to_chart(render=False)
assert obj.chart is not None
def test_to_chart_fallback_when_show_raises(self):
"""``to_chart`` recovers via its own generic chart when ``show`` raises."""
obj = _obbject("/raise/runtime")
charting = Charting(obj)
charting.to_chart(render=False)
assert obj.chart is not None
assert obj.chart.format == "plotly"
def test_to_chart_fallback_render_dispatches(self):
"""``to_chart`` fallback with render dispatches through the GUI boundary."""
obj = _obbject("/raise/runtime")
charting = Charting(obj)
charting._backend._app = RecorderApp()
charting.to_chart(render=True)
assert "show_plotly" in charting._backend._app.calls
def test_to_chart_fallback_failure_raises_runtime_error(self):
"""``to_chart`` raises a ``RuntimeError`` when its fallback also fails."""
obj = _obbject("/raise/runtime")
charting = Charting(obj)
with pytest.raises(RuntimeError, match="Failed to automatically create"):
charting.to_chart(
data=pd.DataFrame({"label": ["x", "y", "z"]}), render=False
)
self._extra_params = {}
self.results = "mock_results"
self.provider = "mock_provider"
self.extra = {}
self.warnings = "mock_warnings"
self.chart = MagicMock()
def to_dataframe(self):
"""Mock to_dataframe."""
return mock_dataframe
return MockOBBject()
def test_charting_settings(obbject):
"""Test charting_settings."""
cm = Charting(obbject)
assert isinstance(cm, Charting)
class TestTable:
"""Tests for the ``table`` method."""
def test_table_with_query_toolbar(self, equity_obbject):
"""``table`` dispatches with the query toolbar enabled by default."""
charting = Charting(equity_obbject)
recorder = RecorderBackend()
charting._backend = recorder
charting.table()
assert len(recorder.tables) == 1
assert "include_query_toolbar" not in recorder.tables[0]
def test_table_without_query_toolbar(self, equity_obbject):
"""``table`` forwards the disabled query-toolbar flag."""
charting = Charting(equity_obbject)
recorder = RecorderBackend()
charting._backend = recorder
charting.table(include_query_toolbar=False)
assert recorder.tables[0]["include_query_toolbar"] is False
def test_table_with_named_index_and_title(self, equity_obbject):
"""``table`` resets a named index and uses the explicit title."""
charting = Charting(equity_obbject)
recorder = RecorderBackend()
charting._backend = recorder
charting.table(data=_make_ohlcv(), title="My Title")
assert recorder.tables[0]["title"] == "My Title"
def test_table_with_range_index(self, equity_obbject):
"""``table`` handles a default ``RangeIndex`` DataFrame."""
charting = Charting(equity_obbject)
recorder = RecorderBackend()
charting._backend = recorder
charting.table(data=pd.DataFrame({"a": [1, 2, 3]}))
assert len(recorder.tables) == 1
def test_table_warns_on_backend_failure(self, equity_obbject):
"""``table`` warns when the backend dispatch raises."""
charting = Charting(equity_obbject)
charting._backend = RaisingBackend()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
charting.table()
assert any("Failed to show table" in str(w.message) for w in caught)
def test_indicators(obbject):
"""Test indicators method."""
obj = Charting(obbject)
indicators = list(obj.indicators().model_dump().keys())
class TestUrl:
"""Tests for the ``url`` method."""
assert indicators == [
"sma",
"ema",
"hma",
"wma",
"zlma",
"ad",
"adoscillator",
"adx",
"aroon",
"atr",
"cci",
"clenow",
"demark",
"donchian",
"fib",
"fisher",
"ichimoku",
"kc",
"macd",
"obv",
"rsi",
"srlines",
"stoch",
]
def test_url_delegates_to_backend(self, equity_obbject):
"""``url`` forwards its arguments to the backend dispatch."""
charting = Charting(equity_obbject)
recorder = RecorderBackend()
charting._backend = recorder
charting.url("https://example.com", title="Docs", width=100, height=200)
assert recorder.urls[0]["url"] == "https://example.com"
assert recorder.urls[0]["width"] == 100
def test_url_warns_on_backend_failure(self, equity_obbject):
"""``url`` warns when the backend dispatch raises."""
charting = Charting(equity_obbject)
charting._backend = RaisingBackend()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
charting.url("https://example.com")
assert any("Failed to show figure" in str(w.message) for w in caught)
@patch("openbb_charting.charting.get_charting_functions_list")
def test_functions(get_charting_functions_list):
"""Test functions method."""
# Arrange
get_charting_functions_list.return_value = ["function1", "function2", "function3"]
class TestToggleAndStyle:
"""Tests for chart-style helpers."""
# Act
result = Charting.functions()
def test_toggle_chart_style_without_chart_raises(self, equity_obbject):
"""Toggling style with no chart present raises a ``ValueError``."""
charting = Charting(equity_obbject)
with pytest.raises(ValueError, match="No chart has been created"):
charting.toggle_chart_style()
# Assert
assert set(result) == {"function1", "function2", "function3"}
assert get_charting_functions_list.call_count >= 1
def test_toggle_chart_style_flips_style(self, equity_obbject):
"""Toggling style flips the configured chart style and updates the chart."""
charting = Charting(equity_obbject)
charting.show(render=False)
before = charting._charting_settings.chart_style
charting.toggle_chart_style()
assert charting._charting_settings.chart_style != before
assert equity_obbject.chart.content is not None
def test_set_chart_style_is_passthrough(self, equity_obbject):
"""``_set_chart_style`` returns the figure unchanged."""
charting = Charting(equity_obbject)
fig = OpenBBFigure()
assert charting._set_chart_style(fig) is fig
@patch("openbb_charting.core.backend.Backend")
def test_backend_init(mock_backend_cls, obbject):
"""Test Backend is initialized in the constructor."""
obj = Charting(obbject)
class TestConvertToString:
"""Tests for the static ``_convert_to_string`` sanitizer."""
mock_backend_cls.assert_called_once_with(obj._charting_settings)
assert obj._backend == mock_backend_cls.return_value
def test_finite_number_passes_through(self):
"""A finite number is returned unchanged."""
assert Charting._convert_to_string(1.5) == 1.5
def test_nan_becomes_empty_string(self):
"""A NaN float renders as an empty string."""
assert Charting._convert_to_string(float("nan")) == ""
def test_get_chart_function(obbject):
"""Test _get_chart_function method."""
# Arrange
mock_function = MagicMock()
charting = Charting(obbject)
charting._functions = {"some_function": mock_function}
route = "/some/function"
def test_dict_joins_values(self):
"""A dict is rendered as a comma-joined list of its values."""
assert Charting._convert_to_string({"a": 1, "b": 2}) == "1, 2"
# Act
result = charting._get_chart_function(route)
def test_list_of_dicts_joins_values(self):
"""A list of dicts joins each dict's values."""
assert Charting._convert_to_string([{"a": 1}, {"b": 2}]) == "1, 2"
# Assert
assert result == mock_function
def test_plain_list_joins_items(self):
"""A plain list joins its items with commas."""
assert Charting._convert_to_string([1, 2, 3]) == "1, 2, 3"
@patch("openbb_charting.charting.Charting._get_chart_function")
def test_show(mock_get_chart_function, obbject):
"""Test show method."""
# Arrange
mock_function = MagicMock()
mock_get_chart_function.return_value = mock_function
mock_fig = MagicMock()
mock_fig.show = MagicMock(
return_value=MagicMock(to_plotly_json=MagicMock(return_value={}))
)
mock_function.return_value = (mock_fig, {"content": "mock_content"})
obj = Charting(obbject)
# Act
obj.show(render=False)
# Assert
mock_get_chart_function.assert_called_once()
mock_function.assert_called_once()
@patch("openbb_charting.charting.Charting._prepare_data_as_df")
@patch("openbb_charting.charting.Charting._get_chart_function")
def test_to_chart(mock_get_chart_function, mock_prepare_data_as_df, obbject):
"""Test to_chart method."""
# Arrange
mock_prepare_data_as_df.return_value = (mock_dataframe, True)
mock_function = MagicMock()
mock_get_chart_function.return_value = mock_function
mock_fig = MagicMock()
mock_fig.show = MagicMock(
return_value=MagicMock(to_plotly_json=MagicMock(return_value={}))
)
mock_function.return_value = (mock_fig, {"content": "mock_content"})
obj = Charting(obbject)
# Act
obj.to_chart(render=False)
# Assert
mock_get_chart_function.assert_called_once()
mock_function.assert_called_once()
def test_string_strips_brackets(self):
"""A string value strips bracket characters."""
assert Charting._convert_to_string("[hello]") == "hello"

View File

@@ -2,33 +2,61 @@
import pandas as pd
import pytest
from openbb_charting.core.plotly_ta.ta_helpers import (
check_columns,
)
def test_check_columns():
"""Test check_columns."""
data = pd.DataFrame(
{
"open": [1, 2, 3, 4, 5],
"high": [1, 2, 3, 4, 5],
"low": [1, 2, 3, 4, 5],
"close": [1, 2, 3, 4, 5],
"volume": [1, 2, 3, 4, 5],
}
)
result = check_columns(data)
assert result
class TestCheckColumns:
"""Tests for the ``check_columns`` helper."""
def test_check_columns(self):
"""It returns the standard 'close' column when present."""
data = pd.DataFrame(
{
"open": [1, 2, 3, 4, 5],
"high": [1, 2, 3, 4, 5],
"low": [1, 2, 3, 4, 5],
"close": [1, 2, 3, 4, 5],
"volume": [1, 2, 3, 4, 5],
}
)
result = check_columns(data)
assert result == "close"
def test_check_columns_fail():
"""Test check_columns."""
data = pd.DataFrame(
{
"open": [1, 2, 3, 4, 5],
"volume": [1, 2, 3, 4, 5],
}
)
with pytest.raises(IndexError):
check_columns(data)
def test_check_columns_fail(self):
"""It raises IndexError when no close-like column exists."""
data = pd.DataFrame(
{
"open": [1, 2, 3, 4, 5],
"volume": [1, 2, 3, 4, 5],
}
)
with pytest.raises(IndexError):
check_columns(data)
def test_check_columns_non_standard_close(self):
"""It returns a non-standard close column when 'close' is absent."""
data = pd.DataFrame(
{
"High": [1, 2, 3, 4, 5],
"Low": [1, 2, 3, 4, 5],
"Adj Close": [1, 2, 3, 4, 5],
}
)
result = check_columns(data)
assert result == "Adj Close"
def test_check_columns_prefers_standard_close(self):
"""It prefers the lowercase 'close' over other close-like columns."""
data = pd.DataFrame(
{
"High": [1, 2, 3, 4, 5],
"Low": [1, 2, 3, 4, 5],
"Adj Close": [1, 2, 3, 4, 5],
"close": [1, 2, 3, 4, 5],
}
)
result = check_columns(data)
assert result == "close"

View File

@@ -0,0 +1,206 @@
"""Tests for ``openbb_charting.charts.helpers``."""
from __future__ import annotations
import pandas as pd
import pytest
from openbb_charting.charts import helpers
from openbb_charting.charts.helpers import (
calculate_returns,
duration_sorter,
get_charting_functions,
get_charting_functions_list,
heikin_ashi,
should_share_axis,
z_score_standardization,
)
class _View:
"""A small stand-in module-like view object for discovery tests."""
__module__ = "tests._fake_view_module"
def _public_implemented(self): # pragma: no cover - body never executed
"""A public function that is implemented."""
return 1
def _public_not_implemented(self): # pragma: no cover - body never executed
"""A public function that raises NotImplementedError."""
raise NotImplementedError
class TestZScoreStandardization:
"""Tests for ``z_score_standardization``."""
def test_returns_zero_mean_unit_std(self, ohlcv_df):
"""It returns a Series with ~zero mean and ~unit standard deviation."""
result = z_score_standardization(ohlcv_df["close"])
assert isinstance(result, pd.Series)
assert abs(result.mean()) < 1e-9
assert abs(result.std() - 1) < 1e-9
class TestCalculateReturns:
"""Tests for ``calculate_returns``."""
def test_first_value_is_zero(self, ohlcv_df):
"""It returns cumulative percentage returns starting at zero."""
result = calculate_returns(ohlcv_df["close"])
assert isinstance(result, pd.Series)
assert result.iloc[0] == 0
assert len(result) == len(ohlcv_df)
class TestShouldShareAxis:
"""Tests for ``should_share_axis``."""
def test_true_when_ratio_within_threshold(self):
"""It returns True when two columns have a similar range."""
df = pd.DataFrame({"a": [0, 1, 2, 3], "b": [0, 1, 2, 3]})
assert should_share_axis(df, "a", "b", threshold=2.5) is True
def test_true_when_ratio_exactly_one(self):
"""It returns True when the range ratio equals exactly one."""
df = pd.DataFrame({"a": [0, 10], "b": [5, 15]})
assert should_share_axis(df, "a", "b") is True
def test_false_when_ratio_exceeds_threshold(self):
"""It returns False when the ranges differ beyond the threshold."""
df = pd.DataFrame({"a": [0, 1], "b": [0, 1000]})
assert should_share_axis(df, "a", "b", threshold=2.5) is False
def test_series_input_is_coerced_to_frame(self):
"""It coerces a Series input to a frame and shares the axis with itself."""
series = pd.Series([1, 2, 3], name="x")
assert should_share_axis(series, "x", "x") is True
def test_exception_returns_false(self):
"""It returns False when an invalid column triggers an exception."""
df = pd.DataFrame({"a": [1, 2, 3]})
assert should_share_axis(df, "missing", "a") is False
class TestHeikinAshi:
"""Tests for ``heikin_ashi``."""
def test_returns_recomputed_ohlc(self, ohlcv_df):
"""It returns a copy with Heikin Ashi OHLC values."""
result = heikin_ashi(ohlcv_df)
assert isinstance(result, pd.DataFrame)
assert list(result.columns) == list(ohlcv_df.columns)
assert len(result) == len(ohlcv_df)
assert not result["close"].equals(ohlcv_df["close"])
def test_missing_columns_raises_value_error(self):
"""It raises ValueError when required OHLC columns are missing."""
df = pd.DataFrame({"open": [1, 2], "close": [1, 2]})
with pytest.raises(ValueError, match="expected column labels"):
heikin_ashi(df)
class TestDurationSorter:
"""Tests for ``duration_sorter``."""
def test_sorts_months_and_years(self):
"""It sorts mixed month and year duration labels ascending."""
durations = ["year_1", "month_3", "month_1", "year_2"]
assert duration_sorter(durations) == [
"month_1",
"month_3",
"year_1",
"year_2",
]
def test_long_term_sorts_last(self):
"""It places the special ``long_term`` label after shorter durations."""
durations = ["long_term", "month_6", "year_1"]
assert duration_sorter(durations) == ["month_6", "year_1", "long_term"]
class TestGetChartingFunctions:
"""Tests for ``get_charting_functions`` and its list variant."""
def test_discovers_only_implemented_public_functions(self):
"""It returns implemented, public, same-module functions only."""
class View:
"""A view exposing implemented, private and not-implemented funcs."""
View.__module__ = __name__
View.implemented = staticmethod(_public_implemented)
View.not_implemented = staticmethod(_public_not_implemented)
View._private = staticmethod(_public_implemented)
_public_implemented.__module__ = __name__
_public_not_implemented.__module__ = __name__
result = get_charting_functions(View)
assert "implemented" in result
assert "not_implemented" not in result
assert "_private" not in result
def test_excludes_functions_from_other_modules(self):
"""It excludes functions whose module differs from the view's module."""
class View:
"""A view whose function comes from a different module."""
View.__module__ = "some.other.module"
View.implemented = staticmethod(_public_implemented)
_public_implemented.__module__ = __name__
result = get_charting_functions(View)
assert result == {}
def test_list_returns_names(self):
"""It returns the names of the discovered functions as a list."""
class View:
"""A view exposing a single implemented function."""
View.__module__ = __name__
View.implemented = staticmethod(_public_implemented)
_public_implemented.__module__ = __name__
result = get_charting_functions_list(View)
assert isinstance(result, list)
assert result == ["implemented"]
def test_get_charting_functions_returns_name_callable_dict(self):
"""It discovers implemented functions as a name->callable mapping."""
def _view_fn():
"""An implemented view function."""
return None
class View:
"""A view exposing a single implemented function."""
View.__module__ = __name__
View.view_chart = staticmethod(_view_fn)
_view_fn.__module__ = __name__
result = get_charting_functions(View)
assert isinstance(result, dict)
assert "view_chart" in result
assert callable(result["view_chart"])
class TestModuleExports:
"""Tests confirming the module exposes the expected callables."""
def test_callables_present(self):
"""It exposes all documented helper callables at module level."""
for name in (
"z_score_standardization",
"calculate_returns",
"should_share_axis",
"heikin_ashi",
"duration_sorter",
"get_charting_functions",
"get_charting_functions_list",
):
assert callable(getattr(helpers, name))

View File

@@ -0,0 +1,23 @@
"""Tests for ``openbb_charting.styles.colors``."""
from openbb_charting.styles import colors
class TestColors:
"""Exercise the color palette module."""
def test_large_cycler_is_list_of_strings(self):
"""LARGE_CYCLER is a non-empty list whose entries are all strings."""
assert isinstance(colors.LARGE_CYCLER, list)
assert colors.LARGE_CYCLER
assert all(isinstance(color, str) for color in colors.LARGE_CYCLER)
def test_large_cycler_known_values(self):
"""LARGE_CYCLER contains the expected first and named entries."""
assert colors.LARGE_CYCLER[0] == "#1f77b4"
assert "burlywood" in colors.LARGE_CYCLER
assert colors.LARGE_CYCLER[-1] == "#d9f202"
def test_large_cycler_length(self):
"""LARGE_CYCLER has the full set of palette entries."""
assert len(colors.LARGE_CYCLER) == 35

View File

@@ -0,0 +1,72 @@
"""Tests for ``openbb_charting.charts.correlation_matrix``."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from openbb_core.provider.abstract.data import Data
from openbb_charting.charts.correlation_matrix import correlation_matrix
from openbb_charting.core.openbb_figure import OpenBBFigure
@pytest.fixture
def long_format_close() -> list[Data]:
"""Long-format close-price records for three symbols."""
rng = np.random.default_rng(0)
idx = pd.date_range("2023-01-01", periods=40, freq="D")
rows: list[dict] = []
for sym in ("AAA", "BBB", "CCC"):
close = 100 + np.cumsum(rng.normal(0, 1, 40))
for stamp, value in zip(idx, close):
rows.append(
{
"date": stamp.strftime("%Y-%m-%d"),
"symbol": sym,
"close": float(value),
}
)
return [Data.model_validate(row) for row in rows]
class TestCorrelationMatrix:
"""Tests for the rendering paths of ``correlation_matrix``."""
def test_wide_dataframe_pearson(self, multi_close_df):
"""It builds a Pearson correlation heatmap from a wide frame."""
fig, content = correlation_matrix(data=multi_close_df)
assert isinstance(fig, OpenBBFigure)
assert isinstance(content, dict)
assert "data" in content
def test_method_kendall(self, multi_close_df):
"""It honors the Kendall correlation method."""
fig, _ = correlation_matrix(data=multi_close_df, method="kendall")
assert isinstance(fig, OpenBBFigure)
def test_method_spearman(self, multi_close_df):
"""It honors the Spearman correlation method."""
fig, _ = correlation_matrix(data=multi_close_df, method="spearman")
assert isinstance(fig, OpenBBFigure)
def test_long_format_pivot(self, long_format_close):
"""It pivots long-format ``symbol``/``close`` data before correlating."""
fig, _ = correlation_matrix(obbject_item=long_format_close)
assert isinstance(fig, OpenBBFigure)
def test_list_input_via_data_kwarg(self, long_format_close):
"""It accepts a list of ``Data`` via the ``data`` kwarg."""
fig, _ = correlation_matrix(data=long_format_close)
assert isinstance(fig, OpenBBFigure)
def test_custom_title_colorscale_and_layout(self, multi_close_df):
"""It honors a custom title, colorscale, and layout kwargs."""
fig, _ = correlation_matrix(
data=multi_close_df,
title="My Correlations",
colorscale="Viridis",
layout_kwargs={"width": 500},
)
assert fig.layout.title.text == "My Correlations"
assert fig.layout.width == 500

View File

@@ -0,0 +1,48 @@
"""Tests for ``openbb_charting.core.dummy_backend``."""
from __future__ import annotations
import pytest
from openbb_charting.core.dummy_backend import DummyBackend
class TestDummyBackend:
"""Tests for the no-op ``DummyBackend`` fallback."""
def test_init_without_theme(self):
"""It initializes with no theme when none is supplied."""
backend = DummyBackend()
assert backend.theme is None
def test_init_with_theme(self):
"""It stores the theme passed as a keyword argument."""
backend = DummyBackend(theme="dark")
assert backend.theme == "dark"
def test_show_plotly_raises_not_implemented(self):
"""``show_plotly`` raises ``NotImplementedError``."""
with pytest.raises(NotImplementedError, match="pywry is not installed"):
DummyBackend().show_plotly(figure=object())
def test_show_dataframe_raises_not_implemented(self):
"""``show_dataframe`` raises ``NotImplementedError``."""
with pytest.raises(NotImplementedError, match="pywry is not installed"):
DummyBackend().show_dataframe(data=object())
def test_show_raises_not_implemented(self):
"""``show`` raises ``NotImplementedError``."""
with pytest.raises(NotImplementedError, match="pywry is not installed"):
DummyBackend().show(content="<html></html>")
def test_emit_is_noop(self):
"""``emit`` accepts arbitrary arguments and returns ``None``."""
assert DummyBackend().emit("event", {"a": 1}, "label") is None
def test_close_is_noop(self):
"""``close`` accepts keyword arguments and returns ``None``."""
assert DummyBackend().close(reset=True) is None
def test_destroy_is_noop(self):
"""``destroy`` returns ``None``."""
assert DummyBackend().destroy() is None

View File

@@ -0,0 +1,440 @@
"""Tests for ``openbb_charting.charts.generic_charts``."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from openbb_charting.charts.generic_charts import (
bar_chart,
bar_increasing_decreasing,
line_chart,
surface3d,
)
@pytest.fixture
def category_bar_df() -> pd.DataFrame:
"""A small categorical frame for bar-chart tests."""
return pd.DataFrame(
{
"label": ["a", "b", "c", "d"],
"value": [10, 20, 30, 40],
"other": [5, 15, 25, 35],
}
)
@pytest.fixture
def surface_points() -> tuple[pd.Series, pd.Series, pd.Series]:
"""A grid of points sufficient to triangulate a 3D surface."""
rng = np.random.default_rng(0)
xs, ys, zs = [], [], []
for i in range(6):
for j in range(6):
xs.append(float(i))
ys.append(float(j))
zs.append(float(rng.uniform(0, 1)))
return pd.Series(xs), pd.Series(ys), pd.Series(zs)
class TestLineChartInputs:
"""Tests for the accepted input types of ``line_chart``."""
def test_none_data_raises(self):
"""It raises ValueError when data is None."""
with pytest.raises(ValueError, match="Data is a required field"):
line_chart(None)
def test_dataframe_input(self, ohlcv_df):
"""It plots a single-symbol OHLCV DataFrame."""
fig = line_chart(ohlcv_df)
assert fig.data
def test_series_input(self, ohlcv_df):
"""It plots a pandas Series input."""
fig = line_chart(ohlcv_df["close"])
assert fig.data
def test_records_list_input(self, ohlcv_records):
"""It plots a list of record dicts."""
fig = line_chart(ohlcv_records)
assert fig.data
def test_data_models_input(self, ohlcv_data):
"""It plots a list of Data models."""
fig = line_chart(ohlcv_data)
assert fig.data
def test_ndarray_input(self):
"""It plots a numpy ndarray, falling back to a positional index."""
arr = np.column_stack([np.arange(10), np.arange(10) * 2.0])
fig = line_chart(arr)
assert fig.data
def test_multi_symbol_pivot(self, ohlcv_records):
"""It pivots when a multi-symbol 'symbol' column is present."""
recs_a = [dict(r, symbol="AAA") for r in ohlcv_records]
recs_b = [dict(r, symbol="BBB", close=r["close"] + 5) for r in ohlcv_records]
fig = line_chart(recs_a + recs_b)
assert len(fig.data) >= 2
def test_date_column_promoted_to_index(self):
"""It promotes a non-index 'date' column to the datetime index."""
df = pd.DataFrame(
{
"date": ["2023-01-01", "2023-01-02", "2023-01-03"],
"close": [1.0, 2.0, 3.0],
}
)
fig = line_chart(df)
assert fig.data
assert fig.layout.xaxis.type is None
def test_no_date_column_falls_back_to_first_column(self):
"""It falls back to the first column when no date index is found."""
df = pd.DataFrame({"id": [1, 2, 3], "close": [10.0, 11.0, 12.0]})
df.index.name = None
fig = line_chart(df)
assert fig.data
class TestLineChartParams:
"""Tests for the parameter branches of ``line_chart``."""
def test_normalize(self, multi_close_df):
"""It applies z-score normalization and sets the Z-Score title."""
fig = line_chart(multi_close_df, normalize=True)
assert "Z-Score" in fig.layout.title.text
def test_returns(self, multi_close_df):
"""It applies cumulative returns and sets the Cumulative Returns title."""
fig = line_chart(multi_close_df, returns=True)
assert "Cumulative Returns" in fig.layout.title.text
def test_returns_with_title(self, multi_close_df):
"""It appends the returns suffix to a provided title."""
fig = line_chart(multi_close_df, returns=True, title="My Title")
assert fig.layout.title.text == "My Title - Cumulative Returns"
def test_normalize_with_title(self, multi_close_df):
"""It appends the z-score suffix to a provided title."""
fig = line_chart(multi_close_df, normalize=True, title="My Title")
assert fig.layout.title.text == "My Title - Z-Score"
def test_same_axis(self, multi_close_df):
"""It plots all columns on a single shared axis."""
fig = line_chart(multi_close_df, same_axis=True)
assert len(fig.data) == len(multi_close_df.columns)
assert all(tr.yaxis == "y" for tr in fig.data)
def test_auto_layout_multi_axis(self, multi_close_df):
"""It auto-lays out divergent series across multiple y-axes."""
wide = multi_close_df.copy()
wide["BBB"] = wide["BBB"] * 1000
wide["CCC"] = wide["CCC"] * 1_000_000
fig = line_chart(wide)
used_axes = {tr.yaxis for tr in fig.data}
assert len(used_axes) >= 2
def test_y_string_split(self, multi_close_df):
"""It splits a comma-separated y string into multiple columns."""
fig = line_chart(multi_close_df, y="AAA,BBB", same_axis=False)
names = {tr.name for tr in fig.data}
assert {"AAA", "BBB"}.issubset(names)
def test_y_and_y2_lists(self, multi_close_df):
"""It places y on the primary and y2 on the secondary axis."""
fig = line_chart(multi_close_df, y=["AAA", "BBB"], y2=["CCC"])
axes = {tr.name: tr.yaxis for tr in fig.data}
assert axes["AAA"] == "y"
assert axes["CCC"] == "y2"
def test_titles_applied(self, multi_close_df):
"""It applies the provided axis titles to the layout."""
fig = line_chart(
multi_close_df,
y=["AAA"],
y2=["BBB"],
ytitle="Primary",
y2title="Secondary",
xtitle="Time",
title="Chart",
)
assert fig.layout.yaxis.title.text == "Primary"
assert fig.layout.yaxis2.title.text == "Secondary"
assert fig.layout.xaxis.title.text == "Time"
def test_layout_and_scatter_kwargs(self, multi_close_df):
"""It applies layout_kwargs and scatter_kwargs overrides."""
fig = line_chart(
multi_close_df,
y=["AAA"],
layout_kwargs={"width": 1234},
scatter_kwargs={"mode": "lines+markers"},
)
assert fig.layout.width == 1234
assert any("markers" in (tr.mode or "") for tr in fig.data)
def test_custom_hovertemplate(self, multi_close_df):
"""It honors a custom hovertemplate supplied via scatter_kwargs."""
fig = line_chart(
multi_close_df,
y=["AAA"],
scatter_kwargs={"hovertemplate": "custom<extra></extra>"},
)
assert any(tr.hovertemplate == "custom<extra></extra>" for tr in fig.data)
def test_target_default_title(self, ohlcv_df):
"""It titles the chart from the default target when none is given."""
fig = line_chart(ohlcv_df)
assert fig.layout.title.text == "Close"
def test_target_selection(self, ohlcv_df):
"""It selects the requested target column."""
fig = line_chart(ohlcv_df, target="open")
assert fig.layout.title.text == "Open"
def test_category_xaxis_for_non_date_index(self):
"""It uses a category x-axis when the index is not date/timestamp."""
df = pd.DataFrame({"id": [1, 2, 3], "close": [10.0, 11.0, 12.0]})
df.index.name = None
fig = line_chart(df)
assert fig.layout.xaxis.type == "category"
def test_non_numeric_data_raises(self):
"""It raises ValueError when auto-layout finds no numeric columns."""
df = pd.DataFrame(
{"date": pd.date_range("2023-01-01", periods=3), "close": ["x", "y", "z"]}
).set_index("date")
with pytest.raises(ValueError, match="expected data with numeric values"):
line_chart(df)
def test_auto_layout_three_axes(self):
"""It allocates a third y-axis when three divergent series exist."""
idx = pd.date_range("2023-01-01", periods=50, freq="D")
df = pd.DataFrame(
{
"small": np.linspace(1, 2, 50),
"medium": np.linspace(100, 200, 50),
"large": np.linspace(10_000, 50_000, 50),
},
index=idx,
)
df.index.name = "date"
fig = line_chart(df)
used_axes = {tr.yaxis for tr in fig.data}
assert "y3" in used_axes
class TestBarChart:
"""Tests for ``bar_chart``."""
def test_vertical_single_y(self, category_bar_df):
"""It renders a vertical bar chart for a single y column."""
fig = bar_chart(category_bar_df, x="label", y="value")
assert fig.data
assert fig.layout.barmode == "group"
def test_horizontal_orientation(self, category_bar_df):
"""It renders a horizontal bar chart and a linear x-axis."""
fig = bar_chart(category_bar_df, x="label", y="value", orientation="h")
assert fig.data[0].orientation == "h"
assert fig.layout.yaxis.type == "category"
def test_multiple_y_list(self, category_bar_df):
"""It renders multiple y columns with legend shown."""
fig = bar_chart(category_bar_df, x="label", y=["value", "other"])
assert len(fig.data) == 2
assert fig.data[0].showlegend is True
def test_y_string_split(self, category_bar_df):
"""It splits a comma-separated y string into multiple bars."""
fig = bar_chart(category_bar_df, x="label", y="value,other")
assert len(fig.data) == 2
def test_barmode_stack(self, category_bar_df):
"""It honors a non-group barmode."""
fig = bar_chart(category_bar_df, x="label", y="value", barmode="stack")
assert fig.layout.barmode == "stack"
def test_xtype_variants(self, category_bar_df):
"""It applies the requested x-axis type for vertical bars."""
fig = bar_chart(category_bar_df, x="label", y="value", xtype="log")
assert fig.layout.xaxis.type == "log"
def test_colors_applied(self, category_bar_df):
"""It applies a custom colorway when colors are provided."""
fig = bar_chart(
category_bar_df, x="label", y="value", colors=["#111111", "#222222"]
)
assert tuple(fig.layout.colorway) == ("#111111", "#222222")
def test_titles_applied(self, category_bar_df):
"""It applies the title and axis titles."""
fig = bar_chart(
category_bar_df,
x="label",
y="value",
title="T",
xtitle="X",
ytitle="Y",
)
assert fig.layout.title.text == "T"
assert fig.layout.xaxis.title.text == "X"
assert fig.layout.yaxis.title.text == "Y"
def test_bar_kwargs_width_and_hovertemplate(self, category_bar_df):
"""It honors width and hovertemplate supplied via bar_kwargs."""
fig = bar_chart(
category_bar_df,
x="label",
y="value",
bar_kwargs={"width": 0.5, "hovertemplate": "h<extra></extra>"},
)
assert fig.data[0].width == 0.5
assert fig.data[0].hovertemplate == "h<extra></extra>"
def test_layout_kwargs(self, category_bar_df):
"""It applies layout_kwargs overrides."""
fig = bar_chart(
category_bar_df, x="label", y="value", layout_kwargs={"width": 999}
)
assert fig.layout.width == 999
def test_data_model_input(self, category_bar_df):
"""It accepts Data-model/list inputs and converts internally."""
records = category_bar_df.to_dict(orient="records")
fig = bar_chart(records, x="label", y="value")
assert fig.data
def test_horizontal_group_multi_width(self, category_bar_df):
"""It computes a divided width for grouped multi-series bars."""
fig = bar_chart(
category_bar_df,
x="label",
y=["value", "other"],
orientation="h",
barmode="group",
)
assert len(fig.data) == 2
class TestBarIncreasingDecreasing:
"""Tests for ``bar_increasing_decreasing``."""
def test_horizontal_default(self):
"""It renders increasing and decreasing bars horizontally."""
fig = bar_increasing_decreasing(
keys=["a", "b", "c", "d"], values=[1, -2, 3, -4]
)
assert len(fig.data) == 2
def test_vertical_orientation(self):
"""It renders increasing and decreasing bars vertically."""
fig = bar_increasing_decreasing(
keys=["a", "b", "c", "d"], values=[1, -2, 3, -4], orientation="v"
)
assert fig.data[0].orientation == "v"
def test_only_increasing(self):
"""It renders a single trace when all values are positive."""
fig = bar_increasing_decreasing(keys=["a", "b"], values=[1, 2])
assert len(fig.data) == 1
def test_only_decreasing(self):
"""It renders a single trace when all values are negative."""
fig = bar_increasing_decreasing(keys=["a", "b"], values=[-1, -2])
assert len(fig.data) == 1
def test_group_barmode_width(self):
"""It computes a divided width for the group barmode."""
fig = bar_increasing_decreasing(
keys=["a", "b", "c"], values=[1, -2, 3], barmode="group"
)
assert fig.data
def test_titles_and_layout_kwargs(self):
"""It applies titles and layout_kwargs overrides."""
fig = bar_increasing_decreasing(
keys=["a", "b"],
values=[1, -1],
title="T",
xtitle="X",
ytitle="Y",
layout_kwargs={"width": 777},
)
assert fig.layout.title.text == "T"
assert fig.layout.width == 777
def test_invalid_values_raise(self):
"""It raises ValueError when Series construction fails."""
with pytest.raises(ValueError, match="Error:"):
bar_increasing_decreasing(keys=["a", "b"], values=[1])
class TestSurface3d:
"""Tests for ``surface3d``."""
def test_basic_surface(self, surface_points):
"""It renders a 3D mesh surface from a point cloud."""
x, y, z = surface_points
fig = surface3d(x, y, z)
assert fig.data
def test_title_set(self, surface_points):
"""It sets the chart title when a non-default title is provided."""
x, y, z = surface_points
fig = surface3d(x, y, z, title="My Surface")
assert fig.layout.title.text == "My Surface"
def test_openbb_platform_title_blanked(self, surface_points):
"""It blanks the placeholder 'OpenBB Platform' title."""
x, y, z = surface_points
fig = surface3d(x, y, z, title="OpenBB Platform")
assert fig.layout.title.text == ""
def test_custom_colorscale(self, surface_points):
"""It honors a custom colorscale."""
x, y, z = surface_points
fig = surface3d(x, y, z, colorscale="Viridis")
assert fig.data[0].colorscale is not None
def test_theme_override(self, surface_points):
"""It applies a theme override without error."""
x, y, z = surface_points
fig = surface3d(x, y, z, theme="light")
assert fig.data
def test_custom_axis_titles(self, surface_points):
"""It applies custom axis titles to the scene."""
x, y, z = surface_points
fig = surface3d(x, y, z, xtitle="Days", ytitle="K", ztitle="Vol")
assert fig.layout.scene.xaxis.title.text == "Days"
assert fig.layout.scene.yaxis.title.text == "K"
assert fig.layout.scene.zaxis.title.text == "Vol"
def test_empty_axis_titles_default(self, surface_points):
"""It falls back to default scene titles when titles are empty."""
x, y, z = surface_points
fig = surface3d(x, y, z, xtitle="", ytitle="", ztitle="")
assert fig.layout.scene.xaxis.title.text == "DTE"
assert fig.layout.scene.yaxis.title.text == "Strike"
assert fig.layout.scene.zaxis.title.text == "IV"
def test_layout_kwargs(self, surface_points):
"""It applies layout_kwargs overrides."""
x, y, z = surface_points
fig = surface3d(x, y, z, layout_kwargs={"width": 888})
assert fig.layout.width == 888
def test_insufficient_points_raises(self):
"""It raises OpenBBError when too few points to triangulate."""
from openbb_core.app.model.abstract.error import OpenBBError
x = pd.Series([1.0, 2.0])
y = pd.Series([1.0, 2.0])
z = pd.Series([1.0, 2.0])
with pytest.raises(OpenBBError, match="Not enough points"):
surface3d(x, y, z)

View File

@@ -0,0 +1,800 @@
"""Tests for ``openbb_charting.core.openbb_figure.OpenBBFigure``."""
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
import pytest
from openbb_core.app.model.charts.charting_settings import ChartingSettings
from openbb_charting.core.openbb_figure import OpenBBFigure
@pytest.fixture
def daily_index() -> pd.DatetimeIndex:
"""A 60-row daily ``DatetimeIndex``."""
return pd.date_range("2023-01-01", periods=60, freq="D")
@pytest.fixture
def daily_close(daily_index) -> pd.Series:
"""A deterministic daily close-price series."""
rng = np.random.default_rng(7)
values = 100 + np.cumsum(rng.normal(0, 1, len(daily_index)))
return pd.Series(values, index=daily_index, name="close")
@pytest.fixture
def intraday_df() -> pd.DataFrame:
"""A deterministic intraday OHLCV-style frame on an hourly index."""
idx = pd.date_range("2023-01-02 09:30", periods=80, freq="1h")
rng = np.random.default_rng(11)
close = 100 + np.cumsum(rng.normal(0, 1, len(idx)))
frame = pd.DataFrame(
{
"open": close,
"high": close + 1,
"low": close - 1,
"close": close,
"volume": rng.integers(1_000_000, 5_000_000, len(idx)),
},
index=idx,
)
frame.index.name = "date"
return frame
class _ReturningBackend:
"""A headless stand-in for the GUI backend whose ``send_figure`` returns."""
def __init__(self):
"""Record whether ``send_figure`` was invoked."""
self.called = False
def send_figure(self, fig, command_location=""):
"""Return the figure instead of rendering it to a window."""
self.called = True
return fig
class _RaisingBackend:
"""A headless backend whose ``send_figure`` always raises."""
def send_figure(self, fig, command_location=""):
"""Raise to simulate a backend failure."""
raise RuntimeError("backend boom")
class TestInit:
"""Construction and attribute defaults."""
def test_default_construction(self):
"""A bare figure initializes its private state and theme."""
fig = OpenBBFigure()
assert fig.theme is not None
assert fig.bar_width == 0.15
assert fig.cmd_xshift == 0
assert fig.subplots_kwargs == {}
assert fig.has_subplots is False
def test_copy_from_existing_figure(self):
"""Passing a ``go.Figure`` copies its ``__dict__``."""
base = go.Figure()
base.add_scatter(x=[1, 2], y=[3, 4], name="base")
fig = OpenBBFigure(base)
assert len(fig.data) == 1
def test_copy_from_dict(self):
"""Passing a figure dict rebuilds it via ``go.Figure``."""
spec = {"data": [{"type": "scatter", "x": [1, 2], "y": [3, 4]}]}
fig = OpenBBFigure(spec)
assert len(fig.data) == 1
def test_xaxis_yaxis_kwargs_applied(self):
"""``xaxis`` and ``yaxis`` kwargs are applied to the axes."""
fig = OpenBBFigure(xaxis=dict(title="X"), yaxis=dict(title="Y"))
assert fig.layout.xaxis.title.text == "X"
assert fig.layout.yaxis.title.text == "Y"
def test_property_setters(self):
"""The bar width, command shift and subplot kwargs setters work."""
fig = OpenBBFigure()
fig.bar_width = 0.3
fig.cmd_xshift = 12
fig.subplots_kwargs = {"rows": 2}
assert fig.bar_width == 0.3
assert fig.cmd_xshift == 12
assert fig.subplots_kwargs == {"rows": 2}
def test_create_backend_kwarg(self):
"""``create_backend=True`` with settings instantiates the backend."""
from openbb_charting.core.backend import Backend
saved = Backend.instance
Backend.instance = None
try:
settings = ChartingSettings()
fig = OpenBBFigure(charting_settings=settings, create_backend=True)
assert Backend.instance is not None
assert fig._backend is not None
finally:
Backend.instance = saved
class TestCreateSubplots:
"""The ``create_subplots`` class method."""
def test_basic_subplots(self):
"""A simple 2x1 grid is created with subplots metadata."""
fig = OpenBBFigure.create_subplots(rows=2, cols=1)
assert fig.has_subplots is True
assert fig._multi_rows is True
assert fig.subplots_kwargs["rows"] == 2
def test_secondary_y_specs(self):
"""A secondary-y spec flags ``has_secondary_y``."""
fig = OpenBBFigure.create_subplots(
rows=1, cols=1, specs=[[{"secondary_y": True}]]
)
assert fig._has_secondary_y is True
def test_single_row_not_multi(self):
"""A single row is not flagged as multi-row."""
fig = OpenBBFigure.create_subplots(rows=1, cols=1)
assert fig._multi_rows is False
class TestTitlesAndAxes:
"""Title and axis title helpers."""
def test_set_title_plain(self):
"""A plain title is written to the layout."""
fig = OpenBBFigure().set_title("Hello")
assert fig.layout.title.text == "Hello"
def test_set_title_wrap(self):
"""Wrapping inserts line breaks into a long title."""
fig = OpenBBFigure().set_title("word " * 40, wrap=True, wrap_width=20)
assert "<br>" in fig.layout.title.text
def test_set_title_with_row_col_adds_annotation(self):
"""A row/col title becomes an annotation rather than a layout title."""
fig = OpenBBFigure.create_subplots(rows=1, cols=1)
fig.set_title("Sub", row=1, col=1)
assert len(fig.layout.annotations) == 1
def test_set_xaxis_and_yaxis_title(self):
"""The axis title setters update the axes and return self."""
fig = OpenBBFigure()
assert fig.set_xaxis_title("X") is fig
assert fig.set_yaxis_title("Y") is fig
assert fig.layout.xaxis.title.text == "X"
assert fig.layout.yaxis.title.text == "Y"
class TestLegendHelpers:
"""Legend and line-legend helpers."""
def test_horizontal_legend(self):
"""The legend is set to a horizontal orientation."""
fig = OpenBBFigure()
fig.horizontal_legend()
assert fig.layout.legend.orientation == "h"
def test_add_hline_legend(self):
"""Adding a horizontal line legend creates the line and label."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="series")
fig.add_hline_legend(y=5, name="hl", line=dict(color="red", dash="dash"))
assert any(t.name == "hl" for t in fig.data)
def test_add_hline_legend_default_line(self):
"""A horizontal line legend with no line dict uses theme defaults."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="series")
fig.add_hline_legend(y=5, name="hl2")
assert any(t.name == "hl2" for t in fig.data)
def test_add_vline_legend_default_line(self):
"""Adding a vertical line legend with no line dict uses defaults."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="series")
fig.add_vline_legend(x=2, name="vl")
assert any(t.name == "vl" for t in fig.data)
def test_add_legend_label_only(self):
"""A label-only legend entry is added."""
fig = OpenBBFigure()
fig.add_legend_label(label="X")
assert any(t.name == "X" for t in fig.data)
def test_add_legend_label_trace_template_raises(self):
"""Templating from an existing trace hits the trace branch (and its bug)."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2], y=[3, 4], name="AAPL", mode="lines")
with pytest.raises(AttributeError):
fig.add_legend_label(trace="AAPL")
def test_add_legend_label_trace_not_found(self):
"""An unknown trace name raises a ``ValueError``."""
fig = OpenBBFigure()
with pytest.raises(ValueError, match="not found"):
fig.add_legend_label(trace="NOPE")
def test_add_legend_label_no_label(self):
"""Omitting the label raises a ``ValueError``."""
fig = OpenBBFigure()
with pytest.raises(ValueError, match="Label must be specified"):
fig.add_legend_label()
class TestTrend:
"""The ``add_trend`` helper."""
def test_add_trend_creates_shapes(self, daily_index):
"""Both trend columns produce line shapes."""
rng = np.random.default_rng(3)
base = 100 + np.cumsum(rng.normal(0, 1, len(daily_index)))
df = pd.DataFrame(
{"OC_High_trend": base + 5, "OC_Low_trend": base - 5}, index=daily_index
)
fig = OpenBBFigure.create_subplots(rows=1, cols=1)
fig.add_trend(df)
assert len(fig.layout.shapes) == 2
def test_add_trend_error_wrapped(self, daily_index):
"""An empty trend column after dropna raises a wrapped ``ValueError``."""
df = pd.DataFrame(
{"OC_High_trend": [np.nan] * len(daily_index)}, index=daily_index
)
fig = OpenBBFigure.create_subplots(rows=1, cols=1)
with pytest.raises(ValueError, match="Error adding trend line"):
fig.add_trend(df)
class TestHistPlot:
"""The ``add_histplot`` helper."""
def test_kde_curve_with_hist(self, daily_close):
"""A KDE curve with histogram adds curve and histogram traces."""
fig = OpenBBFigure.create_subplots(rows=1, cols=1)
fig.add_histplot(daily_close, name="dist", curve="kde", show_rug=False)
assert len(fig.data) >= 2
def test_normal_curve_raises_on_array_max(self, daily_close):
"""The normal-curve branch executes through its array ``max`` bug."""
fig = OpenBBFigure.create_subplots(rows=1, cols=1)
with pytest.raises(ValueError):
fig.add_histplot(daily_close.to_numpy(), name="dist", curve="normal")
def test_rug_only_with_str_color(self, daily_close):
"""A rug-only plot with a string color produces a scatter trace."""
fig = OpenBBFigure.create_subplots(rows=1, cols=1)
fig.add_histplot(
daily_close.to_numpy(),
name="dist",
colors="blue",
show_curve=False,
show_hist=False,
show_rug=True,
)
assert len(fig.data) == 1
def test_rug_with_two_names(self, daily_close):
"""Two names route the rug label to the second name."""
fig = OpenBBFigure.create_subplots(rows=1, cols=1)
fig.add_histplot(
[daily_close.to_numpy()],
name=["a", "b"],
show_curve=False,
show_hist=False,
show_rug=True,
)
assert len(fig.data) == 1
def test_hist_only_default_colors(self, daily_close):
"""A histogram-only plot with no names uses the default color cycle."""
fig = OpenBBFigure.create_subplots(rows=1, cols=1)
fig.add_histplot(
daily_close.to_numpy(),
show_curve=False,
show_rug=False,
show_hist=True,
)
assert len(fig.data) == 1
class TestVolume:
"""Volume scaling and in-chart volume helpers."""
def test_chart_volume_scaling_large(self):
"""A large-magnitude volume series yields range and tick lists."""
rng = np.random.default_rng(2)
vol = pd.Series(rng.integers(1_000_000, 50_000_000, 40))
out = OpenBBFigure.chart_volume_scaling(vol)
assert set(out) == {"range", "ticks"}
assert len(out["ticks"]) == 4
def test_chart_volume_scaling_small(self):
"""A small-magnitude volume series still produces ticks."""
rng = np.random.default_rng(2)
vol = pd.Series(rng.integers(100, 900, 40))
out = OpenBBFigure.chart_volume_scaling(vol)
assert len(out["ticks"]) == 4
def test_add_inchart_volume(self, intraday_df):
"""In-chart volume adds a bar trace and configures the volume axis."""
fig = OpenBBFigure.create_subplots(
rows=2, cols=1, specs=[[{"secondary_y": True}], [{}]]
)
fig.add_inchart_volume(intraday_df)
assert any(t.type == "bar" for t in fig.data)
class TestDateHandling:
"""Date index discovery and gap hiding."""
def test_get_dateindex_pandas(self, daily_index, daily_close):
"""A pandas datetime x-axis is discovered as a date index."""
fig = OpenBBFigure()
fig.add_scatter(x=daily_index, y=daily_close, name="close")
out = fig.get_dateindex()
assert out is not None
assert len(out) == len(daily_index)
def test_get_dateindex_numpy_datetime64(self, daily_index, daily_close):
"""A numpy ``datetime64`` x-axis is converted to ``datetime`` objects."""
fig = OpenBBFigure()
fig.add_scatter(
x=daily_index.to_numpy(), y=daily_close.to_numpy(), name="close"
)
out = fig.get_dateindex()
assert out is not None
from datetime import datetime
assert isinstance(out[0], datetime)
def test_get_dateindex_non_date(self):
"""A non-date x-axis yields ``None``."""
fig = OpenBBFigure()
fig.add_scatter(x=list(range(10)), y=list(range(10)), name="num")
assert fig.get_dateindex() is None
def test_get_dateindex_secondary_y(self, daily_index, daily_close):
"""A secondary-y date trace is classified through the primary/secondary split."""
fig = OpenBBFigure.create_subplots(
rows=1, cols=1, specs=[[{"secondary_y": True}]]
)
fig.add_scatter(x=daily_index, y=daily_close, name="primary")
fig.add_scatter(
x=daily_index, y=daily_close, name="secondary", secondary_y=True
)
out = fig.get_dateindex()
assert out is not None
assert len(out) == len(daily_index)
def test_get_dateindex_skips_trace_without_xaxis(self, daily_index, daily_close):
"""A pie trace lacking an x-axis is skipped during discovery."""
fig = OpenBBFigure()
fig.add_pie(labels=["a", "b"], values=[1, 2])
fig.add_scatter(x=daily_index, y=daily_close, name="close")
out = fig.get_dateindex()
assert out is not None
assert len(out) == len(daily_index)
def test_hide_date_gaps_daily(self, daily_index):
"""Daily data adds rangebreaks for the missing weekend days."""
df = pd.DataFrame(index=daily_index)
df.index.name = "date"
fig = OpenBBFigure()
fig.hide_date_gaps(df)
assert fig.layout.xaxis.rangebreaks is not None
def test_hide_date_gaps_intraday_with_gap(self):
"""Two intraday sessions separated by a gap insert bounded rangebreaks."""
session_a = pd.date_range("2023-01-02 09:30", periods=20, freq="30min")
session_b = pd.date_range("2023-01-03 09:30", periods=20, freq="30min")
df = pd.DataFrame(index=session_a.append(session_b))
fig = OpenBBFigure()
fig.hide_date_gaps(df)
assert fig.layout.xaxis.rangebreaks is not None
def test_hide_date_gaps_weekly_returns_early(self):
"""Weekly/low-frequency data short-circuits without rangebreaks."""
idx = pd.date_range("2023-01-01", periods=40, freq="W")
df = pd.DataFrame(index=idx)
fig = OpenBBFigure()
fig.hide_date_gaps(df)
assert not fig.layout.xaxis.rangebreaks
def test_add_rangebreaks_with_dates(self, daily_index, daily_close):
"""``add_rangebreaks`` walks the collected subplot x-dates."""
fig = OpenBBFigure()
fig.add_scatter(x=daily_index, y=daily_close, name="close")
fig.get_dateindex()
fig.add_rangebreaks()
assert fig.layout.xaxis.rangebreaks is not None
def test_add_rangebreaks_no_dates_returns(self):
"""``add_rangebreaks`` returns early when there is no date index."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="num")
fig.add_rangebreaks()
assert not fig.layout.xaxis.rangebreaks
def test_add_rangebreaks_value_error_continues(self, daily_index, daily_close):
"""An unconcatenatable x-date entry is skipped via the continue branch."""
fig = OpenBBFigure()
fig.add_scatter(x=daily_index, y=daily_close, name="close")
fig.get_dateindex()
fig._subplot_xdates = {1: {1: []}}
fig.add_rangebreaks()
assert fig is not None
class TestTickFormatStops:
"""The private ``_xaxis_tickformatstops`` helper."""
def test_daily_tickformatstops(self, daily_index, daily_close):
"""Daily data installs a single date tick format."""
fig = OpenBBFigure()
fig.add_scatter(x=daily_index, y=daily_close, name="daily")
fig.get_dateindex()
fig._xaxis_tickformatstops()
assert fig is not None
def test_intraday_tickformatstops(self, intraday_df):
"""Intraday data installs intraday tick formats."""
fig = OpenBBFigure()
fig.add_scatter(x=intraday_df.index, y=intraday_df["close"], name="intraday")
fig.get_dateindex()
fig._xaxis_tickformatstops()
assert fig is not None
def test_no_dateindex_returns_early(self):
"""A non-date figure returns early."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="num")
fig._xaxis_tickformatstops()
assert fig is not None
def test_existing_tickformat_returns_early(self, daily_index, daily_close):
"""A preset xaxis tickformat short-circuits the helper."""
fig = OpenBBFigure()
fig.add_scatter(x=daily_index, y=daily_close, name="daily")
fig.update_xaxes(tickformat="%Y")
fig._xaxis_tickformatstops()
assert fig.layout.xaxis.tickformat == "%Y"
class TestSubplotsDict:
"""The ``get_subplots_dict`` helper."""
def test_subplots_dict_populated(self):
"""A subplot figure produces a populated reference dict."""
fig = OpenBBFigure.create_subplots(rows=2, cols=1)
fig.add_scatter(x=[1, 2], y=[3, 4], name="a", row=1, col=1)
out = fig.get_subplots_dict()
assert out
def test_subplots_dict_empty_without_subplots(self):
"""A plain figure returns an empty dict."""
fig = OpenBBFigure()
assert fig.get_subplots_dict() == {}
def test_subplots_dict_skips_empty_cell(self):
"""A ``None`` spec cell is skipped while building the dict."""
fig = OpenBBFigure.create_subplots(rows=2, cols=2, specs=[[{}, None], [{}, {}]])
fig.add_scatter(x=[1, 2], y=[3, 4], name="a", row=1, col=1)
assert fig.get_subplots_dict()
class TestToSubplot:
"""The ``to_subplot`` helper."""
def test_to_subplot_with_kwargs(self):
"""Traces are moved into a subplot grid, applying trace kwargs."""
sub = OpenBBFigure.create_subplots(rows=2, cols=1)
src = OpenBBFigure()
src.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="line")
src.set_xaxis_title("X")
src.set_yaxis_title("Y")
out = src.to_subplot(sub, row=1, col=1, line_width=2)
assert len(out.data) == 1
def test_to_subplot_without_kwargs(self):
"""Traces move into the grid even without extra kwargs."""
sub = OpenBBFigure.create_subplots(rows=1, cols=1)
src = OpenBBFigure()
src.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="line")
out = src.to_subplot(sub, row=1, col=1)
assert len(out.data) == 1
class TestSerialization:
"""HTML and JSON serialization helpers."""
def test_to_html(self, daily_index, daily_close):
"""``to_html`` returns a non-empty HTML fragment."""
fig = OpenBBFigure()
fig.add_scatter(x=daily_index, y=daily_close, name="daily")
html = fig.to_html()
assert isinstance(html, str)
assert html
def test_to_plotly_json_sets_missing_margin(self):
"""A small/absent top margin is bumped to 50."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2], y=[3, 4], name="x")
out = fig.to_plotly_json()
assert out["layout"]["margin"]["t"] == 50
assert out["config"]["scrollZoom"] is True
def test_to_plotly_json_keeps_large_margin(self):
"""A large top margin is preserved."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2], y=[3, 4], name="x")
fig.update_layout(margin=dict(t=120))
out = fig.to_plotly_json()
assert out["layout"]["margin"]["t"] == 120
class TestTable:
"""Table construction helpers."""
@pytest.fixture
def small_df(self) -> pd.DataFrame:
"""A small labelled frame for table tests."""
idx = pd.date_range("2023-01-01", periods=6, freq="D")
rng = np.random.default_rng(5)
df = pd.DataFrame(
{"open": rng.normal(100, 1, 6), "close": rng.normal(100, 1, 6)},
index=idx,
)
df.index.name = "date"
return df
def test_row_colors_even(self, small_df):
"""An even row count yields one color per row."""
colors = OpenBBFigure.row_colors(small_df.head(4))
assert len(colors) == 4
def test_row_colors_odd(self, small_df):
"""An odd row count appends the trailing odd color."""
colors = OpenBBFigure.row_colors(small_df.head(5))
assert len(colors) == 5
def test_tbl_values_with_index(self, small_df):
"""Including the index adds the index name to the header."""
header, cells = OpenBBFigure._tbl_values(small_df, print_index=True)
assert header[0] == "<b>date</b>"
assert len(cells) == 3
def test_tbl_values_without_index(self, small_df):
"""Excluding the index uses only the columns."""
header, cells = OpenBBFigure._tbl_values(small_df, print_index=False)
assert len(header) == 2
assert len(cells) == 2
def test_to_table_auto_columnwidth(self, small_df):
"""Auto column widths produce a table figure with a height."""
fig = OpenBBFigure.to_table(small_df)
assert any(t.type == "table" for t in fig.data)
assert fig.layout.height > 0
def test_to_table_no_index(self, small_df):
"""A table without the index still renders."""
fig = OpenBBFigure.to_table(small_df, print_index=False)
assert any(t.type == "table" for t in fig.data)
def test_to_table_small_height_width_popped(self, small_df):
"""Under-sized height/width kwargs are dropped in favour of defaults."""
fig = OpenBBFigure.to_table(
small_df, columnwidth=[10, 10, 10], height=1, width=1
)
assert fig.layout.height > 1
assert fig.layout.width > 1
def test_to_table_large_height_width_kept(self, small_df):
"""Over-sized height/width kwargs are respected."""
fig = OpenBBFigure.to_table(small_df, height=100_000, width=100_000)
assert fig.layout.height == 100_000
class TestMarginsAndAnnotations:
"""Margin adjustment and watermark/command-source helpers."""
def test_adjust_margins_default(self):
"""Margins are seeded with the default additions on first call."""
fig = OpenBBFigure()
fig._adjust_margins()
assert fig.layout.margin.l == 80
assert fig._margin_adjusted is True
def test_adjust_margins_idempotent(self):
"""A second margin adjustment is a no-op."""
fig = OpenBBFigure()
fig._adjust_margins()
first = fig.layout.margin.l
fig._adjust_margins()
assert fig.layout.margin.l == first
def test_adjust_margins_existing_values(self):
"""Pre-existing margins are incremented by the additions."""
fig = OpenBBFigure()
fig.update_layout(margin=dict(l=10, r=10, b=10, t=10, pad=2))
fig._adjust_margins()
assert fig.layout.margin.l == 90
assert fig.layout.margin.pad == 2
def test_adjust_margins_secondary_y_subplots(self):
"""The secondary-y subplot branch uses the tighter margin set."""
fig = OpenBBFigure.create_subplots(
rows=2, cols=1, specs=[[{"secondary_y": True}], [{}]]
)
fig._adjust_margins()
assert fig.layout.margin.l == 60
def test_add_cmd_source_right_side(self):
"""A right-side y-axis adds the rotated command annotation."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="x")
fig.update_yaxes(side="right", title="Price")
fig._adjust_margins()
fig._add_cmd_source("/equity/price/historical")
assert len(fig.layout.annotations) == 1
def test_add_cmd_source_left_side(self):
"""A left-side y-axis shifts the annotation and widens the margin."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="x")
fig.update_yaxes(side="left", title="Price")
fig._adjust_margins()
fig._add_cmd_source("/test")
assert len(fig.layout.annotations) == 1
def test_add_cmd_source_wide_margin_logscale(self):
"""A wide left margin with logscale increases the x-shift."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="x")
fig.update_layout(margin=dict(l=150))
fig._added_logscale = True
fig.update_yaxes(side="right")
fig._add_cmd_source("/test")
assert len(fig.layout.annotations) == 1
def test_add_cmd_source_secondary_axis_left(self):
"""Both axes titled with a left secondary axis widens the margin."""
fig = OpenBBFigure.create_subplots(
rows=1, cols=1, specs=[[{"secondary_y": True}]]
)
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="a")
fig.add_scatter(x=[1, 2, 3], y=[6, 5, 4], name="b", secondary_y=True)
fig.update_yaxes(title="Left", side="left")
fig.update_layout(yaxis2=dict(title="Right2", side="left"))
fig._adjust_margins()
fig._add_cmd_source("/test")
assert len(fig.layout.annotations) == 1
def test_add_cmd_source_no_location_noop(self):
"""An empty command location adds no annotation."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="x")
fig._add_cmd_source("")
assert len(fig.layout.annotations) == 0
def test_apply_feature_flags_idempotent(self):
"""Applying feature flags twice does not duplicate work."""
fig = OpenBBFigure()
fig._apply_feature_flags()
assert fig._feature_flags_applied is True
fig._apply_feature_flags()
assert fig._feature_flags_applied is True
class TestLogScaleMenus:
"""The ``add_logscale_menus`` helper."""
def test_add_logscale_menus(self):
"""Log-scale menus install update menus and a range selector."""
fig = OpenBBFigure()
fig.add_logscale_menus()
assert fig._added_logscale is True
assert len(fig.layout.updatemenus) == 1
class TestCorrPlot:
"""The ``add_corr_plot`` helper."""
def test_acf_plot(self, daily_close):
"""An ACF plot adds the stem, band and zero-line traces."""
fig = OpenBBFigure()
fig.add_corr_plot(daily_close, max_lag=10)
assert len(fig.data) > 1
def test_acf_plot_with_marker(self, daily_close):
"""A marker dict switches to markers+lines mode and highlights ``m``."""
fig = OpenBBFigure()
fig.add_corr_plot(daily_close, max_lag=10, m=3, marker={"color": "red"})
assert len(fig.data) > 1
def test_pacf_plot(self, daily_close):
"""A PACF plot uses the partial-autocorrelation callback."""
fig = OpenBBFigure()
fig.add_corr_plot(daily_close, max_lag=10, pacf=True)
assert len(fig.data) > 1
def test_corr_plot_default_max_lag(self, daily_close):
"""``max_lag=None`` exercises the auto-lag sizing branch."""
fig = OpenBBFigure()
with pytest.raises(TypeError):
fig.add_corr_plot(daily_close, max_lag=None)
def test_corr_plot_irregular_lags(self, daily_close):
"""A list of lags drives the irregular-lag preparation branch."""
fig = OpenBBFigure()
with pytest.raises(TypeError):
fig.add_corr_plot(daily_close, max_lag=[1, 2, 3, 4, 5])
class TestShow:
"""The ``show`` method across its display branches."""
def test_show_external_returns_self(self, daily_index, daily_close):
"""``external=True`` returns the figure unrendered."""
fig = OpenBBFigure()
fig.add_scatter(x=daily_index, y=daily_close, name="close")
result = fig.show(external=True, cmd_xshift=5, bar_width=0.1)
assert result is fig
assert fig.cmd_xshift == 5
def test_show_external_no_margin_no_dates(self):
"""``margin=False`` and ``date_xaxis=False`` skip those steps."""
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="x")
result = fig.show(external=True, margin=False, date_xaxis=False)
assert result is fig
def test_show_headless_returns_json(self):
"""A headless charting-settings figure returns a JSON string."""
settings = ChartingSettings()
settings.headless = True
fig = OpenBBFigure(charting_settings=settings)
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="x")
out = fig.show()
assert isinstance(out, str)
def test_show_backend_success(self, monkeypatch):
"""A working backend renders via ``send_figure`` and returns it."""
backend = _ReturningBackend()
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="x")
fig._backend = backend
result = fig.show(command_location="/route")
assert backend.called is True
assert result is fig
def test_show_backend_failure_falls_back(self, monkeypatch):
"""A failing backend warns and falls through to ``pio.show``."""
shown = {}
monkeypatch.setattr(
pio, "show", lambda *a, **k: shown.setdefault("called", True)
)
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="x")
fig._backend = _RaisingBackend()
with pytest.warns(UserWarning, match="Failed to show figure"):
fig.show()
assert shown["called"] is True
def test_show_no_backend_uses_pio(self, monkeypatch):
"""With no backend the figure is displayed through ``pio.show``."""
shown = {}
monkeypatch.setattr(
pio, "show", lambda *a, **k: shown.setdefault("called", True)
)
fig = OpenBBFigure()
fig.add_scatter(x=[1, 2, 3], y=[4, 5, 6], name="x")
fig._backend = None
fig.show()
assert shown["called"] is True

View File

@@ -0,0 +1,87 @@
"""Tests for ``openbb_charting.core.config.openbb_styles``."""
import pandas as pd
from openbb_charting.core.config import openbb_styles
from openbb_charting.core.config.openbb_styles import (
PLOTLY_THEME,
PLT_CANDLESTICKS,
PLT_COLORWAY,
PLT_DECREASING_COLORWAY,
PLT_FIB_COLORWAY,
PLT_INCREASING_COLORWAY,
PLT_STYLE_DECREASING,
PLT_STYLE_INCREASING,
PLT_TBL_ROW_COLORS,
de_increasing_color_list,
)
class TestModuleConstants:
"""Validate the structure of the exported style constants."""
def test_colorway_lengths(self):
"""Increasing and decreasing colorways have matching lengths."""
assert len(PLT_INCREASING_COLORWAY) == len(PLT_DECREASING_COLORWAY)
assert len(PLT_COLORWAY) == 12
def test_fib_colorway_mixed_types(self):
"""PLT_FIB_COLORWAY ends with dict styling entries."""
assert isinstance(PLT_FIB_COLORWAY[-1], dict)
assert isinstance(PLT_FIB_COLORWAY[0], str)
def test_candlesticks_colors(self):
"""Candlestick config wires the increasing/decreasing colors."""
assert PLT_CANDLESTICKS["increasing"]["line_color"] == PLT_STYLE_INCREASING
assert PLT_CANDLESTICKS["decreasing"]["line_color"] == PLT_STYLE_DECREASING
def test_table_row_colors_pair(self):
"""Table row colors are a two-element tuple."""
assert isinstance(PLT_TBL_ROW_COLORS, tuple)
assert len(PLT_TBL_ROW_COLORS) == 2
def test_plotly_theme_layout(self):
"""PLOTLY_THEME exposes layout colorway and candlestick data."""
assert PLOTLY_THEME["layout"]["colorway"] == PLT_COLORWAY
assert "candlestick" in PLOTLY_THEME["data"]
def test_module_level_aliases(self):
"""Module-level template and font aliases are present."""
assert openbb_styles.PLT_STYLE_TEMPLATE == "plotly_dark"
assert openbb_styles.PLT_FONT == openbb_styles.PLOTLY_FONT
class TestDeIncreasingColorList:
"""Exercise both branches of ``de_increasing_color_list``."""
def test_text_branch_decreasing(self):
"""A text containing the marker yields the decreasing color."""
result = de_increasing_color_list(text="-5%")
assert result == [PLT_STYLE_DECREASING]
def test_text_branch_increasing(self):
"""A text without the marker yields the increasing color."""
result = de_increasing_color_list(text="5%")
assert result == [PLT_STYLE_INCREASING]
def test_dataframe_column_branch(self):
"""A column produces a per-row list keyed on the marker substring."""
column = pd.Series(["-1", "2", "-3", "4"])
result = de_increasing_color_list(df_column=column)
assert result == [
PLT_STYLE_DECREASING,
PLT_STYLE_INCREASING,
PLT_STYLE_DECREASING,
PLT_STYLE_INCREASING,
]
def test_custom_colors_and_contains_str(self):
"""Custom colors and contains string override the defaults."""
column = pd.Series(["downX", "up"])
result = de_increasing_color_list(
df_column=column,
contains_str="X",
increasing_color="green",
decreasing_color="red",
)
assert result == ["red", "green"]

View File

@@ -0,0 +1,338 @@
"""Tests for ``openbb_charting.core.plotly_ta.base``."""
import numpy as np
import pandas as pd
import pytest
from openbb_charting.core.plotly_ta.base import (
Indicator,
PltTA,
columns_regex,
indicator,
)
@pytest.fixture
def ohlcv_df() -> pd.DataFrame:
"""Build a deterministic OHLCV frame indexed by date."""
rng = np.random.default_rng(11)
idx = pd.date_range("2023-01-01", periods=60, freq="D")
close = 100 + np.cumsum(rng.normal(0, 1, 60))
open_ = close + rng.normal(0, 0.5, 60)
high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.5, 60))
low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.5, 60))
volume = rng.integers(1_000_000, 5_000_000, 60)
frame = pd.DataFrame(
{"open": open_, "high": high, "low": low, "close": close, "volume": volume},
index=idx,
)
frame.index.name = "date"
return frame
def _make_plugin():
"""Build a concrete ``PltTA`` subclass with indicators and a static method."""
class SamplePlugin(PltTA):
"""A concrete plugin used by the base tests."""
__ma_mode__ = ["sma"]
__inchart__ = ["sma"]
__subplots__ = ["rsi"]
@indicator()
def plot_sma(self, fig, df_ta, idx):
"""Bind as an indicator named after the function."""
return fig, idx
@indicator()
def plot_rsi(self, fig, df_ta, idx):
"""Bind as a second indicator named after the function."""
return fig, idx
@staticmethod
def sample_helper():
"""A static helper that becomes part of ``__static_methods__``."""
return "sample"
return SamplePlugin
class TestColumnsRegex:
"""Tests for the module-level ``columns_regex`` helper."""
def test_matches_named_columns(self):
"""It returns the columns matching the regex name."""
df_ta = pd.DataFrame({"SMA_20": [1], "SMA_50": [2], "RSI_14": [3]})
assert columns_regex(df_ta, "SMA") == ["SMA_20", "SMA_50"]
def test_no_match_returns_empty(self):
"""It returns an empty list when nothing matches."""
df_ta = pd.DataFrame({"close": [1.0]})
assert columns_regex(df_ta, "ZZZ") == []
class TestIndicator:
"""Tests for the ``Indicator`` wrapper class."""
def test_init_stores_func_name_attrs(self):
"""It stores the function, name, and extra attributes."""
def fn():
return 1
ind = Indicator(fn, name="myind", color="red")
assert ind.func is fn
assert ind.name == "myind"
assert ind.attrs == {"color": "red"}
def test_call_delegates_to_func(self):
"""Calling the indicator delegates to the wrapped function."""
ind = Indicator(lambda x, y=0: x + y, name="add")
assert ind(2, y=3) == 5
class TestIndicatorDecorator:
"""Tests for the ``indicator`` decorator factory."""
def test_decorator_falls_back_to_func_name(self):
"""The decorator falls back to the function name when none given."""
@indicator()
def my_func():
return 1
assert isinstance(my_func, Indicator)
assert my_func.name == "my_func"
def test_decorator_uses_explicit_name(self):
"""An explicit ``name`` is honored (and does not raise)."""
@indicator(name="custom_name")
def my_func():
return 1
assert isinstance(my_func, Indicator)
assert my_func.name == "custom_name"
def test_decorator_preserves_extra_attrs(self):
"""The decorator preserves additional attributes on the indicator."""
@indicator(subplot=True)
def fn():
return 1
assert fn.attrs == {"subplot": True}
class TestPluginMeta:
"""Tests for the ``PluginMeta`` metaclass."""
def test_collects_indicators_and_static_methods(self):
"""The metaclass collects indicators and the plugin's static methods."""
plugin = _make_plugin()
names = {ind.name for ind in plugin.__indicators__}
assert {"plot_sma", "plot_rsi"} <= names
assert "sample_helper" in plugin.__static_methods__
def test_static_methods_do_not_leak_across_classes(self):
"""Each plugin's ``__static_methods__`` is independent (no shared-list leak)."""
class PluginA(PltTA):
"""First plugin with its own static helper."""
@staticmethod
def helper_a():
"""Static helper unique to PluginA."""
return "a"
class PluginB(PltTA):
"""Second plugin with a different static helper."""
@staticmethod
def helper_b():
"""Static helper unique to PluginB."""
return "b"
assert "helper_a" in PluginA.__static_methods__
assert "helper_a" not in PluginB.__static_methods__
assert "helper_b" in PluginB.__static_methods__
assert "helper_b" not in PluginA.__static_methods__
def test_collects_class_attribute_lists(self):
"""The metaclass aggregates ma_mode, inchart, and subplots."""
plugin = _make_plugin()
assert "sma" in plugin.__ma_mode__
assert "sma" in plugin.__inchart__
assert "rsi" in plugin.__subplots__
def test_iter_yields_indicators(self):
"""Iterating the class yields its indicators."""
plugin = _make_plugin()
assert list(iter(plugin)) == plugin.__indicators__
def test_indicator_as_static_method_raises(self):
"""An indicator declared as a static method raises TypeError."""
with pytest.raises(TypeError, match="can't be a static method"):
class BadPlugin(PltTA):
"""Declares an indicator as a static method, which is illegal."""
bad = staticmethod(Indicator(lambda self: None, name="bad"))
def test_subclass_overrides_parent_indicator(self):
"""A subclass redefining an indicator removes the parent's copy."""
parent = _make_plugin()
class ChildPlugin(parent):
"""Overrides an inherited indicator with a plain attribute."""
plot_sma = "not-an-indicator"
child_names = {ind.name for ind in ChildPlugin.__indicators__}
assert "plot_sma" not in child_names
assert "plot_rsi" in child_names
class TestPltTA:
"""Tests for the ``PltTA`` base class."""
def test_cannot_instantiate_abstract_base(self):
"""Instantiating ``PltTA`` directly raises TypeError."""
with pytest.raises(TypeError, match="Can't instantiate abstract class"):
PltTA()
def test_new_binds_indicators(self):
"""A concrete instance exposes its indicators as callables."""
plugin = _make_plugin()
inst = plugin()
assert callable(inst.plot_sma)
assert callable(inst.plot_rsi)
def test_new_binds_custom_named_indicator(self):
"""An indicator whose name differs from its attribute is bound on the instance."""
class CustomNamePlugin(PltTA):
"""A plugin with an indicator registered under a custom name."""
@indicator(name="aliased")
def plot_thing(self, fig, df_ta, idx):
"""Indicator registered under the name ``aliased``."""
return fig, idx
inst = CustomNamePlugin()
assert callable(inst.aliased)
def test_ma_mode_property_and_setter(self):
"""The ``ma_mode`` property returns a unique list and can be set."""
inst = _make_plugin()()
assert "sma" in inst.ma_mode
inst.ma_mode = ["ema", "ema", "wma"]
assert set(inst.ma_mode) == {"ema", "wma"}
def test_iter_yields_instance_indicators(self):
"""Iterating an instance yields its indicators."""
inst = _make_plugin()()
assert list(iter(inst)) == inst.__indicators__
def test_add_plugins_merges_indicators_and_attrs(self):
"""``add_plugins`` merges another plugin's indicators and attrs."""
class OtherPlugin(PltTA):
"""A second plugin providing an extra indicator and subplot entry."""
__subplots__ = ["macd"]
@indicator()
def plot_macd(self, fig, df_ta, idx):
"""Extra indicator added via add_plugins."""
return fig, idx
base_inst = _make_plugin()()
before = len(base_inst.__indicators__)
base_inst.add_plugins([OtherPlugin])
assert len(base_inst.__indicators__) == before + 1
assert callable(base_inst.plot_macd)
assert "macd" in base_inst.__subplots__
def test_add_plugins_binds_static_methods(self):
"""``add_plugins`` binds a plugin's static methods onto the instance."""
class StaticPlugin(PltTA):
"""A plugin contributing a static helper."""
@staticmethod
def extra_helper():
"""A static helper to be bound onto the aggregating instance."""
return "extra"
base_inst = _make_plugin()()
assert not hasattr(base_inst, "extra_helper")
base_inst.add_plugins([StaticPlugin])
assert base_inst.extra_helper() == "extra"
def test_add_plugins_skips_existing_indicator(self):
"""``add_plugins`` does not re-add an indicator already on the instance."""
class DuplicatePlugin(PltTA):
"""A plugin whose indicator name already exists on the target."""
@indicator()
def plot_sma(self, fig, df_ta, idx):
"""Indicator with a name shared by the base plugin."""
return fig, idx
base_inst = _make_plugin()()
before = len(base_inst.__indicators__)
base_inst.add_plugins([DuplicatePlugin])
assert len(base_inst.__indicators__) == before
def test_remove_plugins_removes_indicators_and_static_methods(self):
"""``remove_plugins`` removes a plugin's indicators and bound static methods."""
class RemovablePlugin(PltTA):
"""A plugin whose indicator and static helper get removed."""
@indicator()
def plot_temp(self, fig, df_ta, idx):
"""Indicator that will be removed."""
return fig, idx
@staticmethod
def temp_helper():
"""Static helper that will be removed."""
return "temp"
base_inst = _make_plugin()()
base_inst.add_plugins([RemovablePlugin])
assert hasattr(base_inst, "plot_temp")
assert base_inst.temp_helper() == "temp"
base_inst.remove_plugins([RemovablePlugin])
assert not hasattr(base_inst, "plot_temp")
assert "temp_helper" not in base_inst.__dict__
def test_get_float_precision_two_decimals(self, ohlcv_df):
"""A price above 1.10 yields a two-decimal precision format."""
inst = _make_plugin()()
inst.df_stock = ohlcv_df
inst.close_column = "close"
assert inst.get_float_precision() == ",.2f"
def test_get_float_precision_six_decimals(self, ohlcv_df):
"""A long low-priced value yields a six-decimal precision format."""
inst = _make_plugin()()
frame = ohlcv_df.copy()
frame["close"] = 0.123456789
inst.df_stock = frame
inst.close_column = "close"
assert inst.get_float_precision() == ".6f"
def test_get_float_precision_empty_format(self, ohlcv_df):
"""A short low-priced value yields an empty precision format."""
inst = _make_plugin()()
frame = ohlcv_df.copy()
frame["close"] = 0.5
inst.df_stock = frame
inst.close_column = "close"
assert inst.get_float_precision() == ""

View File

@@ -0,0 +1,422 @@
"""Tests for ``openbb_charting.core.plotly_ta.ta_class``."""
import warnings
import numpy as np
import pandas as pd
import pytest
import openbb_charting.core.plotly_ta.ta_class as ta_class_module
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
def _make_ohlcv(seed: int = 42, periods: int = 200, start: str = "2023-01-01"):
"""Build a deterministic OHLCV frame indexed by date."""
rng = np.random.default_rng(seed)
idx = pd.date_range(start, periods=periods, freq="D")
close = 100 + np.cumsum(rng.normal(0, 1, periods))
open_ = close + rng.normal(0, 0.5, periods)
high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.5, periods))
low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.5, periods))
volume = rng.integers(1_000_000, 5_000_000, periods)
frame = pd.DataFrame(
{"open": open_, "high": high, "low": low, "close": close, "volume": volume},
index=idx,
)
frame.index.name = "date"
return frame
@pytest.fixture
def ohlcv_df() -> pd.DataFrame:
"""A single-symbol OHLCV DataFrame indexed by date (200 rows)."""
return _make_ohlcv()
@pytest.fixture
def no_volume_df() -> pd.DataFrame:
"""An OHLCV frame whose volume column is entirely zero."""
frame = _make_ohlcv()
frame["volume"] = 0
return frame
class TestPlotlyTASingleton:
"""Tests for the singleton and theming behavior of ``PlotlyTA``."""
def test_new_returns_singleton(self):
"""Repeated construction returns the same instance."""
assert PlotlyTA() is PlotlyTA()
def test_setup_theme_returns_chart_style(self):
"""``setup_theme`` builds a ``ChartStyle`` instance."""
theme = PlotlyTA.setup_theme(chart_style="", user_styles_directory="")
assert theme is not None
assert hasattr(theme, "get_colors")
def test_init_with_args_takes_non_clear_branch(self):
"""Constructing with positional args takes the super-init branch."""
with pytest.raises(TypeError):
PlotlyTA("dummy")
assert ta_class_module.PLOTLY_TA.df_fib is None
def test_clear_data_resets_state(self):
"""A no-arg construction resets the internal data structures."""
inst = PlotlyTA()
assert inst.df_stock is None
assert isinstance(inst.indicators, ChartIndicators)
assert inst.params is None
assert inst.intraday is False
assert inst.show_volume is True
class TestPlotlyTAProperties:
"""Tests for the moving-average / inchart / subplots properties."""
def test_ma_mode_property_and_setter(self):
"""The ``ma_mode`` property returns a unique list and can be set."""
inst = PlotlyTA()
original = inst.ma_mode
inst.ma_mode = ["sma", "sma", "ema"]
assert set(inst.ma_mode) == {"sma", "ema"}
inst.ma_mode = original
def test_inchart_property_and_setter(self):
"""The ``inchart`` property returns a unique list and can be set."""
inst = PlotlyTA()
original = inst.inchart
inst.inchart = ["bbands", "bbands"]
assert inst.inchart == ["bbands"]
inst.inchart = original
def test_subplots_property_and_setter(self):
"""The ``subplots`` property returns a unique list and can be set."""
inst = PlotlyTA()
original = inst.subplots
inst.subplots = ["rsi", "rsi"]
assert inst.subplots == ["rsi"]
inst.subplots = original
class TestPlotlyTAPlot:
"""Tests for the primary plotting entry points."""
def test_plot_with_subplots_and_inchart_and_ma(self, ohlcv_df):
"""A mixed indicator set plots candles, MA, subplots and inchart."""
fig = PlotlyTA.plot(
ohlcv_df,
indicators={
"sma": {"length": [20, 50]},
"rsi": {"length": 14},
"macd": {"fast": 12, "slow": 26, "signal": 9},
"bbands": {"length": 20},
},
symbol="AAA",
candles=True,
volume=True,
)
assert isinstance(fig, OpenBBFigure)
def test_plot_line_chart_no_candles(self, ohlcv_df):
"""Plotting with ``candles=False`` renders a line chart."""
fig = PlotlyTA.plot(
ohlcv_df,
indicators={"sma": {"length": 20}},
symbol="AAA",
candles=False,
volume=True,
)
assert isinstance(fig, OpenBBFigure)
def test_plot_with_series_input(self, ohlcv_df):
"""A Series input is converted to a frame before plotting."""
series = ohlcv_df["close"]
fig = PlotlyTA.plot(series, indicators={}, symbol="AAA", candles=False)
assert isinstance(fig, OpenBBFigure)
def test_plot_no_volume_data(self, no_volume_df):
"""Plotting with zero volume disables the volume subplot."""
fig = PlotlyTA.plot(
no_volume_df,
indicators={"sma": {"length": 20}},
symbol="AAA",
candles=True,
volume=True,
)
assert isinstance(fig, OpenBBFigure)
def test_plot_with_chart_indicators_object(self, ohlcv_df):
"""A ``ChartIndicators`` object is accepted directly."""
ci = ChartIndicators.from_dict({"sma": {"length": 20}})
fig = PlotlyTA.plot(ohlcv_df, indicators=ci, symbol="AAA")
assert isinstance(fig, OpenBBFigure)
def test_plot_with_existing_figure(self, ohlcv_df):
"""Passing a pre-built figure reuses it instead of creating one."""
inst = PlotlyTA()
inst._clear_data()
fig = inst.__plot__(
ohlcv_df,
indicators={"sma": {"length": 20}},
symbol="AAA",
)
fig2 = PlotlyTA.plot(ohlcv_df, indicators={"rsi": {"length": 14}}, fig=fig)
assert isinstance(fig2, OpenBBFigure)
def test_plot_reuses_indicators_when_none(self, ohlcv_df):
"""When indicators is None the previous singleton indicators are reused."""
PlotlyTA.plot(ohlcv_df, indicators={"sma": {"length": 20}}, symbol="AAA")
fig = PlotlyTA.plot(ohlcv_df, indicators=None, symbol="AAA")
assert isinstance(fig, OpenBBFigure)
def test_plot_with_fib_and_srlines(self, ohlcv_df):
"""Overlay-only indicators (fib, srlines) are plotted on the chart."""
fig = PlotlyTA.plot(
ohlcv_df,
indicators={"fib": {}, "srlines": {}},
symbol="AAA",
candles=True,
)
assert isinstance(fig, OpenBBFigure)
def test_plot_with_inchart_overlay_indicators(self, ohlcv_df):
"""In-chart overlay indicators (donchian, ichimoku) are plotted."""
fig = PlotlyTA.plot(
ohlcv_df,
indicators={"donchian": {}, "ichimoku": {}, "vwap": {}},
symbol="AAA",
candles=True,
)
assert isinstance(fig, OpenBBFigure)
def test_plot_multiple_ma_modes_plotted_once(self, ohlcv_df):
"""Multiple moving-average indicators collapse into a single 'ma' plot."""
fig = PlotlyTA.plot(
ohlcv_df,
indicators={
"sma": {"length": 20},
"ema": {"length": 20},
"wma": {"length": 20},
},
symbol="AAA",
candles=True,
)
assert isinstance(fig, OpenBBFigure)
def test_plot_with_aroon_and_atr(self, ohlcv_df):
"""Aroon (two-row) and ATR (plotted last) subplots are handled."""
fig = PlotlyTA.plot(
ohlcv_df,
indicators={"aroon": {"length": 14}, "atr": {"length": 14}},
symbol="AAA",
candles=True,
)
assert isinstance(fig, OpenBBFigure)
def test_plot_unknown_indicator_is_skipped(self, ohlcv_df):
"""An unknown indicator is caught and skipped without failing the plot."""
with warnings.catch_warnings():
warnings.simplefilter("ignore")
fig = PlotlyTA.plot(
ohlcv_df,
indicators={"not_a_real_indicator": {}},
symbol="AAA",
)
assert isinstance(fig, OpenBBFigure)
def test_plot_volume_only_no_volume_margin(self, no_volume_df):
"""With volume disabled the left margin adjustment branch runs."""
fig = PlotlyTA.plot(
no_volume_df,
indicators={},
symbol="AAA",
candles=True,
volume=True,
)
assert isinstance(fig, OpenBBFigure)
def test_plot_many_subplots_reaches_limit(self, ohlcv_df):
"""A large indicator set exercises the max-subplots handling."""
with warnings.catch_warnings():
warnings.simplefilter("ignore")
fig = PlotlyTA.plot(
ohlcv_df,
indicators={
"rsi": {"length": 14},
"macd": {"fast": 12, "slow": 26, "signal": 9},
"stoch": {},
"cci": {"length": 14},
"adx": {"length": 14},
"aroon": {"length": 14},
"fisher": {"length": 14},
"obv": {},
},
symbol="AAA",
candles=True,
volume=True,
)
assert isinstance(fig, OpenBBFigure)
class TestPlotlyTAHelpers:
"""Tests for the helper methods used during plotting."""
def _prepared(self, df, indicators):
"""Return a PlotlyTA instance with internal plotting state populated."""
inst = PlotlyTA()
inst._clear_data()
ci = ChartIndicators.from_dict(indicators)
inst.indicators = ci
prepared = df.copy()
prepared = prepared.sort_index(ascending=True)
inst.df_stock = prepared
inst.close_column = "close"
inst.params = ci.get_params()
inst.has_volume = bool(df["volume"].sum() > 0)
inst.show_volume = inst.has_volume
inst.intraday = False
return inst
def test_calculate_indicators(self, ohlcv_df):
"""``calculate_indicators`` returns a frame with indicator columns."""
inst = self._prepared(ohlcv_df, {"sma": {"length": 20}})
out = inst.calculate_indicators()
assert any("SMA" in c for c in out.columns)
def test_get_subplot_volume(self, ohlcv_df):
"""``get_subplot`` returns the show_volume flag for 'volume'."""
inst = self._prepared(ohlcv_df, {})
assert inst.get_subplot("volume") is inst.show_volume
def test_get_subplot_volume_indicator_without_volume(self, no_volume_df):
"""A volume indicator without volume data is removed and warned."""
inst = self._prepared(no_volume_df, {"obv": {}})
with pytest.warns(UserWarning):
assert inst.get_subplot("obv") is False
assert "obv" not in inst.indicators.get_active_ids()
def test_get_subplot_missing_indicator(self, ohlcv_df):
"""``get_subplot`` returns False when the indicator is not active."""
inst = self._prepared(ohlcv_df, {"sma": {"length": 20}})
assert inst.get_subplot("rsi") is False
def test_get_subplot_active_indicator(self, ohlcv_df):
"""``get_subplot`` returns truthy data for an active subplot."""
inst = self._prepared(ohlcv_df, {"rsi": {"length": 14}})
assert inst.get_subplot("rsi") is True
def test_get_subplot_exception_returns_false(self, ohlcv_df):
"""An indicator that yields no data is caught and reported as False."""
inst = self._prepared(ohlcv_df, {"rsi": {"length": 5000}})
with warnings.catch_warnings():
warnings.simplefilter("ignore")
assert inst.get_subplot("rsi") is False
def test_check_subplots(self, ohlcv_df):
"""``check_subplots`` filters down to plottable subplots."""
inst = self._prepared(ohlcv_df, {"rsi": {"length": 14}})
assert inst.check_subplots(["rsi", "macd"]) == ["rsi"]
def test_get_fig_settings_dict(self, ohlcv_df):
"""``get_fig_settings_dict`` returns a row/specs configuration."""
inst = self._prepared(
ohlcv_df,
{"rsi": {"length": 14}, "macd": {"fast": 12, "slow": 26, "signal": 9}},
)
settings = inst.get_fig_settings_dict()
assert settings["cols"] == 1
assert "specs" in settings
def test_get_fig_settings_dict_with_aroon(self, ohlcv_df):
"""``get_fig_settings_dict`` bumps the row count when aroon is active."""
inst = self._prepared(ohlcv_df, {"aroon": {"length": 14}})
settings = inst.get_fig_settings_dict()
assert "rows" in settings
def test_init_plot_candles(self, ohlcv_df):
"""``init_plot`` with candles builds a candlestick figure."""
inst = self._prepared(ohlcv_df, {})
fig = inst.init_plot("AAA", candles=True)
assert isinstance(fig, OpenBBFigure)
def test_init_plot_line(self, ohlcv_df):
"""``init_plot`` without candles builds a line figure."""
inst = self._prepared(ohlcv_df, {})
fig = inst.init_plot("AAA", candles=False)
assert isinstance(fig, OpenBBFigure)
class TestLocatePlugins:
"""Tests for plugin discovery."""
def test_locate_plugins_debug_emits_warnings(self):
"""Running plugin discovery in debug mode emits informational warnings."""
with pytest.warns(UserWarning):
PlotlyTA._locate_plugins(debug=True)
def test_locate_plugins_no_debug(self):
"""Running plugin discovery without debug does not raise."""
PlotlyTA._locate_plugins(debug=False)
assert PlotlyTA.plugins
class TestProcessFig:
"""Tests for the ``process_fig`` figure-processing path."""
def test_process_fig_runs_via_plot(self, ohlcv_df):
"""``process_fig`` is exercised through a normal plot call."""
fig = PlotlyTA.plot(
ohlcv_df,
indicators={"rsi": {"length": 14}},
symbol="AAA",
candles=True,
volume=True,
)
assert isinstance(fig, OpenBBFigure)
def test_process_fig_direct_call(self, ohlcv_df):
"""``process_fig`` reflows an ``init_plot`` figure into subplots."""
inst = PlotlyTA()
inst._clear_data()
ci = ChartIndicators.from_dict({"rsi": {"length": 14}})
inst.indicators = ci
prepared = ohlcv_df.copy().sort_index(ascending=True)
inst.df_stock = prepared
inst.close_column = "close"
inst.params = ci.get_params()
inst.has_volume = True
inst.show_volume = True
inst.intraday = False
inst.df_ta = inst.calculate_indicators()
base_fig = inst.init_plot("AAA", candles=True)
out = inst.process_fig(base_fig, volume_ticks_x=7)
assert isinstance(out, OpenBBFigure)
def test_process_fig_without_volume(self, no_volume_df):
"""``process_fig`` skips the volume overlay when volume is disabled."""
inst = PlotlyTA()
inst._clear_data()
ci = ChartIndicators.from_dict({"rsi": {"length": 14}})
inst.indicators = ci
prepared = no_volume_df.copy().sort_index(ascending=True)
inst.df_stock = prepared
inst.close_column = "close"
inst.params = ci.get_params()
inst.has_volume = False
inst.show_volume = False
inst.intraday = False
inst.df_ta = inst.calculate_indicators()
base_fig = inst.init_plot("AAA", candles=True)
out = inst.process_fig(base_fig, volume_ticks_x=7)
assert isinstance(out, OpenBBFigure)
class TestModuleState:
"""Tests for module-level constants and singletons."""
def test_global_singleton_is_set(self):
"""The module ``PLOTLY_TA`` global is populated after construction."""
PlotlyTA()
assert ta_class_module.PLOTLY_TA is not None

View File

@@ -0,0 +1,393 @@
"""Tests for ``openbb_charting.core.plotly_ta.data_classes``."""
import numpy as np
import pandas as pd
import pytest
from openbb_charting.core.plotly_ta.data_classes import (
Arguments,
ChartIndicators,
TA_Data,
TA_DataException,
TAIndicator,
columns_regex,
)
@pytest.fixture
def ohlcv_df() -> pd.DataFrame:
"""Build a deterministic OHLCV frame indexed by date."""
rng = np.random.default_rng(7)
idx = pd.date_range("2023-01-01", periods=120, freq="D")
close = 100 + np.cumsum(rng.normal(0, 1, 120))
open_ = close + rng.normal(0, 0.5, 120)
high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.5, 120))
low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.5, 120))
volume = rng.integers(1_000_000, 5_000_000, 120)
frame = pd.DataFrame(
{"open": open_, "high": high, "low": low, "close": close, "volume": volume},
index=idx,
)
frame.index.name = "date"
return frame
@pytest.fixture
def no_volume_df(ohlcv_df) -> pd.DataFrame:
"""Return an OHLCV frame whose volume is entirely zero."""
frame = ohlcv_df.copy()
frame["volume"] = 0
return frame
class TestColumnsRegex:
"""Tests for the module-level ``columns_regex`` helper."""
def test_matches_named_columns(self):
"""It returns columns matching the regex name."""
df_ta = pd.DataFrame({"SMA_20": [1], "SMA_50": [2], "RSI_14": [3]})
assert columns_regex(df_ta, "SMA") == ["SMA_20", "SMA_50"]
def test_no_match_returns_empty(self):
"""It returns an empty list when nothing matches."""
df_ta = pd.DataFrame({"close": [1.0]})
assert columns_regex(df_ta, "DOES_NOT_EXIST") == []
class TestArguments:
"""Tests for the ``Arguments`` dataclass."""
def test_single_element_list_collapses(self):
"""A one-element list is collapsed to its scalar value."""
arg = Arguments(label="length", values=[20])
assert arg.values == 20
def test_multi_element_list_preserved(self):
"""A multi-element list is preserved as-is."""
arg = Arguments(label="length", values=[20, 50])
assert arg.values == [20, 50]
def test_scalar_preserved(self):
"""A scalar value is preserved unchanged."""
arg = Arguments(label="length", values=14)
assert arg.values == 14
class TestTAIndicator:
"""Tests for the ``TAIndicator`` dataclass."""
def test_iter_yields_arguments(self):
"""Iterating an indicator yields its arguments."""
args = [Arguments("length", 14), Arguments("scalar", 3)]
indicator = TAIndicator(name="rsi", args=args)
assert list(indicator) == args
def test_get_args_found(self):
"""``get_args`` returns the matching argument."""
arg = Arguments("length", 14)
indicator = TAIndicator(name="rsi", args=[arg])
assert indicator.get_args("length") is arg
def test_get_args_not_found(self):
"""``get_args`` returns None when the label is absent."""
indicator = TAIndicator(name="rsi", args=[Arguments("length", 14)])
assert indicator.get_args("missing") is None
def test_get_argument_values_found(self):
"""``get_argument_values`` returns the matching values."""
indicator = TAIndicator(name="rsi", args=[Arguments("length", 14)])
assert indicator.get_argument_values("length") == 14
def test_get_argument_values_not_found(self):
"""``get_argument_values`` returns an empty list when absent."""
indicator = TAIndicator(name="rsi", args=[Arguments("length", 14)])
assert indicator.get_argument_values("missing") == []
class TestChartIndicators:
"""Tests for the ``ChartIndicators`` dataclass."""
def test_from_dict_builds_indicators(self):
"""``from_dict`` constructs indicators and arguments."""
ci = ChartIndicators.from_dict({"sma": {"length": [20, 50]}})
assert ci.indicators is not None
assert ci.indicators[0].name == "sma"
assert ci.indicators[0].args[0].label == "length"
assert ci.indicators[0].args[0].values == [20, 50]
def test_get_indicator_found(self):
"""``get_indicator`` returns the matching indicator."""
ci = ChartIndicators.from_dict({"rsi": {"length": 14}})
assert ci.get_indicator("rsi").name == "rsi"
def test_get_indicator_not_found(self):
"""``get_indicator`` returns None when absent."""
ci = ChartIndicators.from_dict({"rsi": {"length": 14}})
assert ci.get_indicator("sma") is None
def test_get_indicator_args_found(self):
"""``get_indicator_args`` returns argument values when present."""
ci = ChartIndicators.from_dict({"rsi": {"length": 14}})
assert ci.get_indicator_args("rsi", "length") == 14
def test_get_indicator_args_label_missing(self):
"""``get_indicator_args`` returns None when the label is absent."""
ci = ChartIndicators.from_dict({"rsi": {"length": 14}})
assert ci.get_indicator_args("rsi", "missing") is None
def test_get_indicator_args_indicator_missing(self):
"""``get_indicator_args`` returns None when the indicator is absent."""
ci = ChartIndicators.from_dict({"rsi": {"length": 14}})
assert ci.get_indicator_args("sma", "length") is None
def test_get_indicators(self):
"""``get_indicators`` returns the underlying list."""
ci = ChartIndicators.from_dict({"rsi": {"length": 14}})
assert ci.get_indicators() == ci.indicators
def test_get_params_with_indicators(self):
"""``get_params`` maps names to indicators."""
ci = ChartIndicators.from_dict({"rsi": {"length": 14}})
params = ci.get_params()
assert set(params) == {"rsi"}
assert isinstance(params["rsi"], TAIndicator)
def test_get_params_without_indicators(self):
"""``get_params`` returns an empty dict when there are no indicators."""
assert ChartIndicators(indicators=None).get_params() == {}
def test_get_active_ids_with_indicators(self):
"""``get_active_ids`` returns the active indicator names."""
ci = ChartIndicators.from_dict({"rsi": {"length": 14}, "sma": {"length": 20}})
assert ci.get_active_ids() == ["rsi", "sma"]
def test_get_active_ids_without_indicators(self):
"""``get_active_ids`` returns an empty list when there are none."""
assert ChartIndicators(indicators=None).get_active_ids() == []
def test_get_arg_names_found(self):
"""``get_arg_names`` returns argument labels for an indicator."""
ci = ChartIndicators.from_dict({"macd": {"fast": 12, "slow": 26}})
assert ci.get_arg_names("macd") == ["fast", "slow"]
def test_get_arg_names_not_found(self):
"""``get_arg_names`` returns an empty list when indicator absent."""
ci = ChartIndicators.from_dict({"macd": {"fast": 12}})
assert ci.get_arg_names("rsi") == []
def test_get_options_dict_with_options(self):
"""``get_options_dict`` maps labels to values when present."""
ci = ChartIndicators.from_dict({"macd": {"fast": 12, "slow": 26}})
assert ci.get_options_dict("macd") == {"fast": 12, "slow": 26}
def test_get_options_dict_no_options(self):
"""``get_options_dict`` returns None when there are no arguments."""
ci = ChartIndicators.from_dict({"obv": {}})
assert ci.get_options_dict("obv") is None
def test_get_available_indicators(self):
"""``get_available_indicators`` returns the literal name tuple."""
available = ChartIndicators.get_available_indicators()
assert "sma" in available
assert "rsi" not in available
assert isinstance(available, tuple)
def test_remove_indicator(self):
"""``remove_indicator`` drops the named indicator."""
ci = ChartIndicators.from_dict({"rsi": {"length": 14}, "sma": {"length": 20}})
ci.remove_indicator("rsi")
assert ci.get_active_ids() == ["sma"]
def test_remove_indicator_no_indicators(self):
"""``remove_indicator`` is a no-op when there are no indicators."""
ci = ChartIndicators(indicators=None)
ci.remove_indicator("rsi")
assert ci.indicators is None
def test_to_dataframe_empty_input(self):
"""``to_dataframe`` returns the (empty) frame unchanged when empty."""
ci = ChartIndicators.from_dict({"sma": {"length": 20}})
empty = pd.DataFrame()
result = ci.to_dataframe(empty)
assert result.empty
def test_to_dataframe_no_indicators(self, ohlcv_df):
"""``to_dataframe`` returns input unchanged when no indicators."""
ci = ChartIndicators(indicators=None)
result = ci.to_dataframe(ohlcv_df)
assert list(result.columns) == list(ohlcv_df.columns)
def test_to_dataframe_success(self, ohlcv_df):
"""``to_dataframe`` joins computed indicator columns."""
ci = ChartIndicators.from_dict({"sma": {"length": 20}})
result = ci.to_dataframe(ohlcv_df, ["sma"])
assert any("SMA" in c for c in result.columns)
def test_to_dataframe_swallows_exception(self, ohlcv_df):
"""``to_dataframe`` warns and returns input on processing error."""
ci = ChartIndicators.from_dict({"sma": {"length": [20, 20]}})
with pytest.warns(UserWarning):
result = ci.to_dataframe(ohlcv_df, ["sma"])
assert "SMA_20" not in result.columns
def test_get_indicator_data_success(self, ohlcv_df):
"""``get_indicator_data`` returns indicator data for an indicator."""
ci = ChartIndicators.from_dict({"sma": {"length": 20}})
indicator = ci.get_indicator("sma")
result = ci.get_indicator_data(ohlcv_df, indicator, length=20)
assert result is not None
def test_get_indicator_data_no_indicators(self, ohlcv_df):
"""``get_indicator_data`` returns None with no active indicators."""
ci = ChartIndicators(indicators=None)
indicator = TAIndicator(name="sma", args=[Arguments("length", 20)])
assert ci.get_indicator_data(ohlcv_df, indicator, length=20) is None
def test_get_indicator_data_swallows_exception(self):
"""``get_indicator_data`` warns and returns None on error."""
ci = ChartIndicators.from_dict({"sma": {"length": 20}})
indicator = ci.get_indicator("sma")
bad = pd.DataFrame({"open": [1, 2, 3], "volume": [1, 2, 3]})
with pytest.warns(UserWarning):
assert ci.get_indicator_data(bad, indicator, length=20) is None
class TestTADataException:
"""Tests for the ``TA_DataException`` type."""
def test_is_exception_subclass(self):
"""It is an ``Exception`` subclass that carries a message."""
err = TA_DataException("boom")
assert isinstance(err, Exception)
assert str(err) == "boom"
class TestTAData:
"""Tests for the ``TA_Data`` processing class."""
def test_init_from_series(self, ohlcv_df):
"""A Series input is converted to a DataFrame internally."""
ci = ChartIndicators.from_dict({"sma": {"length": 20}})
series = ohlcv_df["close"].rename("close")
td = TA_Data(series, ci)
assert isinstance(td.df_ta, pd.DataFrame)
assert td.close_col == "close"
def test_init_from_dict_indicators(self, ohlcv_df):
"""A dict of indicators is converted to ``ChartIndicators``."""
td = TA_Data(ohlcv_df, {"sma": {"length": 20}})
assert isinstance(td.indicators, ChartIndicators)
def test_init_default_ma_mode(self, ohlcv_df):
"""The default moving-average modes are used when none given."""
td = TA_Data(ohlcv_df, ChartIndicators.from_dict({"sma": {"length": 20}}))
assert td.ma_mode == ["sma", "ema", "wma", "hma", "zlma", "rma"]
def test_init_no_close_raises(self):
"""Construction raises when no close column can be resolved."""
bad = pd.DataFrame({"high": [1, 2], "low": [1, 2], "open": [1, 2]})
with pytest.raises(IndexError):
TA_Data(bad, ChartIndicators.from_dict({"sma": {"length": 20}}))
def test_init_has_volume_flag(self, ohlcv_df, no_volume_df):
"""The ``has_volume`` flag reflects whether volume sums above zero."""
ci = ChartIndicators.from_dict({"sma": {"length": 20}})
assert TA_Data(ohlcv_df, ci).has_volume is True
assert TA_Data(no_volume_df, ci).has_volume is False
def test_get_indicator_data_ma_list(self, ohlcv_df):
"""A list length for an MA indicator yields one column per length."""
ci = ChartIndicators.from_dict({"sma": {"length": [20, 50]}})
td = TA_Data(ohlcv_df, ci)
out = td.get_indicator_data(ci.get_indicator("sma"), length=[20, 50])
assert {"SMA_20", "SMA_50"} <= set(out.columns)
def test_get_indicator_data_ma_scalar(self, ohlcv_df):
"""A scalar length for an MA indicator yields a single series."""
ci = ChartIndicators.from_dict({"ema": {"length": 20}})
td = TA_Data(ohlcv_df, ci)
out = td.get_indicator_data(ci.get_indicator("ema"), length=20)
assert out is not None
def test_get_indicator_data_zlma_renamed(self, ohlcv_df):
"""ZLMA output is renamed from the pandas_ta ZL_EMA name."""
ci = ChartIndicators.from_dict({"zlma": {"length": 20}})
td = TA_Data(ohlcv_df, ci)
out = td.get_indicator_data(ci.get_indicator("zlma"), length=20)
assert "ZLMA" in out.name
def test_get_indicator_data_vwap(self, ohlcv_df):
"""VWAP is computed from its declared columns."""
ci = ChartIndicators.from_dict({"vwap": {}})
td = TA_Data(ohlcv_df, ci)
out = td.get_indicator_data(ci.get_indicator("vwap"))
assert out is not None
def test_get_indicator_data_columns_with_use_open(self, ohlcv_df):
"""A columns indicator appends open when ``use_open`` is True."""
ci = ChartIndicators.from_dict({"stoch": {"use_open": True}})
td = TA_Data(ohlcv_df, ci)
out = td.get_indicator_data(ci.get_indicator("stoch"))
assert out is not None
def test_get_indicator_data_columns_without_use_open(self, ohlcv_df):
"""A columns indicator omits open when ``use_open`` is absent."""
ci = ChartIndicators.from_dict({"adx": {}})
td = TA_Data(ohlcv_df, ci)
out = td.get_indicator_data(ci.get_indicator("adx"), length=14)
assert out is not None
def test_get_indicator_data_default_branch(self, ohlcv_df):
"""A non-MA, non-columns indicator is computed from close."""
ci = ChartIndicators.from_dict({"rsi": {"length": 14}})
td = TA_Data(ohlcv_df, ci)
out = td.get_indicator_data(ci.get_indicator("rsi"), length=14)
assert out is not None
def test_get_indicator_data_scalar_returns_none(self, ohlcv_df):
"""A scalar length larger than the data yields None directly."""
ci = ChartIndicators.from_dict({"sma": {"length": 5000}})
td = TA_Data(ohlcv_df, ci)
out = td.get_indicator_data(ci.get_indicator("sma"), length=5000)
assert out is None
def test_get_indicator_data_empty_returns_none(self, ohlcv_df):
"""An all-NaN indicator output is dropped to None after dropna."""
ci = ChartIndicators.from_dict({"sma": {"length": [5000, 6000]}})
td = TA_Data(ohlcv_df, ci)
out = td.get_indicator_data(ci.get_indicator("sma"), length=[5000, 6000])
assert out is None
def test_to_dataframe_no_active_indicators(self, ohlcv_df):
"""``to_dataframe`` returns None when there are no indicators."""
ci = ChartIndicators(indicators=None)
td = TA_Data(ohlcv_df, ci)
assert td.to_dataframe() is None
def test_to_dataframe_skips_volume_indicator_without_volume(self, no_volume_df):
"""Volume-requiring indicators are skipped when volume is absent."""
ci = ChartIndicators.from_dict({"obv": {}})
td = TA_Data(no_volume_df, ci)
out = td.to_dataframe()
assert not any("OBV" in c for c in out.columns)
def test_to_dataframe_skips_overlay_only_indicators(self, ohlcv_df):
"""Overlay-only indicators (fib/srlines/...) are skipped."""
ci = ChartIndicators.from_dict({"fib": {}})
td = TA_Data(ohlcv_df, ci)
out = td.to_dataframe()
assert list(out.columns) == list(ohlcv_df.columns)
def test_to_dataframe_joins_indicator(self, ohlcv_df):
"""``to_dataframe`` joins and interpolates computed indicators."""
ci = ChartIndicators.from_dict({"sma": {"length": 20}})
td = TA_Data(ohlcv_df, ci)
out = td.to_dataframe()
assert any("SMA" in c for c in out.columns)
def test_to_dataframe_raises_on_indicator_error(self, ohlcv_df):
"""``to_dataframe`` wraps indicator errors in ``TA_DataException``."""
ci = ChartIndicators.from_dict({"sma": {"length": [20, 20]}})
td = TA_Data(ohlcv_df, ci)
with pytest.raises(TA_DataException, match="Error processing indicator sma"):
td.to_dataframe()

View File

@@ -0,0 +1,522 @@
"""Tests for the technical-analysis plugins of ``openbb_charting``."""
from __future__ import annotations
import builtins
import datetime as dt
import warnings
import numpy as np
import pandas as pd
import pytest
from freezegun import freeze_time
from openbb_charting.core.openbb_figure import OpenBBFigure
from openbb_charting.core.plotly_ta.data_classes import ChartIndicators
from openbb_charting.core.plotly_ta.plugins import momentum_plugin
from openbb_charting.core.plotly_ta.ta_class import PlotlyTA
def _make_ohlcv(
seed: int = 42, periods: int = 250, start: str = "2023-01-01"
) -> pd.DataFrame:
"""Build a deterministic daily OHLCV frame indexed by date."""
rng = np.random.default_rng(seed)
idx = pd.date_range(start, periods=periods, freq="D")
close = 100 + np.cumsum(rng.normal(0, 1, periods))
open_ = close + rng.normal(0, 0.5, periods)
high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.5, periods))
low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.5, periods))
volume = rng.integers(1_000_000, 5_000_000, periods)
frame = pd.DataFrame(
{"open": open_, "high": high, "low": low, "close": close, "volume": volume},
index=idx,
)
frame.index.name = "date"
return frame
def _make_intraday(
periods: int = 160, freq: str = "5min", start: str = "2024-03-01 09:30"
) -> pd.DataFrame:
"""Build a deterministic intraday OHLCV frame indexed by datetime."""
rng = np.random.default_rng(7)
idx = pd.date_range(start, periods=periods, freq=freq)
close = 100 + np.cumsum(rng.normal(0, 0.2, periods))
open_ = close + rng.normal(0, 0.1, periods)
high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.1, periods))
low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.1, periods))
volume = rng.integers(1_000, 5_000, periods)
frame = pd.DataFrame(
{"open": open_, "high": high, "low": low, "close": close, "volume": volume},
index=idx,
)
frame.index.name = "date"
return frame
def _prepared(df: pd.DataFrame, indicators: dict, intraday: bool = False) -> PlotlyTA:
"""Return a ``PlotlyTA`` instance with internal plotting state populated."""
inst = PlotlyTA()
inst._clear_data()
ci = ChartIndicators.from_dict(indicators)
inst.indicators = ci
inst.df_stock = df.copy().sort_index(ascending=True)
inst.close_column = "close"
inst.params = ci.get_params()
inst.has_volume = bool(df["volume"].sum() > 0)
inst.show_volume = inst.has_volume
inst.intraday = intraday
inst.df_ta = inst.calculate_indicators()
return inst
@pytest.fixture
def daily_df() -> pd.DataFrame:
"""A single-symbol daily OHLCV DataFrame (250 rows)."""
return _make_ohlcv()
@pytest.fixture
def recent_daily_df() -> pd.DataFrame:
"""A daily OHLCV frame ending today, for window-based date filtering."""
start = (pd.Timestamp.now().normalize() - pd.Timedelta(days=249)).strftime(
"%Y-%m-%d"
)
return _make_ohlcv(periods=250, start=start)
@pytest.fixture
def recent_intraday_df() -> pd.DataFrame:
"""A 5-minute intraday OHLCV frame spanning the last few days."""
start = pd.Timestamp.now().normalize() - pd.Timedelta(days=2)
return _make_intraday(periods=160, freq="5min", start=start.strftime("%Y-%m-%d"))
def _plot(df: pd.DataFrame, indicators: dict, **kwargs) -> OpenBBFigure:
"""Run ``PlotlyTA.plot`` while silencing the plugin error/skip warnings."""
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return PlotlyTA.plot(df, indicators=indicators, symbol="AAA", **kwargs)
class TestMomentumPlugin:
"""Tests for ``Momentum`` momentum indicators."""
def test_cci_subplot(self, daily_df):
"""CCI renders its line, over/under rectangles and annotation."""
fig = _plot(daily_df, {"cci": {"length": 14}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_cg_subplot(self, daily_df):
"""The center-of-gravity oscillator plots its line and signal."""
fig = _plot(daily_df, {"cg": {"length": 10}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_fisher_subplot(self, daily_df):
"""The Fisher transform plots its transform, signal and bands."""
fig = _plot(daily_df, {"fisher": {"length": 14}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_macd_subplot(self, daily_df):
"""MACD plots the histogram, MACD and signal lines."""
fig = _plot(
daily_df,
{"macd": {"fast": 12, "slow": 26, "signal": 9}},
candles=True,
volume=True,
)
assert isinstance(fig, OpenBBFigure)
def test_rsi_subplot(self, daily_df):
"""RSI plots its line, over/under zones and tickvals."""
fig = _plot(daily_df, {"rsi": {"length": 14}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_stoch_subplot(self, daily_df):
"""The stochastic oscillator plots its %K and %D lines."""
fig = _plot(daily_df, {"stoch": {}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_clenow_inchart(self, daily_df):
"""The Clenow momentum overlay fits and plots its trend line."""
fig = _plot(daily_df, {"clenow": {"window": 90}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_clenow_import_error_warns(self, daily_df, monkeypatch):
"""A missing ``openbb-technical`` install warns and returns unchanged."""
inst = _prepared(daily_df, {"clenow": {"window": 90}})
fig = inst.init_plot("AAA", candles=True)
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "openbb_technical.helpers":
raise ImportError("forced")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.warns(UserWarning, match="openbb-technical"):
out, idx = inst.plot_clenow(fig, inst.df_ta, 0)
assert idx == 0
assert isinstance(out, OpenBBFigure)
def test_demark_inchart(self, daily_df, monkeypatch):
"""Demark plots up/down sequence labels from a TD-sequence count."""
inst = _prepared(daily_df, {"demark": {"min_val": 5}})
fig = inst.init_plot("AAA", candles=True)
n = len(inst.df_ta)
up = np.zeros(n, dtype=int)
dn = np.zeros(n, dtype=int)
up[10:19] = np.arange(1, 10)
dn[30:39] = np.arange(1, 10)
td = pd.DataFrame({"TD_SEQ_UPa": up, "TD_SEQ_DNa": dn})
monkeypatch.setattr(
momentum_plugin.ta, "td_seq", lambda *a, **k: td, raising=False
)
out, idx = inst.plot_demark(fig, inst.df_ta, 0)
assert idx == 1
assert isinstance(out, OpenBBFigure)
def test_ichimoku_inchart(self, daily_df):
"""Ichimoku plots conversion/base lines and the leading-span cloud."""
fig = _plot(
daily_df,
{
"ichimoku": {
"conversion_period": 9,
"base_period": 26,
"lagging_line_period": 52,
"displacement": 26,
}
},
candles=True,
volume=True,
)
assert isinstance(fig, OpenBBFigure)
def test_ichimoku_default_params(self, daily_df):
"""Ichimoku falls back to default periods when none are supplied."""
fig = _plot(daily_df, {"ichimoku": {}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
class TestVolatilityPlugin:
"""Tests for ``Volatility`` volatility indicators."""
def test_atr_subplot(self, daily_df):
"""ATR plots its line in a dedicated subplot."""
fig = _plot(daily_df, {"atr": {"length": 14}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_bbands_inchart_integer_std(self, daily_df):
"""Bollinger bands with an integer std truncate the band label."""
fig = _plot(
daily_df, {"bbands": {"length": 20, "std": 2.0}}, candles=True, volume=True
)
assert isinstance(fig, OpenBBFigure)
def test_bbands_inchart_fractional_std(self, daily_df):
"""Bollinger bands with a fractional std keep the decimal label."""
fig = _plot(
daily_df, {"bbands": {"length": 20, "std": 2.5}}, candles=True, volume=True
)
assert isinstance(fig, OpenBBFigure)
def test_bbands_dark_theme_opacity(self, daily_df):
"""A dark theme drives the full band opacity branch."""
inst = _prepared(daily_df, {"bbands": {"length": 20, "std": 2.0}})
fig = inst.init_plot("AAA", candles=True)
original = fig.theme.plt_style
fig.theme.plt_style = "dark"
try:
out, idx = inst.plot_bbands(fig, inst.df_ta, 0)
finally:
fig.theme.plt_style = original
assert idx == 1
assert isinstance(out, OpenBBFigure)
def test_donchian_inchart(self, daily_df):
"""Donchian channels plot the upper/lower filled band."""
fig = _plot(daily_df, {"donchian": {}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_donchian_dark_theme(self, daily_df):
"""A dark theme drives the donchian dark-style branch."""
inst = _prepared(daily_df, {"donchian": {}})
fig = inst.init_plot("AAA", candles=True)
original = fig.theme.plt_style
fig.theme.plt_style = "dark"
try:
out, idx = inst.plot_donchian(fig, inst.df_ta, 0)
finally:
fig.theme.plt_style = original
assert idx == 1
assert isinstance(out, OpenBBFigure)
def test_kc_inchart(self, daily_df):
"""Keltner channels plot the upper/lower filled band."""
fig = _plot(daily_df, {"kc": {"mamode": "ema"}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_kc_default_mamode(self, daily_df):
"""Keltner channels default to the EMA moving-average mode."""
fig = _plot(daily_df, {"kc": {}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_kc_dark_theme(self, daily_df):
"""A dark theme drives the Keltner dark-style branch."""
inst = _prepared(daily_df, {"kc": {"mamode": "ema"}})
fig = inst.init_plot("AAA", candles=True)
original = fig.theme.plt_style
fig.theme.plt_style = "dark"
try:
out, idx = inst.plot_kc(fig, inst.df_ta, 0)
finally:
fig.theme.plt_style = original
assert idx == 1
assert isinstance(out, OpenBBFigure)
class TestVolumePlugin:
"""Tests for ``Volume`` volume indicators."""
def test_ad_subplot(self, daily_df):
"""The accumulation/distribution line plots in a subplot."""
fig = _plot(daily_df, {"ad": {}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_adosc_subplot(self, daily_df):
"""The A/D oscillator plots its line in a subplot."""
fig = _plot(daily_df, {"adosc": {}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_obv_subplot(self, daily_df):
"""On-balance volume plots its line in a subplot."""
fig = _plot(daily_df, {"obv": {}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
class TestOverlapPlugin:
"""Tests for ``Overlap`` overlap indicators."""
def test_single_moving_average(self, daily_df):
"""A single SMA overlay is plotted in-chart."""
fig = _plot(daily_df, {"sma": {"length": 20}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_multi_length_moving_average(self, daily_df):
"""A multi-length SMA overlay plots one trace per length."""
fig = _plot(daily_df, {"sma": {"length": [20, 50]}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_all_moving_average_modes(self, daily_df):
"""Every moving-average mode collapses into a single ``ma`` plot."""
fig = _plot(
daily_df,
{
"sma": {"length": 20},
"ema": {"length": 20},
"wma": {"length": 20},
"hma": {"length": 20},
"zlma": {"length": 20},
"rma": {"length": 20},
},
candles=True,
volume=True,
)
assert isinstance(fig, OpenBBFigure)
def test_vwap_inchart(self, daily_df):
"""VWAP is plotted as an in-chart overlay."""
fig = _plot(daily_df, {"vwap": {}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_moving_average_empty_column_skipped(self, daily_df):
"""An empty moving-average column is skipped without plotting."""
inst = _prepared(daily_df, {"sma": {"length": 20}})
fig = inst.init_plot("AAA", candles=True)
empty_ta = inst.df_ta.iloc[0:0]
out, idx = inst.plot_ma(fig, empty_ta, 0)
assert idx == 0
assert isinstance(out, OpenBBFigure)
def test_moving_average_exception_warns(self, daily_df):
"""An error while adding a moving average is caught and warned."""
inst = _prepared(daily_df, {"sma": {"length": 20}})
fig = inst.init_plot("AAA", candles=True)
inst.inchart_colors = []
with pytest.warns(UserWarning, match="Error adding SMA"):
out, idx = inst.plot_ma(fig, inst.df_ta, 0)
assert idx == 0
assert isinstance(out, OpenBBFigure)
class TestTrendPlugin:
"""Tests for ``Trend`` trend indicators."""
def test_adx_subplot(self, daily_df):
"""ADX plots the ADX, +DI and -DI lines with a threshold line."""
fig = _plot(daily_df, {"adx": {"length": 14}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_aroon_two_row_subplot(self, daily_df):
"""Aroon plots the up/down lines and the oscillator across two rows."""
fig = _plot(daily_df, {"aroon": {"length": 14}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
class TestCustomPlugin:
"""Tests for ``Custom`` custom indicators."""
def test_fib_inchart_daily(self, daily_df):
"""Fibonacci retracement levels are plotted from daily data."""
fig = _plot(daily_df, {"fib": {}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_fib_rising_series_left_labels(self):
"""A rising series yields left-anchored Fibonacci labels."""
idx = pd.date_range("2023-01-01", periods=150, freq="D")
close = np.linspace(100, 200, 150)
df = pd.DataFrame(
{
"open": close,
"high": close + 1,
"low": close - 1,
"close": close,
"volume": np.ones(150) * 1e6,
},
index=idx,
)
df.index.name = "date"
inst = _prepared(df, {"fib": {"limit": 120}})
fig = inst.init_plot("AAA", candles=True)
out = inst.plot_fib(fig, inst.df_ta)
assert isinstance(out, OpenBBFigure)
def test_fib_falling_series_right_labels(self):
"""A falling series yields right-anchored Fibonacci labels."""
idx = pd.date_range("2023-01-01", periods=150, freq="D")
close = np.linspace(200, 100, 150)
df = pd.DataFrame(
{
"open": close,
"high": close + 1,
"low": close - 1,
"close": close,
"volume": np.ones(150) * 1e6,
},
index=idx,
)
df.index.name = "date"
inst = _prepared(df, {"fib": {"limit": 120}})
fig = inst.init_plot("AAA", candles=True)
out = inst.plot_fib(fig, inst.df_ta)
assert isinstance(out, OpenBBFigure)
def test_fib_intraday_branch(self, recent_intraday_df):
"""Fibonacci handles the intraday same-day resampling branch."""
inst = _prepared(recent_intraday_df, {"fib": {}}, intraday=True)
fig = inst.init_plot("AAA", candles=True)
out = inst.plot_fib(fig, inst.df_ta)
assert isinstance(out, OpenBBFigure)
def test_fib_import_error_warns(self, daily_df, monkeypatch):
"""A missing ``openbb-technical`` install warns and returns unchanged."""
inst = _prepared(daily_df, {"fib": {}})
fig = inst.init_plot("AAA", candles=True)
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "openbb_technical.helpers":
raise ImportError("forced")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.warns(UserWarning, match="Fibonacci"):
out = inst.plot_fib(fig, inst.df_ta)
assert isinstance(out, OpenBBFigure)
def test_srlines_daily_window(self, recent_daily_df):
"""Support/resistance line detection runs over a recent daily window."""
fig = _plot(
recent_daily_df, {"srlines": {"window": [200]}}, candles=True, volume=True
)
assert isinstance(fig, OpenBBFigure)
def test_srlines_intraday_window(self, recent_intraday_df):
"""Support/resistance detection runs the intraday resampling branch."""
fig = _plot(
recent_intraday_df,
{"srlines": {"window": [5]}},
candles=True,
volume=True,
)
assert isinstance(fig, OpenBBFigure)
def test_srlines_default_window(self, recent_daily_df):
"""Support/resistance falls back to the default window when unset."""
fig = _plot(recent_daily_df, {"srlines": {}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_srlines_daily_weekend_x_range(self):
"""Daily support/resistance extends the projection past a weekend."""
with freeze_time(dt.datetime(2024, 6, 15, 12, 0)):
idx = pd.date_range("2024-01-01", "2024-06-14", freq="D")
rng = np.random.default_rng(1)
close = 100 + np.cumsum(rng.normal(0, 1, len(idx)))
df = pd.DataFrame(
{
"open": close,
"high": close + 1,
"low": close - 1,
"close": close,
"volume": np.ones(len(idx)) * 1e6,
},
index=idx,
)
df.index.name = "date"
fig = _plot(df, {"srlines": {"window": [200]}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)
def test_srlines_empty_detection_loop(self):
"""Support/resistance returns cleanly when the scan window is too short."""
with freeze_time(dt.datetime(2024, 6, 17, 12, 0)):
idx = pd.date_range("2024-06-14", "2024-06-16", freq="D")
rng = np.random.default_rng(5)
close = 100 + np.cumsum(rng.normal(0, 1, len(idx)))
df = pd.DataFrame(
{
"open": close,
"high": close + 1,
"low": close - 1,
"close": close,
"volume": np.ones(len(idx)) * 1e6,
},
index=idx,
)
df.index.name = "date"
inst = _prepared(df, {"srlines": {"window": [5]}})
fig = inst.init_plot("AAA", candles=True)
out = inst.plot_srlines(fig, inst.df_ta)
assert isinstance(out, OpenBBFigure)
def test_srlines_intraday_late_weekend_x_range(self):
"""Intraday support/resistance projects from a post-15:00 Friday bar."""
with freeze_time(dt.datetime(2024, 6, 14, 16, 0)):
idx = pd.date_range("2024-06-14 09:30", periods=78, freq="5min")
rng = np.random.default_rng(3)
close = 100 + np.cumsum(rng.normal(0, 0.2, len(idx)))
df = pd.DataFrame(
{
"open": close,
"high": close + 0.3,
"low": close - 0.3,
"close": close,
"volume": np.ones(len(idx)) * 1e4,
},
index=idx,
)
df.index.name = "date"
fig = _plot(df, {"srlines": {"window": [5]}}, candles=True, volume=True)
assert isinstance(fig, OpenBBFigure)

View File

@@ -0,0 +1,237 @@
"""Tests for ``openbb_charting.charts.price_historical``."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from openbb_core.provider.abstract.data import Data
from openbb_charting.charts.price_historical import price_historical
from openbb_charting.core.openbb_figure import OpenBBFigure
def _ohlcv(seed: int, periods: int = 120, symbol: str | None = None) -> pd.DataFrame:
"""Build a deterministic OHLCV frame with a string ``date`` column."""
rng = np.random.default_rng(seed)
idx = pd.date_range("2023-01-01", periods=periods, freq="D")
close = 100 + np.cumsum(rng.normal(0, 1, periods))
open_ = close + rng.normal(0, 0.5, periods)
high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.5, periods))
low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.5, periods))
volume = rng.integers(1_000_000, 5_000_000, periods)
frame = pd.DataFrame(
{
"date": idx.strftime("%Y-%m-%d"),
"open": open_,
"high": high,
"low": low,
"close": close,
"volume": volume,
}
)
if symbol is not None:
frame["symbol"] = symbol
return frame
@pytest.fixture
def multi_symbol_data() -> list[Data]:
"""Long-format OHLCV records for three symbols."""
frames = [_ohlcv(i, symbol=sym) for i, sym in enumerate(["AAA", "BBB", "CCC"])]
long = pd.concat(frames, ignore_index=True)
return [Data.model_validate(row) for row in long.to_dict(orient="records")]
@pytest.fixture
def two_symbol_data() -> list[Data]:
"""Long-format OHLCV records for two symbols."""
frames = [_ohlcv(i, symbol=sym) for i, sym in enumerate(["AAA", "BBB"])]
long = pd.concat(frames, ignore_index=True)
return [Data.model_validate(row) for row in long.to_dict(orient="records")]
@pytest.fixture
def single_symbol_data() -> list[Data]:
"""Single-symbol OHLCV records without a ``symbol`` column."""
return [Data.model_validate(row) for row in _ohlcv(7).to_dict(orient="records")]
class TestPriceHistoricalSingleSymbol:
"""Tests for the single-symbol candlestick path of ``price_historical``."""
def test_candles_default(self, ohlcv_data):
"""It draws candles by default and returns a figure plus plotly JSON."""
fig, content = price_historical(obbject_item=ohlcv_data)
assert isinstance(fig, OpenBBFigure)
assert isinstance(content, dict)
assert "data" in content
assert "layout" in content
def test_dataframe_input(self, ohlcv_df):
"""It accepts a ready DataFrame passed via ``data``."""
fig, _ = price_historical(data=ohlcv_df)
assert isinstance(fig, OpenBBFigure)
def test_list_input_via_data_kwarg(self, ohlcv_data):
"""It accepts a list of ``Data`` passed via the ``data`` kwarg."""
fig, _ = price_historical(data=ohlcv_data)
assert isinstance(fig, OpenBBFigure)
def test_custom_title(self, ohlcv_data):
"""It honors a custom title on the candlestick path."""
fig, _ = price_historical(obbject_item=ohlcv_data, title="My Prices")
assert fig.layout.title.text == "My Prices"
def test_heikin_ashi(self, ohlcv_data):
"""It computes Heikin-Ashi candles and tags the title."""
fig, _ = price_historical(obbject_item=ohlcv_data, heikin_ashi=True)
assert "Heikin Ashi" in fig.layout.title.text
def test_indicator_sma(self, ohlcv_data):
"""It overlays a simple-moving-average indicator."""
fig, _ = price_historical(
obbject_item=ohlcv_data, indicators={"sma": {"length": [20]}}
)
assert isinstance(fig, OpenBBFigure)
def test_indicator_atr_keeps_volume(self, ohlcv_data):
"""It adds in-chart volume back when the ATR indicator is requested."""
fig, _ = price_historical(
obbject_item=ohlcv_data, indicators={"atr": {"length": 14}}
)
assert isinstance(fig, OpenBBFigure)
def test_volume_disabled(self, ohlcv_data):
"""It draws candles without volume when volume is disabled."""
fig, _ = price_historical(obbject_item=ohlcv_data, volume=False)
assert isinstance(fig, OpenBBFigure)
def test_dataframe_with_date_column(self, ohlcv_df):
"""It moves a ``date`` column to the index when one is present."""
with_date_col = ohlcv_df.reset_index()
fig, _ = price_historical(data=with_date_col)
assert isinstance(fig, OpenBBFigure)
class TestPriceHistoricalSingleSymbolTargetBranches:
"""Tests for single-symbol target selection without a ``symbol`` column."""
def test_target_close_single_symbol(self, single_symbol_data):
"""It selects the target column for a single-symbol frame."""
fig, _ = price_historical(obbject_item=single_symbol_data, target="close")
assert isinstance(fig, OpenBBFigure)
def test_normalize_single_symbol(self, single_symbol_data):
"""It normalizes the target column for a single-symbol frame."""
fig, _ = price_historical(
obbject_item=single_symbol_data, target="close", normalize=True
)
assert isinstance(fig, OpenBBFigure)
def test_returns_single_symbol(self, single_symbol_data):
"""It computes returns on the target column for a single-symbol frame."""
fig, _ = price_historical(
obbject_item=single_symbol_data, target="close", returns=True
)
assert isinstance(fig, OpenBBFigure)
class TestPriceHistoricalLine:
"""Tests for the line/scatter rendering path of ``price_historical``."""
def test_candles_off_single_symbol(self, ohlcv_data):
"""It draws a single line when candles are turned off."""
fig, _ = price_historical(obbject_item=ohlcv_data, candles=False)
assert isinstance(fig, OpenBBFigure)
assert fig.data
def test_two_column_secondary_axis(self):
"""It places a divergent-scale second series on the secondary axis."""
idx = pd.date_range("2023-01-01", periods=80, freq="D")
df = pd.DataFrame(
{"AAA": np.linspace(100, 120, 80), "BBB": np.linspace(1, 2, 80)},
index=idx,
)
df.index.name = "date"
fig, _ = price_historical(data=df, candles=False)
yaxes = {getattr(trace, "yaxis", None) for trace in fig.data}
assert "y2" in yaxes
def test_two_column_shared_axis(self):
"""It shares the primary axis when the two series have similar ranges."""
idx = pd.date_range("2023-01-01", periods=80, freq="D")
df = pd.DataFrame(
{"AAA": np.linspace(100, 120, 80), "BBB": np.linspace(101, 121, 80)},
index=idx,
)
df.index.name = "date"
fig, _ = price_historical(data=df, candles=False)
yaxes = {getattr(trace, "yaxis", None) for trace in fig.data}
assert yaxes == {"y"}
def test_same_axis_flag(self):
"""It forces all series onto the primary axis when ``same_axis`` is set."""
idx = pd.date_range("2023-01-01", periods=80, freq="D")
df = pd.DataFrame(
{"AAA": np.linspace(100, 120, 80), "BBB": np.linspace(1, 2, 80)},
index=idx,
)
df.index.name = "date"
fig, _ = price_historical(data=df, candles=False, same_axis=True)
yaxes = {getattr(trace, "yaxis", None) for trace in fig.data}
assert yaxes == {"y"}
class TestPriceHistoricalMultiSymbol:
"""Tests for the multi-symbol branches of ``price_historical``."""
def test_multi_symbol_target_close(self, multi_symbol_data):
"""It pivots long-format data on ``symbol`` for the chosen target."""
fig, _ = price_historical(obbject_item=multi_symbol_data, target="close")
assert len(fig.data) == 3
def test_multi_symbol_returns(self, multi_symbol_data):
"""It computes cumulative returns and labels the title accordingly."""
fig, _ = price_historical(
obbject_item=multi_symbol_data, target="close", returns=True
)
assert "Cumulative Returns" in fig.layout.title.text
def test_multi_symbol_normalize(self, multi_symbol_data):
"""It z-score normalizes the series and labels the title."""
fig, _ = price_historical(
obbject_item=multi_symbol_data, target="close", normalize=True
)
assert "Normalized" in fig.layout.title.text
def test_multi_symbol_normalize_and_returns(self, multi_symbol_data):
"""It applies normalized cumulative returns when both flags are set."""
fig, _ = price_historical(
obbject_item=multi_symbol_data,
target="close",
returns=True,
normalize=True,
)
assert "Normalized Cumulative Returns" in fig.layout.title.text
def test_two_symbol_returns(self, two_symbol_data):
"""It renders cumulative returns for a two-column pivot."""
fig, _ = price_historical(
obbject_item=two_symbol_data, target="close", returns=True
)
assert isinstance(fig, OpenBBFigure)
def test_multi_symbol_same_axis(self, multi_symbol_data):
"""It forces a single axis when ``same_axis`` is set with many series."""
fig, _ = price_historical(
obbject_item=multi_symbol_data, target="close", same_axis=True
)
assert isinstance(fig, OpenBBFigure)
def test_multi_symbol_custom_title(self, multi_symbol_data):
"""It honors a custom title on the multi-symbol scatter path."""
fig, _ = price_historical(
obbject_item=multi_symbol_data, target="close", title="Custom"
)
assert fig.layout.title.text == "Custom"

View File

@@ -0,0 +1,109 @@
"""Tests for ``openbb_charting.charts.price_performance``."""
from __future__ import annotations
import pandas as pd
import pytest
from openbb_core.provider.abstract.data import Data
from openbb_charting.charts.price_performance import price_performance
from openbb_charting.core.openbb_figure import OpenBBFigure
_COLS = (
"one_day",
"one_week",
"one_month",
"three_month",
"six_month",
"ytd",
"one_year",
"two_year",
"three_year",
"four_year",
"five_year",
)
def _perf_row(symbol: str, sign: float) -> dict:
"""Build a single price-performance record for ``symbol``."""
values = {col: round(sign * (i + 1) / 100, 4) for i, col in enumerate(_COLS)}
return {"symbol": symbol, **values}
@pytest.fixture
def performance_data() -> list[Data]:
"""Two-symbol price-performance records as ``Data`` models."""
rows = [_perf_row("AAA", 1.0), _perf_row("BBB", -1.0)]
return [Data.model_validate(row) for row in rows]
class TestPricePerformance:
"""Tests for the happy paths of ``price_performance``."""
def test_vertical_orientation(self, performance_data):
"""It draws a vertical bar chart and returns plotly JSON."""
fig, content = price_performance(obbject_item=performance_data)
assert isinstance(fig, OpenBBFigure)
assert isinstance(content, dict)
assert "data" in content
def test_horizontal_orientation(self, performance_data):
"""It swaps the axis titles for horizontal orientation."""
fig, _ = price_performance(obbject_item=performance_data, orientation="h")
assert isinstance(fig, OpenBBFigure)
def test_dataframe_input(self, performance_data):
"""It accepts a pre-built DataFrame via the ``data`` kwarg."""
df = pd.DataFrame([_perf_row("AAA", 1.0), _perf_row("BBB", -1.0)])
fig, _ = price_performance(data=df)
assert isinstance(fig, OpenBBFigure)
def test_list_input_via_data_kwarg(self, performance_data):
"""It accepts a list of ``Data`` via the ``data`` kwarg."""
fig, _ = price_performance(data=performance_data)
assert isinstance(fig, OpenBBFigure)
def test_custom_title_and_layout(self, performance_data):
"""It honors a custom title and extra layout kwargs."""
fig, _ = price_performance(
obbject_item=performance_data,
title="My Performance",
layout_kwargs={"showlegend": False},
)
assert fig.layout.title.text == "My Performance"
assert fig.layout.showlegend is False
def test_limit_trims_rows(self, performance_data):
"""It limits the number of plotted periods when ``limit`` is given."""
fig, _ = price_performance(obbject_item=performance_data, limit=3)
assert isinstance(fig, OpenBBFigure)
def test_drops_duplicate_rows(self):
"""It de-duplicates identical rows before plotting."""
rows = [_perf_row("AAA", 1.0), _perf_row("AAA", 1.0)]
data = [Data.model_validate(row) for row in rows]
fig, _ = price_performance(data=data)
assert isinstance(fig, OpenBBFigure)
def test_none_value_cell(self):
"""It tolerates a ``None`` value in a performance column."""
row = _perf_row("AAA", 1.0)
row["one_day"] = None
df = pd.DataFrame([row, _perf_row("BBB", 1.0)])
fig, _ = price_performance(data=df)
assert isinstance(fig, OpenBBFigure)
class TestPricePerformanceErrors:
"""Tests for the error branches of ``price_performance``."""
def test_empty_data_raises(self):
"""It raises when the input frame has no rows."""
with pytest.raises(ValueError, match="No data was found"):
price_performance(data=pd.DataFrame())
def test_no_matching_columns_raises(self):
"""It raises when none of the expected performance columns exist."""
df = pd.DataFrame({"symbol": ["AAA"], "unrelated": [1.0]})
with pytest.raises(ValueError, match="No columns matching"):
price_performance(data=df)

View File

@@ -0,0 +1,152 @@
"""Tests for ``openbb_charting.query_params``."""
from __future__ import annotations
import pytest
from openbb_charting.query_params import (
ChartParams,
ChartQueryParams,
EquityPriceHistoricalChartQueryParams,
IndicatorsParams,
MAIndicatorsQueryParams,
SMAIndicatorsQueryParams,
_get_type_name,
)
class _OriginWithName:
"""A stand-in generic origin exposing ``_name`` but no ``__name__``."""
class _NoOriginNoName:
"""A stand-in type hint exposing nothing recognizable."""
class TestGetTypeName:
"""Tests for the ``_get_type_name`` type-hint formatter."""
def test_parametrized_origin_with_dunder_name(self):
"""It formats a generic whose origin exposes ``__name__``."""
assert _get_type_name(list[int]) == "list[int]"
def test_parametrized_origin_with_underscore_name(self):
"""It formats a generic whose origin only exposes ``_name``."""
origin = _OriginWithName()
origin._name = "FakeGeneric"
class _Hint:
pass
hint = _Hint()
hint.__origin__ = origin
hint.__args__ = (int,)
assert _get_type_name(hint) == "FakeGeneric[int]"
def test_plain_string_hint(self):
"""It returns a string hint unchanged."""
assert _get_type_name("close") == "close"
def test_type_with_dunder_name(self):
"""It returns the ``__name__`` of a concrete type."""
assert _get_type_name(int) == "int"
def test_object_with_only_underscore_name(self):
"""It returns ``_name`` for an object lacking ``__name__``."""
obj = _NoOriginNoName()
obj._name = "LonelyName"
assert _get_type_name(obj) == "LonelyName"
def test_object_with_nothing_recognizable(self):
"""It falls back to ``str(t)`` for an unrecognized hint."""
obj = _NoOriginNoName()
result = _get_type_name(obj)
assert "_NoOriginNoName object" in result
class TestChartQueryParams:
"""Tests for the ``ChartQueryParams`` base model and subclasses."""
def test_base_default_data_is_none(self):
"""The base ``data`` field defaults to ``None``."""
params = ChartQueryParams()
assert params.data is None
def test_repr_lists_parameters(self):
"""The ``__repr__`` renders the model name and a Parameters section."""
params = EquityPriceHistoricalChartQueryParams()
text = repr(params)
assert "EquityPriceHistoricalChartQueryParams" in text
assert "Parameters" in text
assert "title" in text
def test_doc_set_to_repr(self):
"""The instance ``__doc__`` is populated from ``__repr__``."""
params = EquityPriceHistoricalChartQueryParams()
assert params.__doc__ == repr(params)
def test_subclass_field_defaults(self):
"""A populated subclass exposes its declared field defaults."""
params = EquityPriceHistoricalChartQueryParams()
assert params.candles is True
assert params.volume is True
assert params.multi_symbol is False
def test_ma_query_params_defaults(self):
"""The moving-average query params expose their defaults."""
params = MAIndicatorsQueryParams()
assert params.length == 50
assert params.offset == 0
class TestChartParams:
"""Tests for the ``ChartParams`` registry."""
def test_known_route_maps_to_class(self):
"""A registered route maps to its query-params class."""
assert (
ChartParams.equity_price_historical is EquityPriceHistoricalChartQueryParams
)
def test_registry_supports_hasattr_lookup(self):
"""Routes are discoverable via ``hasattr`` and missing ones are not."""
assert hasattr(ChartParams, "economy_fred_series")
assert not hasattr(ChartParams, "does_not_exist")
class TestIndicatorsParams:
"""Tests for the ``IndicatorsParams`` aggregate model."""
def test_default_indicators_present(self):
"""All known indicator keys are present in the default model."""
keys = list(IndicatorsParams().model_dump().keys())
assert "sma" in keys
assert "macd" in keys
assert "stoch" in keys
def test_repr_renders_descriptions(self):
"""The ``__repr__`` renders indicator descriptions as a block."""
text = repr(IndicatorsParams())
assert isinstance(text, str)
assert text.startswith("\n")
assert len(text) > 0
def test_nested_default_is_typed_model(self):
"""A nested default is the typed indicator-params model."""
params = IndicatorsParams()
assert isinstance(params.sma, SMAIndicatorsQueryParams)
def test_invalid_indicator_key_rejected(self):
"""An unknown indicator key fails model validation."""
with pytest.raises(ValueError, match="not a valid indicator"):
IndicatorsParams.validate_model({"not_an_indicator": {}})
def test_invalid_indicator_via_constructor(self):
"""Constructing with an unknown indicator raises a validation error."""
with pytest.raises(Exception, match="not a valid indicator"):
IndicatorsParams(bogus={"length": 1})
def test_valid_indicator_via_constructor(self):
"""Constructing with a valid indicator key succeeds."""
params = IndicatorsParams(sma=SMAIndicatorsQueryParams(length=10))
assert params.sma.length == 10

View File

@@ -0,0 +1,113 @@
"""Tests for ``openbb_charting.charts.relative_rotation``."""
from __future__ import annotations
import datetime
import numpy as np
import pandas as pd
import pytest
from plotly.graph_objects import Figure
from openbb_charting.charts.relative_rotation import (
create_rrg_with_tails,
create_rrg_without_tails,
)
def _rrg_frame(seed: int, columns: list[str], periods: int = 90) -> pd.DataFrame:
"""Build a deterministic RS-ratio/momentum frame with a ``date`` index."""
rng = np.random.default_rng(seed)
idx = [stamp.date() for stamp in pd.date_range("2023-01-01", periods=periods)]
data = {col: 100 + rng.normal(0, 2, periods) for col in columns}
frame = pd.DataFrame(data, index=idx)
frame.index.name = "date"
return frame
@pytest.fixture
def ratios_momentum() -> tuple[pd.DataFrame, pd.DataFrame]:
"""A pair of RS-ratio and RS-momentum frames sharing the same columns."""
columns = ["AAA", "BBBBBBBB", "C-D"]
return _rrg_frame(1, columns), _rrg_frame(2, columns)
class TestCreateRrgWithTails:
"""Tests for ``create_rrg_with_tails``."""
def test_daily_tails(self, ratios_momentum):
"""It builds daily tails without resampling."""
ratios, momentum = ratios_momentum
fig = create_rrg_with_tails(
ratios.copy(), momentum.copy(), "price", "^GSPC", 16, "day"
)
assert isinstance(fig, Figure)
assert len(fig.frames) == 16
def test_weekly_tails(self, ratios_momentum):
"""It resamples to weekly tails."""
ratios, momentum = ratios_momentum
fig = create_rrg_with_tails(
ratios.copy(), momentum.copy(), "price", "^GSPC", 16, "week"
)
assert isinstance(fig, Figure)
assert fig.layout.title.text is not None
def test_monthly_tails(self, ratios_momentum):
"""It resamples to monthly tails."""
ratios, momentum = ratios_momentum
fig = create_rrg_with_tails(
ratios.copy(), momentum.copy(), "price", "^GSPC", 16, "month"
)
assert isinstance(fig, Figure)
def test_short_symbol_names(self):
"""It uses the larger text font for short symbol names."""
columns = ["A", "BB", "CC"]
ratios = _rrg_frame(3, columns)
momentum = _rrg_frame(4, columns)
fig = create_rrg_with_tails(ratios, momentum, "price", "^GSPC", 10, "day")
assert isinstance(fig, Figure)
class TestCreateRrgWithoutTails:
"""Tests for ``create_rrg_without_tails``."""
def test_default_last_date(self, ratios_momentum):
"""It defaults to the last available date when none is supplied."""
ratios, momentum = ratios_momentum
fig = create_rrg_without_tails(
ratios.copy(), momentum.copy(), "^GSPC", "price", None
)
assert isinstance(fig, Figure)
assert len(fig.data) == 3
def test_specific_date_found(self, ratios_momentum):
"""It targets a specific date present in the index."""
ratios, momentum = ratios_momentum
target = str(ratios.index[-5])
fig = create_rrg_without_tails(
ratios.copy(), momentum.copy(), "^GSPC", "price", target
)
assert isinstance(fig, Figure)
def test_date_not_found_warns(self, ratios_momentum):
"""It warns and falls back to the last date for an absent date."""
ratios, momentum = ratios_momentum
with pytest.warns(UserWarning, match="not found in data"):
fig = create_rrg_without_tails(
ratios.copy(),
momentum.copy(),
"^GSPC",
"price",
datetime.date(1999, 1, 1),
)
assert isinstance(fig, Figure)
def test_short_symbol_names(self):
"""It uses the larger text font for short symbol names."""
columns = ["A", "BB", "CC"]
ratios = _rrg_frame(5, columns)
momentum = _rrg_frame(6, columns)
fig = create_rrg_without_tails(ratios, momentum, "^GSPC", "price", None)
assert isinstance(fig, Figure)

View File

@@ -0,0 +1,30 @@
"""Tests for ``openbb_charting.core.to_chart``."""
from __future__ import annotations
import pytest
from openbb_charting.core.to_chart import to_chart
class TestToChart:
"""Tests for the ``to_chart`` module-level wrapper."""
def test_success_returns_figure_and_content(self, ohlcv_df):
"""It returns the figure and a plotly JSON dict for valid OHLCV data."""
fig, content = to_chart(ohlcv_df, symbol="AAA")
assert fig is not None
assert isinstance(content, dict)
assert "data" in content
assert "layout" in content
def test_success_without_volume_or_candles(self, ohlcv_df):
"""It honors the volume and candles flags on the success path."""
fig, content = to_chart(ohlcv_df, candles=False, volume=False)
assert fig is not None
assert isinstance(content, dict)
def test_invalid_data_raises_wrapped_exception(self):
"""It re-raises a wrapped Exception when conversion fails."""
with pytest.raises(Exception, match="Failed to convert results to chart"):
to_chart("not a valid time series")

4234
openbb_platform/obbject_extensions/charting/uv.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff