From 32a2870c70e3162ba841204f57fc74f654042f8d Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:43:14 -0700 Subject: [PATCH] [V5] Fix a Number of V5 Bugs (#7707) * fix a number of v5 bugs * fix lint error * more lint * fixes for quantitative, technical, charting, famafrench for version bumps --------- Co-authored-by: deeleeramone <> --- build/docker/platform.dockerfile | 47 ---- build/docker/platformAPI.Dockerfile | 10 - build/pypi/openbb_platform/PUBLISH.md | 60 ----- build/pypi/openbb_platform/nightly.py | 86 ------- build/pypi/openbb_platform/publish.py | 239 ------------------ cli/openbb_cli/cli.py | 20 +- cli/openbb_cli/codegen/package_gen.py | 6 +- cli/openbb_cli/config/completer.py | 6 +- .../controllers/base_platform_controller.py | 8 +- cli/openbb_cli/models/settings.py | 12 +- cli/tests/test_cli.py | 97 +++++++ cli/tests/test_config_completer.py | 16 ++ cli/tests/test_config_loader.py | 78 ++++-- ...st_controllers_base_platform_controller.py | 29 +++ cli/tests/test_models_settings.py | 32 +++ openbb_platform/Dockerfile | 43 ++++ openbb_platform/Dockerfile.dockerignore | 42 +++ openbb_platform/core/openbb/__init__.py | 8 + .../core/openbb_core/app/command_runner.py | 2 +- .../core/openbb_core/app/model/preferences.py | 12 +- .../core/openbb_core/app/static/container.py | 6 +- .../package_builder/method_definition.py | 70 ++--- .../openbb_core/provider/query_executor.py | 2 +- .../openbb_core/provider/utils/helpers.py | 8 +- openbb_platform/core/pyproject.toml | 2 +- .../tests/app/model/test_user_settings.py | 8 +- .../test_method_definition_helpers.py | 19 ++ .../core/tests/app/static/test_container.py | 18 ++ .../core/tests/app/test_command_runner.py | 22 ++ .../core/tests/provider/utils/test_helpers.py | 85 +++++++ openbb_platform/core/uv.lock | 2 +- openbb_platform/docker-compose.yml | 31 +++ .../openbb_econometrics/diagnostics.py | 12 +- .../openbb_econometrics/time_series.py | 41 +-- .../econometrics/openbb_econometrics/utils.py | 5 +- .../extensions/econometrics/pyproject.toml | 2 +- .../extensions/econometrics/uv.lock | 99 +++++--- .../openbb_mcp_server/app/config.py | 18 +- .../mcp_server/tests/app/test_config.py | 25 ++ .../openbb_platform_api/app/app.py | 4 + .../openbb_platform_api/app/args.py | 5 +- .../openbb_platform_api/app/config.py | 15 +- .../platform_api/tests/app/test_app_routes.py | 24 ++ .../platform_api/tests/app/test_args.py | 15 ++ .../platform_api/tests/app/test_config.py | 33 +++ .../extensions/quantitative/README.md | 58 +++-- .../integration/test_quantitative_api.py | 10 +- .../integration/test_quantitative_python.py | 9 +- .../openbb_quantitative/_stats_helpers.py | 12 +- .../openbb_quantitative/attribution.py | 7 +- .../openbb_quantitative/factors.py | 7 +- .../openbb_quantitative/metrics.py | 60 +++-- .../openbb_quantitative/performance.py | 109 ++++---- .../openbb_quantitative/risk_decomposition.py | 7 +- .../openbb_quantitative/rolling.py | 38 +-- .../quantitative/openbb_quantitative/stats.py | 4 +- .../extensions/quantitative/pyproject.toml | 2 +- .../quantitative/tests/test_metrics.py | 115 ++++++++- .../quantitative/tests/test_performance.py | 144 +++++++++-- .../quantitative/tests/test_rolling.py | 49 ++-- .../quantitative/tests/test_stats_helpers.py | 13 +- .../extensions/quantitative/uv.lock | 187 +++++++++----- .../openbb_technical/indicators/statistics.py | 36 ++- .../openbb_technical/technical_views.py | 89 +++---- .../extensions/technical/pyproject.toml | 4 +- .../technical/tests/test_technical_views.py | 43 ++++ openbb_platform/extensions/technical/uv.lock | 174 +++++++++---- .../openbb_charting/core/openbb_figure.py | 6 +- .../charting/pyproject.toml | 3 +- .../charting/tests/test_openbb_figure.py | 137 +--------- .../obbject_extensions/charting/uv.lock | 177 +++++++++---- openbb_platform/openbb.docker.toml | 8 + .../models/country_portfolio_returns.py | 86 ++++--- .../openbb_famafrench/models/factors.py | 50 +++- .../models/international_index_returns.py | 86 ++++--- .../models/regional_portfolio_returns.py | 32 +-- .../models/us_portfolio_returns.py | 32 +-- .../openbb_famafrench/utils/constants.py | 2 + .../openbb_famafrench/utils/missing_values.py | 41 +++ .../famafrench/tests/test_missing_values.py | 47 ++++ .../providers/famafrench/tests/test_models.py | 148 +++++++---- 81 files changed, 2147 insertions(+), 1309 deletions(-) delete mode 100644 build/docker/platform.dockerfile delete mode 100644 build/docker/platformAPI.Dockerfile delete mode 100644 build/pypi/openbb_platform/PUBLISH.md delete mode 100644 build/pypi/openbb_platform/nightly.py delete mode 100644 build/pypi/openbb_platform/publish.py create mode 100644 openbb_platform/Dockerfile create mode 100644 openbb_platform/Dockerfile.dockerignore create mode 100644 openbb_platform/docker-compose.yml create mode 100644 openbb_platform/openbb.docker.toml create mode 100644 openbb_platform/providers/famafrench/openbb_famafrench/utils/missing_values.py create mode 100644 openbb_platform/providers/famafrench/tests/test_missing_values.py diff --git a/build/docker/platform.dockerfile b/build/docker/platform.dockerfile deleted file mode 100644 index 3bbfb2ae436..00000000000 --- a/build/docker/platform.dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# ---- Base Python ---- - FROM python:3.11-slim-bullseye AS base - - # set work directory - WORKDIR /openbb - - # set environment variables - ENV PYTHONDONTWRITEBYTECODE 1 - ENV PYTHONUNBUFFERED 1 - - # install dependencies - RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential openssh-client curl \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - - # install toml and poetry - RUN pip install toml poetry - - # ---- Copy Files/Build ---- - FROM base AS builder - - WORKDIR /openbb - - # install Rust - RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ - && apt-get update \ - && apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - - # add Rust to PATH - ENV PATH="/root/.cargo/bin:${PATH}" - - COPY ./openbb_platform ./openbb_platform - - # Install the Platform - RUN pip install /openbb/openbb_platform[all] - RUN pip install openbb-devtools - - # ---- Copy Files ---- - FROM base - - COPY --from=builder /usr/local /usr/local - - # Launch the API - CMD ["uvicorn", "openbb_core.api.rest_api:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/build/docker/platformAPI.Dockerfile b/build/docker/platformAPI.Dockerfile deleted file mode 100644 index 956a08e2049..00000000000 --- a/build/docker/platformAPI.Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.10-slim-bookworm - -WORKDIR /app - -RUN pip install "openbb[all]" -RUN pip install openbb-platform-api - -EXPOSE 6900 - -ENTRYPOINT ["openbb-api", "--host", "0.0.0.0"] diff --git a/build/pypi/openbb_platform/PUBLISH.md b/build/pypi/openbb_platform/PUBLISH.md deleted file mode 100644 index ed4fc310814..00000000000 --- a/build/pypi/openbb_platform/PUBLISH.md +++ /dev/null @@ -1,60 +0,0 @@ -# Publishing to PyPI - -## Pre-release - -> [!WARNING] -> The `release` branch is solely for the purpose of publishing the package(s)! Any last minute changes should be made in appropriate PRs and merged to the `develop` branch. Once the `release` branch is created, the `develop` branch should be frozen for further commits until this process finishes. - -> A `release` branch for a particular package should only cater to the changes for that package directory. For e.g. the `release/openbb-core-2.0.0` branch should only contain changes for the `openbb-core` package i.e. in the `openbb_platform/core` directory. - -0. Merge `main` into `develop` and solve any conflict. You can do this by checking out `develop`, creating a branch `feature/merge-main-into-develop` and running `git merge main`. Merge this into `develop` if there are changes to commit. -1. Open a PR with the changes to be published in the format `release/` (for e.g. `release/4.0.0` ). For a particular package use the format `release/-` (for e.g. `release/openbb-core-1.0.1`). -2. Bump `openbb` package version in `openbb_platform/pyproject.toml`. - -> [!WARNING] -> The version must be incremented before running the tests in 4. and 5., since there are tests that rely of this to check for deprecated endpoints. Pay attention to commands that should be deprecated in the version you will publish. - -3. Ensure all the CI workflows pass. -4. Ensure all unit tests pass: `pytest openbb_platform -m "not integration"` -5. Ensure all integration tests pass: `pytest openbb_platform -m integration` - -## Release - -> Ensure you have the appropriate credentials and permissions to publish to PyPI. - -1. Run the following commands for publishing the packages to PyPI: - - > [!TIP] - > Consider using the `--dry-run` flag to check if everything is correct before publishing. - - > For a single package release (e.g. patching `openbb-core`), the following steps are not required since the package can be published manually. - - 1. To publish `openbb-core` run: `python build/pypi/openbb_platform/publish.py --core` - 2. To publish **ALL** extensions run: `python build/pypi/openbb_platform/publish.py --extensions` - 3. To publish `openbb` (the main package) run: `python build/pypi/openbb_platform/publish.py --openbb` - > [!TIP] - > Note that, in order for packages to pick up the latest versions of dependencies, it is advised to clear the local cache of the dependencies: we can do that with `pip cache purge` and `poetry cache clear pypi --all`. Sometimes there might be some delay in the PyPI API, so it might be necessary to wait a few minutes for pip to pick the latest versions. - -2. Publish the CLI - - 2.1. Bump `openbb` dependency on `cli/pyproject.toml` to the latest version - - 2.2. Run `poetry publish --build` from `cli` - -3. Regenerate assets for external use by running `python assets/scripts/generate_extension_data.py`. -4. Merge `release/-` to the `main` branch. -5. Run `Deploy to GitHub Pages` action in [openbb-docs](https://github.com/OpenBB-finance/openbb-docs/actions) to update the documentation website. Go to [docs.openbb.co](https://docs.openbb.co) to see the changes. - -## Post-release - -1. Install the packages on Google Colaboratory via PyPi and test to check if everything is working as expected. -2. Install the packages in a new environment locally via PyPi and test to check if everything is working as expected. -3. Open a new PR with the `release/-` branch pointing to the `develop` branch. -4. Merge the `release/-` branch to the `develop` branch. -5. If any bugs are encountered, create a new branch - `hotfix` for `main` and `bugfix` for `develop` and merge them accordingly. - -### Generate the changelog - -1. Run the changelog automation by using the "release_drafter" GitHub action and input the number of the last release PR (not this one!). -2. Edit and make the changelog live on the repository. -3. Paste it in the platform-release-changelog Slack channel. diff --git a/build/pypi/openbb_platform/nightly.py b/build/pypi/openbb_platform/nightly.py deleted file mode 100644 index 8d0954a86d3..00000000000 --- a/build/pypi/openbb_platform/nightly.py +++ /dev/null @@ -1,86 +0,0 @@ -import subprocess -import sys -from pathlib import Path -from typing import Any - -import toml - -PLATFORM_PATH = Path(__file__).parent.parent.parent.parent.resolve() / "openbb_platform" -PYPROJECT = PLATFORM_PATH / "pyproject.toml" - -SUB_PACKAGES = {} -DEPENDENCIES: dict[str, Any] = {} -PLUGINS: dict[str, dict] = { - "openbb_core_extension": {}, - "openbb_provider_extension": {}, - "openbb_obbject_extension": {}, -} -CMD = [sys.executable, "-m", "poetry", "build"] - - -PYPROJECT_TOML = toml.load(PYPROJECT) -POETRY_DICT: dict[str, dict] = PYPROJECT_TOML["tool"]["poetry"] -POETRY_DICT.pop("extras", None) - - -original_pyproject = PYPROJECT.read_text() - - -def gather_metadata(sub_path: str): - """Gather metadata from the pyproject.toml files. - - Parameters - ---------- - sub_path : str - The path to the sub packages. - """ - for path in PLATFORM_PATH.rglob(f"{sub_path}/**/pyproject.toml"): - pyproject_toml = toml.load(path) - poetry_dict: dict[str, dict] = pyproject_toml["tool"]["poetry"] - package_name = poetry_dict["packages"][0]["include"] - - for extension in list(PLUGINS.keys()): - PLUGINS[extension].update(poetry_dict.get("plugins", {}).get(extension, {})) - - DEPENDENCIES.update(pyproject_toml["tool"]["poetry"]["dependencies"]) - SUB_PACKAGES[package_name] = path.relative_to(PLATFORM_PATH).parent.as_posix() - - -def build(): - """Build the Platform package.""" - for sub_path in ["core", "providers", "extensions", "obbject_extensions"]: - gather_metadata(sub_path) - - # need to pop these from the dependencies - DEPENDENCIES.pop("openbb-core", None) - - # add the sub packages - for package_name, path in SUB_PACKAGES.items(): - POETRY_DICT["packages"].append({"include": package_name, "from": f"./{path}"}) - - # add the plugins extensions - for extension, plugins in PLUGINS.items(): - POETRY_DICT.setdefault("plugins", {}).setdefault(extension, {}).update(plugins) - - # update the dependencies and platform poetry dict - POETRY_DICT["dependencies"] = DEPENDENCIES - PYPROJECT_TOML["tool"]["poetry"] = POETRY_DICT - - temp_pyproject = toml.dumps(PYPROJECT_TOML) - - try: - with open(PYPROJECT, "w", encoding="utf-8", newline="\n") as f: - f.write(temp_pyproject) - - subprocess.run(CMD, cwd=PLATFORM_PATH, check=True) # noqa: S603,PLW1510 - except (Exception, KeyboardInterrupt) as e: - print(e) # noqa: T201 - print("Restoring pyproject.toml") # noqa: T201 - - # we restore the original pyproject.toml - with open(PYPROJECT, "w", encoding="utf-8", newline="\n") as f: - f.write(original_pyproject) - - -if __name__ == "__main__": - build() diff --git a/build/pypi/openbb_platform/publish.py b/build/pypi/openbb_platform/publish.py deleted file mode 100644 index 7e1df02faea..00000000000 --- a/build/pypi/openbb_platform/publish.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Publish the OpenBB Platform to PyPi.""" - -import argparse -import logging -import sys -from functools import partial -from pathlib import Path -from subprocess import PIPE, run -from typing import Literal - -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) -console = logging.StreamHandler() -logger.addHandler(console) -formatter = logging.Formatter("[%(levelname)s] %(message)s") -console.setFormatter(formatter) - -DIR_PLATFORM = Path(__file__).parent.parent.parent.parent.resolve() / "openbb_platform" -DIR_CORE = ["core"] -DIR_EXTENSIONS = ["extensions", "providers", "obbject_extensions"] - - -def parse_args(): - parser = argparse.ArgumentParser( - description="Publish OpenBB Platform to PyPi with optional core or extensions flag." - ) - parser.add_argument( - "-c", "--core", action="store_true", help="Publish core packages.", dest="core" - ) - parser.add_argument( - "-e", - "--extensions", - action="store_true", - help="Publish extension packages, such as openbb-equity or openbb-fmp.", - dest="extensions", - ) - parser.add_argument( - "-o", - "--openbb", - action="store_true", - help="Publish openbb package.", - dest="openbb", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Run the commands without actually publishing.", - default=False, - dest="dry_run", - ) - parser.add_argument( - "-v", - "--verbose", - action="store_true", - help="Run the tool in verbose mode.", - default=False, - dest="verbose", - ) - parser.add_argument( - "-y", - "--yes", - action="store_true", - help="Answer input questions.", - default=False, - dest="yes", - ) - parser.add_argument( - "--semver", - help="Semantic version.", - default="patch", - choices=["patch", "minor", "major", "none"], - dest="semver", - ) - return parser.parse_args() - - -def publish( - dry_run: bool = False, - core: bool = False, - extensions: bool = False, - openbb: bool = False, - verbose: bool = False, - semver: Literal["patch", "minor", "major", "none"] = "patch", -): - """Publish the Platform to PyPi with optional core or extensions.""" - package_directories = [] - if core: - package_directories.extend(DIR_CORE) - if extensions: - package_directories.extend(DIR_EXTENSIONS) - - partial_run = partial( - run, - check=True, - stdout=None if verbose else PIPE, - stderr=None if verbose else PIPE, - ) - - for _dir in package_directories: - is_extension = _dir in DIR_EXTENSIONS - paths = [ - p - for p in sorted(DIR_PLATFORM.rglob(f"{_dir}/**/pyproject.toml")) - if "devtools" not in str(p) - ] - total = len(paths) - logger.info("~~~ /%s ~~~", _dir) - for i, path in enumerate(paths): - logger.info( - "๐Ÿš€ (%s/%s) Publishing openbb-%s...", - i + 1, - total, - path.parent.stem.replace("_", "-"), - ) - try: - # Update openbb-core to latest in each pyproject.toml - if is_extension: - partial_run( - [ - sys.executable, - "-m", - "poetry", - "add", - "openbb-core=latest", - "--lock", - ], - cwd=path.parent, - ) - # Bump pyproject.toml version - if semver != "none": - partial_run( - [sys.executable, "-m", "poetry", "version", semver], - cwd=path.parent, - ) - # Publish (if not dry running) - if not dry_run: - partial_run( - [ - sys.executable, - "-m", - "poetry", - "publish", - "--build", - ], - cwd=path.parent, - ) - logger.info("โœ… Success") - except Exception as e: - logger.error("โŒ Failed to publish %s:\n\n%s", path.parent.stem, e) - - if openbb: - STEPS = 7 - logger.info("~~~ /openbb ~~~") - logger.info("๐Ÿงฉ (1/%s) Installing poetry-plugin-up...", STEPS) - partial_run( - ["pip", "install", "poetry-plugin-up"], - cwd=DIR_PLATFORM, - ) - logger.info("โซ (2/%s) Updating openbb pyproject.toml...", STEPS) - partial_run( - [sys.executable, "-m", "poetry", "up", "--latest"], - cwd=DIR_PLATFORM, - ) - logger.info("๐Ÿ”’ (3/%s) Writing openbb poetry.lock...", STEPS) - partial_run( - [sys.executable, "-m", "poetry", "up", "--latest"], - cwd=DIR_PLATFORM, - ) - logger.info("๐Ÿ“ (4/%s) Installing openbb from /%s...", STEPS, DIR_PLATFORM.stem) - partial_run( - ["pip", "install", "-U", "--editable", "."], - cwd=DIR_PLATFORM, - ) - logger.info("๐Ÿšง (5/%s) Building python interface...", STEPS) - result = run( - [sys.executable, "-c", "import openbb; openbb.build()"], # noqa: S603 - cwd=DIR_PLATFORM, - check=True, - capture_output=True, - text=True, - ) - if verbose: - logger.info("Captured result -> %s", result) - if result.stderr: - logger.error("โŒ stderr is not empty!") - raise Exception(result.stderr) - logger.info("๐Ÿงช (6/%s) Unit testing...", STEPS) - partial_run( - ["pytest", "tests", "-m", "not integration"], - cwd=DIR_PLATFORM, - ) - logger.info("๐Ÿšญ (7/%s) Smoke testing...", STEPS) - # TODO: Improve smoke test coverage here - result = run( # noqa: S603 - [ # noqa: S603 - sys.executable, - "-c", - "from openbb import obb; obb.equity.price.historical('AAPL', provider='yfinance')", - ], - cwd=DIR_PLATFORM, - check=True, - capture_output=True, - text=True, - ) - if verbose: - logger.info("Captured result -> %s", result) - if result.stderr: - logger.error("โŒ stderr is not empty!") - raise Exception(result.stderr) - logger.info("๐Ÿ‘ Great success! ๐Ÿ‘") - logger.info( - "Confirm any files changed and run `poetry publish --build` from /openbb_platform" - ) - - -if __name__ == "__main__": - msg = ( - "\nYou are about to publish a new version of OpenBB Platform to PyPI." - + "\n\nPlease ensure you've read the PUBLISH.md file and double check with " - + "`poetry config --list` if you're publishing to PyPI or TestPyPI." - ) - args = parse_args() - if not args.dry_run: - msg += "\n\n๐Ÿ›‘ You are NOT using the --dry-run flag!" - res = "y" if args.yes else input(f"{msg}\n\nDo you want to continue? [y/N] ") - print("") # noqa: T201 - if res.lower() == "y": - logger.info("Process started. Press Ctrl+C to abort.") - try: - publish( - dry_run=args.dry_run, - core=args.core, - extensions=args.extensions, - openbb=args.openbb, - verbose=args.verbose, - semver=args.semver, - ) - except KeyboardInterrupt: - sys.exit(1) diff --git a/cli/openbb_cli/cli.py b/cli/openbb_cli/cli.py index 24934464ff7..0e1ad182466 100644 --- a/cli/openbb_cli/cli.py +++ b/cli/openbb_cli/cli.py @@ -575,6 +575,7 @@ def _generate_extension( provider_name=derived_provider, project_name=project_name, package_name=package_name, + router_name=router_name, ) paths = package_set.write() for pkg, path in zip(package_set.packages, paths, strict=True): @@ -660,6 +661,18 @@ _CONFIG_SCALAR_KEYS = ( "batch_concurrency", ) +# Env var (if any) that already wins over TOML for each key, per the documented +# precedence "defaults -> ... -> TOML -> .env files -> OPENBB_* env vars -> CLI +# flags". Some of these flags bake a non-empty literal into their argparse +# default (e.g. --output, --batch-concurrency), so `parser.get_default(key)` +# can't be used to detect "unset" โ€” it's checked against the env var directly. +_CONFIG_SCALAR_ENV_VARS = { + "server": "OPENBB_SERVER_URL", + "header_file": "OPENBB_HEADER_FILE", + "query_param_file": "OPENBB_QUERY_PARAM_FILE", + "batch_concurrency": "OPENBB_CLI_BATCH_CONCURRENCY", +} + def _peek_flag(argv: list[str], flag: str) -> str | None: """Pull ``--flag VALUE`` (or ``--flag=VALUE``) out of argv without parsing the rest.""" @@ -680,9 +693,10 @@ def _apply_config_defaults( value = config.get(key) if value is None: continue - existing = parser.get_default(key) - if existing in (None, "", []): - overrides[key] = value + env_var = _CONFIG_SCALAR_ENV_VARS.get(key) + if env_var and os.environ.get(env_var): + continue + overrides[key] = value if overrides: parser.set_defaults(**overrides) diff --git a/cli/openbb_cli/codegen/package_gen.py b/cli/openbb_cli/codegen/package_gen.py index efe58eef5fc..e6b8fc5cb49 100644 --- a/cli/openbb_cli/codegen/package_gen.py +++ b/cli/openbb_cli/codegen/package_gen.py @@ -382,6 +382,7 @@ def generate_packages( provider_name: str | None = None, project_name: str | None = None, package_name: str | None = None, + router_name: str | None = None, description: str | None = None, website: str | None = None, version: str = "0.1.0", @@ -398,6 +399,9 @@ def generate_packages( Project / package slug. project_name, package_name, description, website : str, optional Project-level overrides. + router_name : str, optional + Overrides the generated top-level router's module/entry-point name. + Defaults to ``provider_name``. version : str Initial version string for ``pyproject.toml``. @@ -505,7 +509,7 @@ def generate_packages( for module_path, function_name in routers.root_post_imports ] - root_namespace = _slugify(provider_name or project_slug) + root_namespace = _slugify(router_name or provider_name or project_slug) sub_routers = [r for r in routers.routers if r.entry_point_name] for r in sub_routers: r.entry_point_name = None diff --git a/cli/openbb_cli/config/completer.py b/cli/openbb_cli/config/completer.py index b4c36fec6fc..0b89b19f2f2 100644 --- a/cli/openbb_cli/config/completer.py +++ b/cli/openbb_cli/config/completer.py @@ -366,7 +366,11 @@ class CustomFileHistory(FileHistory): def sanitize_input(self, string: str) -> str: """Sanitize sensitive information from the input string by parsing arguments.""" - keywords = ["--password", "--email", "--pat"] + # "-v"/"--value" is how /user/credentials/ sets a provider API key + # (e.g. "fmp_api_key -v sk-..."); masking it here costs seeing a past + # non-secret "-v" setting in history, in exchange for never writing a + # credential to disk in the clear. + keywords = ["--password", "--email", "--pat", "-v", "--value"] string_list = string.split(" ") for kw in keywords: diff --git a/cli/openbb_cli/controllers/base_platform_controller.py b/cli/openbb_cli/controllers/base_platform_controller.py index 3fb70362f22..0120d40f652 100644 --- a/cli/openbb_cli/controllers/base_platform_controller.py +++ b/cli/openbb_cli/controllers/base_platform_controller.py @@ -9,7 +9,11 @@ import pandas as pd from openbb_cli.config.menu_text import MenuText from openbb_cli.controllers.base_controller import BaseController -from openbb_cli.controllers.utils import export_data, print_rich_table +from openbb_cli.controllers.utils import ( + export_data, + extract_dataframe, + print_rich_table, +) from openbb_cli.outputs.figure import is_plotly_figure from openbb_cli.session import Session @@ -270,6 +274,8 @@ class PlatformController(BaseController): self._follow_stream(obbject) elif isinstance(obbject, _OBBject()): + df = extract_dataframe(obbject) + if ( session.max_obbjects_exceeded() and obbject.results diff --git a/cli/openbb_cli/models/settings.py b/cli/openbb_cli/models/settings.py index 9afa284181b..e855e7e02d7 100644 --- a/cli/openbb_cli/models/settings.py +++ b/cli/openbb_cli/models/settings.py @@ -1,5 +1,6 @@ """Settings model.""" +import os from enum import Enum from typing import Any, Literal from zoneinfo import available_timezones @@ -199,9 +200,18 @@ class Settings(BaseModel): @model_validator(mode="before") @classmethod def from_env(cls, values: dict) -> dict: - """Load settings from .env.""" + """Load settings from the persisted dotfile and ``OPENBB_*`` env vars. + + Precedence (lowest to highest): the persisted ``.cli.env`` dotfile, + then ``OPENBB_*`` process env vars โ€” which also carries TOML + ``[settings]`` values promoted into the environment by + ``apply_settings_to_env`` โ€” then explicit constructor values. + """ settings = {} settings.update(dotenv_values(ENV_FILE_SETTINGS)) + settings.update( + {k: v for k, v in os.environ.items() if k.startswith("OPENBB_")} + ) settings.update(values) filtered = {k.replace("OPENBB_", ""): v for k, v in settings.items()} return filtered diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py index 52bbbdaddf8..1c420cd0103 100644 --- a/cli/tests/test_cli.py +++ b/cli/tests/test_cli.py @@ -1060,3 +1060,100 @@ def test_generate_extension_filters_commands_before_codegen(tmp_path, capsys): assert captured["spec_commands"] == ["equity.balance", "equity.price"] out = capsys.readouterr().out assert "filter: 2/3 commands kept" in out + + +def test_generate_extension_passes_router_name_to_codegen(tmp_path): + """``--router-name`` reaches ``generate_packages`` verbatim, distinct + from ``--provider-name`` โ€” it must not be silently dropped on the way + from the CLI argument to codegen. + """ + from openbb_cli.dispatchers.spec import SPEC_VERSION, write_spec + + spec_path = tmp_path / "x.spec" + write_spec( + spec_path, + { + "version": SPEC_VERSION, + "base_url": "http://x", + "api_prefix": "/api/v1", + "commands": {"equity.price": _stub_command_spec()}, + }, + ) + captured: dict = {} + + def fake_generate_packages(spec_doc, **kwargs): # noqa: ARG001 + captured["router_name"] = kwargs.get("router_name") + captured["provider_name"] = kwargs.get("provider_name") + package_set = MagicMock() + package_set.write.return_value = [] + package_set.packages = [] + return package_set + + with patch( + "openbb_cli.codegen.package_gen.generate_packages", + side_effect=fake_generate_packages, + ): + rc = cli._generate_extension( + [(None, str(spec_path))], + str(tmp_path / "out"), + provider_name="myprovider", + project_name=None, + package_name=None, + router_name="custom_router", + ) + assert rc == 0 + assert captured["router_name"] == "custom_router" + assert captured["provider_name"] == "myprovider" + + +def test_apply_config_defaults_applies_output_and_batch_concurrency(): + """TOML ``output``/``batch-concurrency`` reach parsed args when the CLI flag + isn't passed. Both flags bake a non-empty literal into their argparse + default, so a naive "is the existing default falsy" check silently + discards the TOML value for these two keys even though it correctly + applies for the other ``_CONFIG_SCALAR_KEYS``. + """ + from openbb_cli.dispatchers.runtime import build_parser + + config = {"output": "from_toml.spec", "batch_concurrency": 42} + parser = build_parser() + cli._apply_config_defaults(parser, [], config) + args = parser.parse_args(["--generate-spec"]) + assert args.output == "from_toml.spec" + assert args.batch_concurrency == 42 + + +def test_apply_config_defaults_cli_flag_beats_toml(): + """An explicit ``--output`` on the command line still wins over TOML.""" + from openbb_cli.dispatchers.runtime import build_parser + + config = {"output": "from_toml.spec"} + argv = ["--generate-spec", "--output", "from_cli.spec"] + parser = build_parser() + cli._apply_config_defaults(parser, argv, config) + args = parser.parse_args(argv) + assert args.output == "from_cli.spec" + + +def test_apply_config_defaults_env_var_beats_toml(monkeypatch): + """``OPENBB_CLI_BATCH_CONCURRENCY`` still wins over TOML, per the documented + precedence (TOML < env vars < CLI flags).""" + from openbb_cli.dispatchers.runtime import build_parser + + monkeypatch.setenv("OPENBB_CLI_BATCH_CONCURRENCY", "99") + config = {"batch_concurrency": 42} + parser = build_parser() + cli._apply_config_defaults(parser, [], config) + args = parser.parse_args(["--generate-spec"]) + assert args.batch_concurrency == 99 + + +def test_apply_config_defaults_falls_back_to_hardcoded_default(): + """With no TOML, env, or CLI value, the hardcoded argparse default stands.""" + from openbb_cli.dispatchers.runtime import build_parser + + parser = build_parser() + cli._apply_config_defaults(parser, [], {}) + args = parser.parse_args(["--generate-spec"]) + assert args.output == "openbb.spec" + assert args.batch_concurrency == 8 diff --git a/cli/tests/test_config_completer.py b/cli/tests/test_config_completer.py index 4b683975631..2700574f7c4 100644 --- a/cli/tests/test_config_completer.py +++ b/cli/tests/test_config_completer.py @@ -360,6 +360,22 @@ def test_custom_file_history_store_string_writes_sanitized(tmp_path): assert "********" in contents +def test_custom_file_history_sanitizes_credential_value_flag(tmp_path): + """``/user/credentials/`` sets a provider key via `` -v ``; + that value must never reach the on-disk history in the clear. + """ + from openbb_cli.config.completer import CustomFileHistory + + h = CustomFileHistory(filename=str(tmp_path / "hist.txt")) + out = h.sanitize_input("fmp_api_key -v sk-THIS-IS-A-SECRET-KEY-12345") + assert "sk-THIS-IS-A-SECRET-KEY-12345" not in out + assert "********" in out + + out_long = h.sanitize_input("polygon_api_key --value another-secret") + assert "another-secret" not in out_long + assert "********" in out_long + + def test_get_completions_complementary_reverse_processed_pair(): """Complementary pair with the *second* flag already processed โ†’ first appended. diff --git a/cli/tests/test_config_loader.py b/cli/tests/test_config_loader.py index 3a0c97dcd3d..6ce7988e8e4 100644 --- a/cli/tests/test_config_loader.py +++ b/cli/tests/test_config_loader.py @@ -115,15 +115,30 @@ def test_apply_settings_to_env_handles_none_or_empty(): def test_apply_settings_to_env_translates_kebab_keys(monkeypatch): monkeypatch.delenv("OPENBB_ALLOWED_NUMBER_OF_ROWS", raising=False) - applied = loader.apply_settings_to_env({"settings": {"allowed-number-of-rows": 50}}) - assert "OPENBB_ALLOWED_NUMBER_OF_ROWS" in applied - assert os.environ["OPENBB_ALLOWED_NUMBER_OF_ROWS"] == "50" + try: + applied = loader.apply_settings_to_env( + {"settings": {"allowed-number-of-rows": 50}} + ) + assert "OPENBB_ALLOWED_NUMBER_OF_ROWS" in applied + assert os.environ["OPENBB_ALLOWED_NUMBER_OF_ROWS"] == "50" + finally: + # `apply_settings_to_env` writes straight to the real `os.environ`, + # which `monkeypatch.delenv` above never registered for auto-cleanup + # (nothing existed yet to revert). `monkeypatch.delenv` again here + # would be wrong too โ€” once the key exists, it records "50" as the + # value to *restore* on teardown, re-leaking it. A direct pop is the + # only way to guarantee it doesn't leak into later tests that + # construct a real ``Settings()`` and read ``os.environ``. + os.environ.pop("OPENBB_ALLOWED_NUMBER_OF_ROWS", None) def test_apply_settings_to_env_serializes_booleans_capitalized(monkeypatch): monkeypatch.delenv("OPENBB_USE_PROMPT_TOOLKIT", raising=False) - loader.apply_settings_to_env({"settings": {"use-prompt-toolkit": True}}) - assert os.environ["OPENBB_USE_PROMPT_TOOLKIT"] == "True" + try: + loader.apply_settings_to_env({"settings": {"use-prompt-toolkit": True}}) + assert os.environ["OPENBB_USE_PROMPT_TOOLKIT"] == "True" + finally: + os.environ.pop("OPENBB_USE_PROMPT_TOOLKIT", None) def test_apply_settings_to_env_settings_table_does_not_clobber_existing(monkeypatch): @@ -136,14 +151,17 @@ def test_apply_settings_to_env_settings_table_does_not_clobber_existing(monkeypa def test_apply_settings_to_env_top_level_overrides_settings_table(monkeypatch): """Top-level promoted keys (snake_case after normalization) override [settings].""" monkeypatch.delenv("OPENBB_OUTPUT_MODE", raising=False) - # Mimic what `load_config()` produces: top-level keys arrive snake_cased - loader.apply_settings_to_env( - { - "output_mode": "rich", - "settings": {"output-mode": "tsv"}, - } - ) - assert os.environ["OPENBB_OUTPUT_MODE"] == "rich" + try: + # Mimic what `load_config()` produces: top-level keys arrive snake_cased + loader.apply_settings_to_env( + { + "output_mode": "rich", + "settings": {"output-mode": "tsv"}, + } + ) + assert os.environ["OPENBB_OUTPUT_MODE"] == "rich" + finally: + os.environ.pop("OPENBB_OUTPUT_MODE", None) # --- load_env_files --- @@ -154,9 +172,12 @@ def test_load_env_files_loads_explicit_path(tmp_path, monkeypatch): monkeypatch.delenv("OPENBB_FROM_DOTENV", raising=False) # Block discovery of the user-global .env so this test stays hermetic monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path / "no-such-dir") - loaded = loader.load_env_files(str(env)) - assert env in loaded - assert os.environ["OPENBB_FROM_DOTENV"] == "hello" + try: + loaded = loader.load_env_files(str(env)) + assert env in loaded + assert os.environ["OPENBB_FROM_DOTENV"] == "hello" + finally: + os.environ.pop("OPENBB_FROM_DOTENV", None) def test_load_env_files_does_not_override_existing(tmp_path, monkeypatch): @@ -177,8 +198,11 @@ def test_load_env_files_uses_env_var_when_no_explicit(tmp_path, monkeypatch): monkeypatch.delenv("OPENBB_FROM_ENV_VAR", raising=False) monkeypatch.setenv(loader.EXPLICIT_ENV_FILE_ENV, str(env)) monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path / "no-such-dir") - loader.load_env_files() - assert os.environ["OPENBB_FROM_ENV_VAR"] == "yes" + try: + loader.load_env_files() + assert os.environ["OPENBB_FROM_ENV_VAR"] == "yes" + finally: + os.environ.pop("OPENBB_FROM_ENV_VAR", None) # --- render_config_template --- @@ -273,10 +297,13 @@ def test_load_env_files_imports_dotenv_values(tmp_path, monkeypatch): monkeypatch.setattr(loader, "USER_OPENBB_DIR", tmp_path / "no-user") monkeypatch.delenv("OPENBB_LOADER_REAL", raising=False) monkeypatch.delenv("NULL_VALUE", raising=False) - loader.load_env_files(str(env)) - assert os.environ["OPENBB_LOADER_REAL"] == "yes" - # Bare ``NULL_VALUE`` (no equals) parses as None and is skipped, not set - assert "NULL_VALUE" not in os.environ + try: + loader.load_env_files(str(env)) + assert os.environ["OPENBB_LOADER_REAL"] == "yes" + # Bare ``NULL_VALUE`` (no equals) parses as None and is skipped, not set + assert "NULL_VALUE" not in os.environ + finally: + os.environ.pop("OPENBB_LOADER_REAL", None) def test_load_env_files_picks_up_user_global_env(tmp_path, monkeypatch): @@ -291,8 +318,11 @@ def test_load_env_files_picks_up_user_global_env(tmp_path, monkeypatch): monkeypatch.setattr(loader, "USER_OPENBB_DIR", user_dir) monkeypatch.delenv("OPENBB_LOADER_USER_GLOBAL", raising=False) monkeypatch.delenv(loader.EXPLICIT_ENV_FILE_ENV, raising=False) - loader.load_env_files() - assert os.environ["OPENBB_LOADER_USER_GLOBAL"] == "present" + try: + loader.load_env_files() + assert os.environ["OPENBB_LOADER_USER_GLOBAL"] == "present" + finally: + os.environ.pop("OPENBB_LOADER_USER_GLOBAL", None) # --- render_config_template with active values --- diff --git a/cli/tests/test_controllers_base_platform_controller.py b/cli/tests/test_controllers_base_platform_controller.py index 3593aeca5f0..d9d8a45d6a1 100644 --- a/cli/tests/test_controllers_base_platform_controller.py +++ b/cli/tests/test_controllers_base_platform_controller.py @@ -551,6 +551,35 @@ def test_generated_call_chart_export_extracts_fig(mock_session): export_data.assert_called_once() +def test_generated_call_obbject_export_invokes_export_data(mock_session): + """``ns.export`` + an ``OBBject`` result โ†’ ``export_data`` is called. + + The generated method's local ``df`` must be populated from the OBBject's + own results (via ``extract_dataframe``), not left as the empty sentinel + initialized before the OBBject branch โ€” otherwise every OBBject export + falls through to the "No data to export." warning regardless of whether + the command actually returned data. + """ + from openbb_core.app.model.obbject import OBBject + + obbject = OBBject(results=[{"a": 1}, {"a": 2}]) + controller, translator = _make_command_call_test_setup( + mock_session, command_returns=obbject + ) + ns = MagicMock(export=["csv"], register_obbject=False, chart=False, sheet_name=None) + del ns.register_key + controller.parse_known_args_and_warn.return_value = ns + mock_session.max_obbjects_exceeded.return_value = False + with patch( + "openbb_cli.controllers.base_platform_controller.export_data" + ) as export_data: + controller.call_cmd([]) + export_data.assert_called_once() + assert not export_data.call_args.kwargs["df"].empty + msgs = [str(c) for c in mock_session.console.print.call_args_list] + assert not any("No data to export" in m for m in msgs) + + def test_generated_call_export_with_empty_df_warns(mock_session): """When ``ns.export`` is set but the local ``df`` is empty, a yellow warning runs.""" controller, translator = _make_command_call_test_setup( diff --git a/cli/tests/test_models_settings.py b/cli/tests/test_models_settings.py index 4704abc2389..389457a45a2 100644 --- a/cli/tests/test_models_settings.py +++ b/cli/tests/test_models_settings.py @@ -49,6 +49,38 @@ def test_from_env(mock_dotenv_values): assert settings["VERSION"] == "2.0.0" +@patch("openbb_cli.models.settings.dotenv_values", return_value={}) +def test_from_env_reads_openbb_prefixed_os_environ(mock_dotenv_values, monkeypatch): + """A real ``OPENBB_*`` process env var reaches the constructed settings, + not just the persisted ``.cli.env`` dotfile โ€” this is also how TOML + ``[settings]`` values reach ``Settings()``, since ``apply_settings_to_env`` + promotes them into ``os.environ`` as ``OPENBB_*`` before the REPL starts. + """ + monkeypatch.setenv("OPENBB_FLAIR", ":rocket") + settings = Settings() + assert settings.FLAIR == ":rocket" + + +@patch( + "openbb_cli.models.settings.dotenv_values", + return_value={"OPENBB_FLAIR": ":bug"}, +) +def test_from_env_os_environ_overrides_dotfile(mock_dotenv_values, monkeypatch): + """``OPENBB_*`` env vars outrank the persisted dotfile, per the documented + precedence (dotfile < env vars < explicit constructor values).""" + monkeypatch.setenv("OPENBB_FLAIR", ":rocket") + settings = Settings() + assert settings.FLAIR == ":rocket" + + +@patch("openbb_cli.models.settings.dotenv_values", return_value={}) +def test_from_env_explicit_values_override_os_environ(mock_dotenv_values, monkeypatch): + """An explicit constructor kwarg still outranks an ``OPENBB_*`` env var.""" + monkeypatch.setenv("OPENBB_FLAIR", ":rocket") + settings = Settings(FLAIR=":bug") + assert settings.FLAIR == ":bug" + + @patch("openbb_cli.models.settings.set_key") @patch( "openbb_cli.models.settings.open", diff --git a/openbb_platform/Dockerfile b/openbb_platform/Dockerfile new file mode 100644 index 00000000000..79d5546bfc2 --- /dev/null +++ b/openbb_platform/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1 +# +# Build context must be the repository root (not openbb_platform/), since +# dev_install.py installs packages from both openbb_platform/ and cli/: +# docker build -f openbb_platform/Dockerfile -t openbb-platform . +# +# Only openbb-core and openbb-charting are published at their V5 versions on +# PyPI; every other package here (cli, platform-api, mcp-server, providers, +# routers) is still pre-V5 upstream, so everything is installed editable from +# this checkout via openbb_platform/dev_install.py rather than by name. + +FROM python:3.11-slim AS builder + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential curl git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir uv + +COPY . . + +RUN python openbb_platform/dev_install.py --routers + +FROM python:3.11-slim + +RUN useradd --create-home --shell /bin/bash openbb + +COPY --from=builder /usr/local /usr/local +COPY --from=builder --chown=openbb:openbb /app /app + +USER openbb +ENV HOME=/home/openbb +ENV PYTHONUNBUFFERED=1 +ENV OPENBB_CONFIG=/app/openbb_platform/openbb.docker.toml + +WORKDIR /app/openbb_platform + +EXPOSE 6900 8001 + +CMD ["openbb-api"] diff --git a/openbb_platform/Dockerfile.dockerignore b/openbb_platform/Dockerfile.dockerignore new file mode 100644 index 00000000000..822d55b04f6 --- /dev/null +++ b/openbb_platform/Dockerfile.dockerignore @@ -0,0 +1,42 @@ +# .git is intentionally NOT excluded: dev_install.py resolves the installable +# package set via `git ls-files`, so the build stage needs a real git index. +# +# Everything outside openbb_platform/, cli/, and .git is irrelevant to this +# image and gets excluded explicitly (desktop/ alone is a ~29GB node_modules +# tree) rather than relying on an allowlist, since the top-level layout is +# effectively a monorepo of unrelated projects. +desktop +images +examples +wargame +cookiecutter +build +docs +.venv +**/.venv +__pycache__ +**/__pycache__ +*.pyc +*.pyo +.pytest_cache +**/.pytest_cache +.ruff_cache +**/.ruff_cache +.mypy_cache +**/.mypy_cache +.coverage +**/.coverage +htmlcov +**/htmlcov +dist +**/dist +*.egg-info +**/*.egg-info +.DS_Store +**/.DS_Store +node_modules +**/node_modules +.idea +.vscode +*.md +!openbb_platform/**/README.md diff --git a/openbb_platform/core/openbb/__init__.py b/openbb_platform/core/openbb/__init__.py index ab194efabc9..42eb9f7bfb4 100644 --- a/openbb_platform/core/openbb/__init__.py +++ b/openbb_platform/core/openbb/__init__.py @@ -6,6 +6,7 @@ from pathlib import Path from importlib import import_module from typing import List, Optional, Union +from openbb_core.app.config import load_layered_config as _load_layered_config from openbb_core.app.static.app_factory import ( BaseApp as _BaseApp, create_app as _create_app, @@ -40,6 +41,13 @@ def build( _PackageBuilder(_this_dir).auto_build() _ReferenceLoader(_this_dir) +# Runs the same layered TOML cascade the launchers (openbb-api, openbb-mcp, +# openbb-cli) bootstrap explicitly, so `openbb.toml` / `OPENBB_*` env vars +# take effect for a bare `from openbb import obb` too. Best-effort by +# construction โ€” every cascade layer is independently optional, so this is a +# no-op when no config file is present anywhere in the cascade. +_load_layered_config() + try: _extensions_module = import_module("openbb.package.__extensions__") _Extensions = _extensions_module.Extensions diff --git a/openbb_platform/core/openbb_core/app/command_runner.py b/openbb_platform/core/openbb_core/app/command_runner.py index 499e9023771..21c27242941 100644 --- a/openbb_platform/core/openbb_core/app/command_runner.py +++ b/openbb_platform/core/openbb_core/app/command_runner.py @@ -647,7 +647,7 @@ class CommandRunner: self._command_map = command_map or CommandMap() self._system_settings = system_settings or SystemService().system_settings - self._user_settings = user_settings or UserService.read_from_file() + self._user_settings = user_settings or UserService().default_user_settings def init_logging_service(self) -> None: """Initialize the logging service.""" diff --git a/openbb_platform/core/openbb_core/app/model/preferences.py b/openbb_platform/core/openbb_core/app/model/preferences.py index af53972484f..7a114d40f49 100644 --- a/openbb_platform/core/openbb_core/app/model/preferences.py +++ b/openbb_platform/core/openbb_core/app/model/preferences.py @@ -14,12 +14,12 @@ class Preferences(BaseModel): data_directory: str = str(Path.home() / "OpenBBUserData") export_directory: str = str(Path.home() / "OpenBBUserData" / "exports") metadata: bool = True - output_type: Literal[ - "OBBject", "dataframe", "polars", "numpy", "dict", "chart", "llm" - ] = Field( - default="OBBject", - description="Python default output type.", - validate_default=True, + output_type: Literal["OBBject", "dataframe", "polars", "numpy", "dict", "llm"] = ( + Field( + default="OBBject", + description="Python default output type.", + validate_default=True, + ) ) request_timeout: PositiveInt = 60 show_warnings: bool = False diff --git a/openbb_platform/core/openbb_core/app/static/container.py b/openbb_platform/core/openbb_core/app/static/container.py index 1b6b4459abe..54b5442fcca 100644 --- a/openbb_platform/core/openbb_core/app/static/container.py +++ b/openbb_platform/core/openbb_core/app/static/container.py @@ -56,7 +56,11 @@ class Container: output_type = self._command_runner.user_settings.preferences.output_type - if output_type == "OBBject": + if output_type == "OBBject" or not hasattr(obbject, "to_" + output_type): + # A ``no_validate=True`` command may return something other than + # an ``OBBject`` (e.g. a bare dict/list) with no `to_*` + # conversion methods, so a non-default `output_type` has nothing + # to apply โ€” hand the result back exactly as the command built it. return obbject return getattr(obbject, "to_" + output_type)() diff --git a/openbb_platform/core/openbb_core/app/static/package_builder/method_definition.py b/openbb_platform/core/openbb_core/app/static/package_builder/method_definition.py index e2c6e7790ed..c9a0a06bf87 100644 --- a/openbb_platform/core/openbb_core/app/static/package_builder/method_definition.py +++ b/openbb_platform/core/openbb_core/app/static/package_builder/method_definition.py @@ -1091,38 +1091,40 @@ class MethodDefinition: od[param] = new_value + @staticmethod + def _get_type_repr(type_hint: Any) -> str: + """Get the string representation of a type hint.""" + if isinstance(type_hint, type): + return type_hint.__name__ + + # Unwrap ForwardRef to its inner string so we don't emit + # ForwardRef('int') in generated signatures. + if hasattr(type_hint, "__forward_arg__"): + return type_hint.__forward_arg__ + + s = str(type_hint) + if s.startswith("typing."): + s = s[7:] + return s + + @staticmethod + def _normalize_type_names(text: str) -> str: + """Rewrite ``typing`` and fully-qualified names in a rendered type.""" + text = text.replace("NoneType", "None") + text = text.replace("pandas.core.frame.DataFrame", "pandas.DataFrame") + text = text.replace("openbb_core.provider.abstract.data.Data", "Data") + text = text.replace("ForwardRef('Data')", "Data") + text = text.replace("ForwardRef('DataFrame')", "DataFrame") + text = text.replace("ForwardRef('Series')", "Series") + text = text.replace("ForwardRef('ndarray')", "ndarray") + text = re.sub(r"\bDict\b", "dict", text) + text = re.sub(r"\bList\b", "list", text) + return text.replace("typing.", "") + @staticmethod def build_func_params(formatted_params: OrderedDict[str, Parameter]) -> str: """Convert function params to string representations.""" - def get_type_repr(type_hint: Any) -> str: - """Get the string representation of a type hint.""" - if isinstance(type_hint, type): - return type_hint.__name__ - - # Unwrap ForwardRef to its inner string so we don't emit - # ForwardRef('int') in generated signatures. - if hasattr(type_hint, "__forward_arg__"): - return type_hint.__forward_arg__ - - s = str(type_hint) - if s.startswith("typing."): - s = s[7:] - return s - - def normalize_type_names(text: str) -> str: - """Rewrite ``typing`` and fully-qualified names in a rendered type.""" - text = text.replace("NoneType", "None") - text = text.replace("pandas.core.frame.DataFrame", "pandas.DataFrame") - text = text.replace("openbb_core.provider.abstract.data.Data", "Data") - text = text.replace("ForwardRef('Data')", "Data") - text = text.replace("ForwardRef('DataFrame')", "DataFrame") - text = text.replace("ForwardRef('Series')", "Series") - text = text.replace("ForwardRef('ndarray')", "ndarray") - text = re.sub(r"\bDict\b", "dict", text) - text = re.sub(r"\bList\b", "list", text) - return text.replace("typing.", "") - def split_preserving_whitespace(text: str, width: int) -> list[str]: """Split text into chunks of about ``width`` characters that rejoin exactly.""" chunks: list[str] = [] @@ -1144,10 +1146,12 @@ class MethodDefinition: isinstance(m, OpenBBField) for m in param.annotation.__metadata__ ) ): - return normalize_type_names(str(param)) + return MethodDefinition._normalize_type_names(str(param)) type_hint = param.annotation.__args__[0] - type_repr = normalize_type_names(get_type_repr(type_hint)) + type_repr = MethodDefinition._normalize_type_names( + MethodDefinition._get_type_repr(type_hint) + ) meta = next( m for m in param.annotation.__metadata__ if isinstance(m, OpenBBField) ) @@ -1200,8 +1204,12 @@ class MethodDefinition: func_returns = return_type elif isclass(return_type) and issubclass(return_type, OBBject): func_returns = "OBBject" + elif return_type: + func_returns = MethodDefinition._normalize_type_names( + MethodDefinition._get_type_repr(return_type) + ) else: - func_returns = return_type.__name__ if return_type else Any + func_returns = Any return func_returns # type: ignore diff --git a/openbb_platform/core/openbb_core/provider/query_executor.py b/openbb_platform/core/openbb_core/provider/query_executor.py index 1676c9b2046..07a314554ad 100644 --- a/openbb_platform/core/openbb_core/provider/query_executor.py +++ b/openbb_platform/core/openbb_core/provider/query_executor.py @@ -56,7 +56,7 @@ class QueryExecutor: extra_msg = f" Check {website} to get it." if website else "" raise OpenBBError( f"Missing credential '{c}'.{extra_msg} Refer to the documentation for setting provider " - "credentials at https://docs.openbb.co/platform/settings/user_settings/api_keys." + "credentials at https://docs.openbb.co/odp/python/settings/user_settings/api_keys." ) else: filtered_credentials[c] = secret diff --git a/openbb_platform/core/openbb_core/provider/utils/helpers.py b/openbb_platform/core/openbb_core/provider/utils/helpers.py index a7d4532f77c..866ee3d82e5 100644 --- a/openbb_platform/core/openbb_core/provider/utils/helpers.py +++ b/openbb_platform/core/openbb_core/provider/utils/helpers.py @@ -240,6 +240,13 @@ async def get_async_requests_session(**kwargs) -> ClientSession: python_settings = get_python_request_settings() _ = kwargs.pop("raise_for_status", None) + # "password" is only valid as input to ssl.load_cert_chain, below; aiohttp + # accepts neither it nor "proxy_headers" as a ClientSession constructor + # kwarg, so both must be kept out of the generic settings merge/pass-through + # further down or ClientSession(**conn_kwargs) raises a TypeError. + password = python_settings.pop("password", None) + python_settings.pop("proxy_headers", None) + proxy = python_settings.get("proxy") http_proxy = os.environ.get("HTTP_PROXY", os.environ.get("HTTPS_PROXY")) https_proxy = os.environ.get("HTTPS_PROXY", os.environ.get("HTTP_PROXY")) @@ -261,7 +268,6 @@ async def get_async_requests_session(**kwargs) -> ClientSession: ca = python_settings.get("cafile") or os.environ.get("REQUESTS_CA_BUNDLE") cert = python_settings.get("certfile") key = python_settings.get("keyfile") - password = python_settings.get("password") ssl_context = ssl.create_default_context() if ca: diff --git a/openbb_platform/core/pyproject.toml b/openbb_platform/core/pyproject.toml index 19b44631ae2..ee09a72217d 100644 --- a/openbb_platform/core/pyproject.toml +++ b/openbb_platform/core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openbb-core" -version = "2.0.0" +version = "2.0.1" description = "OpenBB package with core functionality." authors = [{ name = "OpenBB Team", email = "hello@openbb.co" }] license = { text = "Apache-2.0" } diff --git a/openbb_platform/core/tests/app/model/test_user_settings.py b/openbb_platform/core/tests/app/model/test_user_settings.py index efbf1a2b62d..79f01a2f547 100644 --- a/openbb_platform/core/tests/app/model/test_user_settings.py +++ b/openbb_platform/core/tests/app/model/test_user_settings.py @@ -45,9 +45,9 @@ def test_user_settings_invalid_json_warns_and_uses_kwargs(tmp_path, monkeypatch) monkeypatch.setattr(user_settings_module, "USER_SETTINGS_PATH", str(p)) with pytest.warns(UserWarning, match="Error loading user settings"): - settings = UserSettings(preferences=Preferences(output_type="chart")) + settings = UserSettings(preferences=Preferences(output_type="dataframe")) - assert settings.preferences.output_type == "chart" + assert settings.preferences.output_type == "dataframe" def test_user_settings_oserror_warns_and_uses_kwargs(monkeypatch): @@ -80,5 +80,5 @@ def test_user_settings_repr_contains_sections(): def test_user_settings_no_file_uses_kwargs(monkeypatch): monkeypatch.setattr(user_settings_module.os.path, "exists", lambda _p: False) - settings = UserSettings(preferences=Preferences(output_type="chart")) - assert settings.preferences.output_type == "chart" + settings = UserSettings(preferences=Preferences(output_type="dataframe")) + assert settings.preferences.output_type == "dataframe" diff --git a/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py b/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py index 43816a9c680..e292214d9da 100644 --- a/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py +++ b/openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py @@ -1142,6 +1142,25 @@ def test_build_func_returns_obbject_subclass(): assert MethodDefinition.build_func_returns(_O) == "OBBject" +def test_build_func_returns_plain_class(): + assert MethodDefinition.build_func_returns(int) == "int" + + +def test_build_func_returns_pep604_union(): + from openbb_core.provider.abstract.data import Data + + assert MethodDefinition.build_func_returns(Data | None) == "Data | None" + + +def test_build_func_returns_falsy_return_type(): + """A falsy (but not ``inspect._empty``) return type โ€” e.g. ``None`` โ€” + falls through to the ``Any`` sentinel rather than a rendered type name. + """ + from typing import Any + + assert MethodDefinition.build_func_returns(None) is Any + + def test_collect_dependency_calls_skips_unsafe_annotated_dependency(): def _unsafe() -> None: return None diff --git a/openbb_platform/core/tests/app/static/test_container.py b/openbb_platform/core/tests/app/static/test_container.py index 4a97c3254e2..2f10760869c 100644 --- a/openbb_platform/core/tests/app/static/test_container.py +++ b/openbb_platform/core/tests/app/static/test_container.py @@ -201,6 +201,24 @@ def test_run_with_dataframe_output_type(mock_sync_run): assert out == "DF" +@patch("openbb_core.app.command_runner.CommandRunner.sync_run") +def test_run_no_validate_dict_result_with_non_default_output_type(mock_sync_run): + """A ``no_validate=True`` command may return a bare ``dict`` (or other + non-``OBBject`` shape) instead of an ``OBBject``. A non-default + ``output_type`` (e.g. ``dataframe``) must not crash trying to call a + ``to_dataframe`` conversion method that plain dicts don't have โ€” the + raw result is returned unchanged since there's nothing to convert. + """ + settings = UserSettings() + settings.preferences.output_type = "dataframe" + c = Container(CommandRunner(user_settings=settings)) + + mock_sync_run.return_value = {"content": "base64-pdf-data"} + + out = c._run("/foo", standard_params={}, extra_params={}) + assert out == {"content": "base64-pdf-data"} + + def test_get_provider_single_provider_in_config_short_circuits(): class MockCredentials(BaseModel): provider_1_api_key: SecretStr | None = None diff --git a/openbb_platform/core/tests/app/test_command_runner.py b/openbb_platform/core/tests/app/test_command_runner.py index 197765da386..ed0170bdb6e 100644 --- a/openbb_platform/core/tests/app/test_command_runner.py +++ b/openbb_platform/core/tests/app/test_command_runner.py @@ -349,6 +349,28 @@ def test_command_runner_properties(): assert runner.command_map == cmd_map +def test_command_runner_default_user_settings_reads_live_user_service_singleton(): + """When no ``user_settings`` is passed, the default comes from the live + ``UserService()`` singleton's ``default_user_settings`` โ€” mirroring + ``system_settings``' own ``SystemService().system_settings`` default โ€” + not a fresh ``UserService.read_from_file()`` disk re-read. + + A fresh classmethod re-read would bypass any layered-TOML ``[user]`` + overrides that ``apply_config_to_services`` merged onto the singleton + instance, since that function patches the singleton in place rather + than rewriting the on-disk JSON. + """ + sentinel = UserSettings() + with patch( + "openbb_core.app.service.user_service.UserService", + ) as mock_user_service_cls: + mock_user_service_cls.return_value.default_user_settings = sentinel + runner = CommandRunner() + + mock_user_service_cls.assert_called_once() + assert runner.user_settings is sentinel + + @patch("openbb_core.app.command_runner.CommandRunner") def test_command_runner_run(_): """Test run.""" diff --git a/openbb_platform/core/tests/provider/utils/test_helpers.py b/openbb_platform/core/tests/provider/utils/test_helpers.py index fb921fbcbe1..4c3f0ffb238 100644 --- a/openbb_platform/core/tests/provider/utils/test_helpers.py +++ b/openbb_platform/core/tests/provider/utils/test_helpers.py @@ -435,6 +435,91 @@ def test_get_async_requests_session_ssl_context_with_cert(tmp_path, monkeypatch) asyncio.run(_go()) +def test_get_async_requests_session_password_and_proxy_headers_do_not_crash( + monkeypatch, +): + """python_settings.http.password/proxy_headers must never reach + ClientSession(**conn_kwargs) directly; aiohttp accepts neither as a + constructor kwarg. + """ + import asyncio + + from openbb_core.provider.utils import helpers as H + + monkeypatch.setattr( + H, + "get_python_request_settings", + lambda: {"password": "secret", "proxy_headers": {"X-Foo": "bar"}}, + ) + + async def _go(): + s = await H.get_async_requests_session() + await s.close() + + asyncio.run(_go()) + + +def test_get_async_requests_session_ssl_password_still_used(tmp_path, monkeypatch): + """The password setting must still reach ssl_context.load_cert_chain.""" + import asyncio + import subprocess + + from openbb_core.provider.utils import helpers as H + + cert = tmp_path / "c.pem" + key = tmp_path / "k.pem" + subprocess.run( # noqa: S603 + [ # noqa: S607 + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + str(key), + "-out", + str(cert), + "-days", + "1", + "-nodes", + "-subj", + "/CN=t", + ], + check=True, + capture_output=True, + ) + subprocess.run( # noqa: S603 + [ # noqa: S607 + "openssl", + "rsa", + "-in", + str(key), + "-out", + str(key), + "-aes256", + "-passout", + "pass:correct-horse", + ], + check=True, + capture_output=True, + ) + monkeypatch.setattr( + H, + "get_python_request_settings", + lambda: { + "certfile": str(cert), + "keyfile": str(key), + "password": "wrong-password", + }, + ) + + async def _go(): + await H.get_async_requests_session() + + with pytest.raises(Exception, match="PEM"): + asyncio.run(_go()) + + def test_get_async_requests_session_with_basic_auth_and_cookies(): """Lines 303, 317-318: proxy_auth/auth/dict cookies.""" import asyncio diff --git a/openbb_platform/core/uv.lock b/openbb_platform/core/uv.lock index f99d8573e6e..a7aabd1e48a 100644 --- a/openbb_platform/core/uv.lock +++ b/openbb_platform/core/uv.lock @@ -1661,7 +1661,7 @@ wheels = [ [[package]] name = "openbb-core" -version = "2.0.0" +version = "2.0.1" source = { editable = "." } dependencies = [ { name = "aiohttp" }, diff --git a/openbb_platform/docker-compose.yml b/openbb_platform/docker-compose.yml new file mode 100644 index 00000000000..9e6995f9cb3 --- /dev/null +++ b/openbb_platform/docker-compose.yml @@ -0,0 +1,31 @@ +services: + api: + build: + context: .. + dockerfile: openbb_platform/Dockerfile + command: ["openbb-api", "--reload"] + ports: + - "6900:6900" + volumes: + - ..:/app + - openbb_home:/home/openbb + env_file: + - path: ../.env + required: false + + mcp: + build: + context: .. + dockerfile: openbb_platform/Dockerfile + command: ["openbb-mcp"] + ports: + - "8001:8001" + volumes: + - ..:/app + - openbb_home:/home/openbb + env_file: + - path: ../.env + required: false + +volumes: + openbb_home: diff --git a/openbb_platform/extensions/econometrics/openbb_econometrics/diagnostics.py b/openbb_platform/extensions/econometrics/openbb_econometrics/diagnostics.py index e7f3b581514..8911105efe1 100644 --- a/openbb_platform/extensions/econometrics/openbb_econometrics/diagnostics.py +++ b/openbb_platform/extensions/econometrics/openbb_econometrics/diagnostics.py @@ -217,15 +217,13 @@ def residual_autocorrelation( from statsmodels.stats.diagnostic import acorr_breusch_godfrey results = _ols_fit(params.data, params.y_column, params.x_columns) - lm_stat, lm_p_value, f_stat, f_p_value = acorr_breusch_godfrey( - results, nlags=params.lags - ) + test = acorr_breusch_godfrey(results, nlags=params.lags, result_object=True) return OBBject( results=ResidualAutocorrelationData( - lm_statistic=float(lm_stat), - lm_p_value=float(lm_p_value), - f_statistic=float(f_stat), - f_p_value=float(f_p_value), + lm_statistic=float(test.lm), + lm_p_value=float(test.lmpval), + f_statistic=float(test.fval), + f_p_value=float(test.fpval), ) ) diff --git a/openbb_platform/extensions/econometrics/openbb_econometrics/time_series.py b/openbb_platform/extensions/econometrics/openbb_econometrics/time_series.py index 8c49b38a9e7..29a707f10fb 100644 --- a/openbb_platform/extensions/econometrics/openbb_econometrics/time_series.py +++ b/openbb_platform/extensions/econometrics/openbb_econometrics/time_series.py @@ -5,7 +5,7 @@ from datetime import ( datetime, ) from itertools import combinations -from typing import Literal +from typing import Literal, cast from openbb_core.app.model.example import APIEx from openbb_core.app.model.obbject import OBBject @@ -265,17 +265,16 @@ def unit_root(params: UnitRootQueryParams) -> OBBject[UnitRootData]: maxlag=params.maxlag, regression=params.regression, autolag=params.autolag, + result_object=True, ) - adf_stat, p_value, used_lag, nobs, critical_values = result[:5] - ic_best = result[5] if len(result) > 5 else None return OBBject( results=UnitRootData( - adf_stat=float(adf_stat), - p_value=float(p_value), - used_lag=int(used_lag), - nobs=int(nobs), - ic_best=float(ic_best) if ic_best is not None else None, - critical_values=dict(critical_values), + adf_stat=float(result.statistic), + p_value=float(result.pvalue), + used_lag=int(result.lags), + nobs=int(result.nobs), + ic_best=float(result.icbest) if result.icbest is not None else None, + critical_values=dict(result.critical_values), ) ) @@ -296,25 +295,33 @@ def kpss(params: KpssQueryParams) -> OBBject[KpssData]: from openbb_core.app.utils import basemodel_to_df, get_target_column from statsmodels.tools.sm_exceptions import InterpolationWarning - from statsmodels.tsa.stattools import kpss as _kpss + from statsmodels.tsa.stattools import ( + KPSSResult, + kpss as _kpss, + ) series = get_target_column(basemodel_to_df(params.data), params.column) - # Capture statsmodels' off-table InterpolationWarning as a typed result field. with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always", InterpolationWarning) - statistic, p_value, lags, critical_values = _kpss( - series, regression=params.regression, nlags=params.nlags + result = cast( + KPSSResult, + _kpss( + series, + regression=params.regression, + nlags=params.nlags, + result_object=True, + ), ) p_value_interpolated = not any( issubclass(w.category, InterpolationWarning) for w in caught ) return OBBject( results=KpssData( - kpss_stat=float(statistic), - p_value=float(p_value), + kpss_stat=float(result.statistic), + p_value=float(result.pvalue), p_value_interpolated=p_value_interpolated, - lags=int(lags), - critical_values=dict(critical_values), + lags=int(result.lags), + critical_values=dict(result.critical_values), ) ) diff --git a/openbb_platform/extensions/econometrics/openbb_econometrics/utils.py b/openbb_platform/extensions/econometrics/openbb_econometrics/utils.py index b6dfe5317f7..0832a63620f 100644 --- a/openbb_platform/extensions/econometrics/openbb_econometrics/utils.py +++ b/openbb_platform/extensions/econometrics/openbb_econometrics/utils.py @@ -28,10 +28,9 @@ def get_engle_granger_two_step_cointegration_test( alpha = short_run_ols_fit.params.iloc[0] - # adfuller's p-value assumes z is not pre-estimated; limited effect for high N. - adfstat, pvalue, _, _, _ = adfuller(z, maxlag=1, autolag=None) + adf = adfuller(z, maxlag=1, autolag=None, result_object=True) - return c, gamma, alpha, z, adfstat, pvalue + return c, gamma, alpha, z, adf.statistic, adf.pvalue def mock_multi_index_data(): diff --git a/openbb_platform/extensions/econometrics/pyproject.toml b/openbb_platform/extensions/econometrics/pyproject.toml index 4931cdef708..d4ccf2a55bc 100644 --- a/openbb_platform/extensions/econometrics/pyproject.toml +++ b/openbb_platform/extensions/econometrics/pyproject.toml @@ -8,7 +8,7 @@ readme = "README.md" requires-python = ">=3.10,<4" dependencies = [ "openbb-core[pandas]>=2.0.0", - "statsmodels>=0.14", + "statsmodels>=0.15", "linearmodels>=6", "scipy>=1.11", "arch>=7.2", diff --git a/openbb_platform/extensions/econometrics/uv.lock b/openbb_platform/extensions/econometrics/uv.lock index 1e15d3f26b6..994678bfa0b 100644 --- a/openbb_platform/extensions/econometrics/uv.lock +++ b/openbb_platform/extensions/econometrics/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10, <4" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -1713,7 +1713,7 @@ requires-dist = [ { name = "linearmodels", specifier = ">=6" }, { name = "openbb-core", extras = ["pandas"], specifier = ">=2.0.0" }, { name = "scipy", specifier = ">=1.11" }, - { name = "statsmodels", specifier = ">=0.14" }, + { name = "statsmodels", specifier = ">=0.15" }, ] [package.metadata.requires-dev] @@ -1874,7 +1874,7 @@ wheels = [ [[package]] name = "pandas-ta-openbb" -version = "0.4.24" +version = "0.4.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1885,9 +1885,9 @@ dependencies = [ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "statsmodels" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/b3/c8812123c1463480085929aebfeab0f2400cce2c0803223dcfe51f1af7f2/pandas_ta_openbb-0.4.24.tar.gz", hash = "sha256:69ff98beccbbe0ab238371af25a5c0be9f123aa474888cef74892441c353da1b", size = 135689, upload-time = "2026-03-27T08:24:46.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/9c/a3e2c4b20d9df713462dbf7f260d49fded025e69604990dd3a77f8c848a6/pandas_ta_openbb-0.4.25.tar.gz", hash = "sha256:1e71e7e64fdf00441965dd2b6c3d850be607a058f326e666dd54bf718049d8fb", size = 135752, upload-time = "2026-09-28T07:28:24.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/60/d785cb8519613fa50c4cda58cdcabb8078dfa414c3cb3663acebd1a4da04/pandas_ta_openbb-0.4.24-py3-none-any.whl", hash = "sha256:0e6538af573c9ab84a6b13c965ccfc0a930974b876f90473e71b49dcb7a38920", size = 259501, upload-time = "2026-03-27T08:24:47.915Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d6/68035f2581dbbaeae47dcdc89613eef026734fc8475563f5364e8804ddb4/pandas_ta_openbb-0.4.25-py3-none-any.whl", hash = "sha256:cebce28cdd2f69fab5f1a1455f16b52f7961d7c3255f5183c15a2f973fc8ec46", size = 259351, upload-time = "2026-09-28T07:28:10.171Z" }, ] [[package]] @@ -2895,9 +2895,10 @@ wheels = [ [[package]] name = "statsmodels" -version = "0.14.6" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "formulaic" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, @@ -2907,38 +2908,62 @@ dependencies = [ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/1f/a3cbf6ed7cf6286afec694bfef561f3e51483197716717fb8476f1f0c50e/statsmodels-0.15.0.tar.gz", hash = "sha256:5d257fe58d0772bc46a557880ca78e2a8e07fec7bfd9d11074aef8e33e1aecbc", size = 15736853, upload-time = "2026-08-27T10:45:21.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/6d/9ec309a175956f88eb8420ac564297f37cf9b1f73f89db74da861052dc29/statsmodels-0.14.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4ff0649a2df674c7ffb6fa1a06bffdb82a6adf09a48e90e000a15a6aaa734b0", size = 10142419, upload-time = "2025-12-05T19:27:35.625Z" }, - { url = "https://files.pythonhosted.org/packages/86/8f/338c5568315ec5bf3ac7cd4b71e34b98cb3b0f834919c0c04a0762f878a1/statsmodels-0.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:109012088b3e370080846ab053c76d125268631410142daad2f8c10770e8e8d9", size = 10022819, upload-time = "2025-12-05T19:27:49.385Z" }, - { url = "https://files.pythonhosted.org/packages/b0/77/5fc4cbc2d608f9b483b0675f82704a8bcd672962c379fe4d82100d388dbf/statsmodels-0.14.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e93bd5d220f3cb6fc5fc1bffd5b094966cab8ee99f6c57c02e95710513d6ac3f", size = 10118927, upload-time = "2025-12-05T23:07:51.256Z" }, - { url = "https://files.pythonhosted.org/packages/94/55/b86c861c32186403fe121d9ab27bc16d05839b170d92a978beb33abb995e/statsmodels-0.14.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06eec42d682fdb09fe5d70a05930857efb141754ec5a5056a03304c1b5e32fd9", size = 10413015, upload-time = "2025-12-05T23:08:53.95Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/daf0dba729ccdc4176605f4a0fd5cfe71cdda671749dca10e74a732b8b1c/statsmodels-0.14.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0444e88557df735eda7db330806fe09d51c9f888bb1f5906cb3a61fb1a3ed4a8", size = 10441248, upload-time = "2025-12-05T23:09:09.353Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1c/2e10b7c7cc44fa418272996bf0427b8016718fd62f995d9c1f7ab37adf35/statsmodels-0.14.6-cp310-cp310-win_amd64.whl", hash = "sha256:e83a9abe653835da3b37fb6ae04b45480c1de11b3134bd40b09717192a1456ea", size = 9583410, upload-time = "2025-12-05T19:28:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/a9/4d/df4dd089b406accfc3bb5ee53ba29bb3bdf5ae61643f86f8f604baa57656/statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4", size = 10121514, upload-time = "2025-12-05T19:28:16.521Z" }, - { url = "https://files.pythonhosted.org/packages/82/af/ec48daa7f861f993b91a0dcc791d66e1cf56510a235c5cbd2ab991a31d5c/statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6", size = 10003346, upload-time = "2025-12-05T19:28:29.568Z" }, - { url = "https://files.pythonhosted.org/packages/a9/2c/c8f7aa24cd729970728f3f98822fb45149adc216f445a9301e441f7ac760/statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb", size = 10129872, upload-time = "2025-12-05T23:09:25.724Z" }, - { url = "https://files.pythonhosted.org/packages/40/c6/9ae8e9b0721e9b6eb5f340c3a0ce8cd7cce4f66e03dd81f80d60f111987f/statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca", size = 10381964, upload-time = "2025-12-05T23:09:41.326Z" }, - { url = "https://files.pythonhosted.org/packages/28/8c/cf3d30c8c2da78e2ad1f50ade8b7fabec3ff4cdfc56fbc02e097c4577f90/statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d", size = 10409611, upload-time = "2025-12-05T23:09:57.131Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cc/018f14ecb58c6cb89de9d52695740b7d1f5a982aa9ea312483ea3c3d5f77/statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7", size = 9580385, upload-time = "2025-12-05T19:28:42.778Z" }, - { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, - { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, - { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, - { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, - { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" }, - { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" }, - { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" }, - { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" }, - { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" }, - { url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" }, - { url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" }, - { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/8c/b6af48649a55aae10df1affa44c47a83a9f3109f9a32d84451f7d9b9b8d5/statsmodels-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b5561f454d6b629bb45cb505df6fdb498f960d0de111bd81f215fa47b15a72d", size = 11744547, upload-time = "2026-08-27T10:34:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/51e4be3772a7767699a4e6864e884f314c4c55e1c169775587d99f6f03c5/statsmodels-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0a42d58efadd47352f805fcf7eeb000f3399fe9cda260055a81105a922443bdd", size = 11592057, upload-time = "2026-08-27T10:34:35.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/88/ed18a489086ca94c2238424f5dc149d6eaddc1198a5b9efae71626096cba/statsmodels-0.15.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc37bb71f4b1e8c9b07e9ce0d32e01fbabe231d323484d35b58e93683a5123e1", size = 11858408, upload-time = "2026-08-27T14:28:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/92/f2/15a4d323326a52cc7d4a7b8e9fe72c85497fae514162ae2b1d8ee35aa3a2/statsmodels-0.15.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:972c33d9fe6bf907b853ba0eccb2f7cf3da90fdddbee002b8478bcaccf637e2a", size = 12109474, upload-time = "2026-08-27T14:29:22.624Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/c6cad0ef9b628340e1ac6ee16e508cff87e5e41d352035c856b7f3d4ad85/statsmodels-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d37d574412a47a71561c0b28c5f876137a86f26fa91e889a879950e22b4e9b4f", size = 12124128, upload-time = "2026-08-27T14:29:47.511Z" }, + { url = "https://files.pythonhosted.org/packages/f7/18/7ed9486977781b3e81a9447cbd04bd4a5fd7ff1807d97929778fb481e644/statsmodels-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:0e108365c5062ba1cf568a183cfc8da551ab8b2e84dbb00e1e87a6a714dd4cb3", size = 11367159, upload-time = "2026-08-27T15:08:25.734Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2e/bba0e676ff1df16be77fc70f652c0261d8ca3a7467dda5ecf39afa7bded0/statsmodels-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:103a83723f998897fbb5d5d0ff728cbe3755a90d9b1f22406bf58f305faff2e7", size = 11714869, upload-time = "2026-08-27T10:34:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/58/c8/08a71f7ad3f0506967d0ab0cdc16a6dec0fe94d0b317f5567836010a10f6/statsmodels-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0ca5d27711f171ee478d547d5a8c5424d1820a1b73c2a3d6bf63f71ad4525f71", size = 11567437, upload-time = "2026-08-27T10:35:08.711Z" }, + { url = "https://files.pythonhosted.org/packages/51/bd/e9292d873ed61f6d02864dd6a936e53c4129e10eee39117d7cd2d0da845c/statsmodels-0.15.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ef37437f740620a8da03437a29b1fc21bd9a70eeb231cd0d223cc4d766adb78", size = 11827696, upload-time = "2026-08-27T14:30:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/fa/6f/235505a633776a2c1188e583e5a507a548b4fbd21abc14e199f1a84e8ee2/statsmodels-0.15.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b67886b66d9c7ca118526accedb5c6de7ffd74c015dc0492ea0b1690192b65da", size = 12087914, upload-time = "2026-08-27T14:30:57.648Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/d64a5a3eef6cd7b1cc71645b3f625c16e024113e7ffcd1b4ec3da71605ec/statsmodels-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd184e911a1a8c52e5c1c27b43ccd10c24faec08053271ca1a35276cae0a3ada", size = 12109911, upload-time = "2026-08-27T14:31:25.914Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/77c4b5d3f61eab5676981d20f705acee8222ad31de8aca42a331dcd4cfdb/statsmodels-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:5e1214312d6362cebe0b5c53b4f478d9072559709000369dfc528211cb993e8a", size = 11361029, upload-time = "2026-08-27T10:35:24.869Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/14121b56377f2881ce94c0d0a01466585468923340d94841337d324f6be6/statsmodels-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e8deb1e3d4ab89fec2a52815f1ec924920c4e0cdb307421a4e47cfed87c03ff", size = 11654329, upload-time = "2026-08-27T15:09:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f5/6b550b5c936dd9ec5c859477698b4fcbccc826e9a4a4a52847fa51673357/statsmodels-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6e20788141df134b83ffb43b8cccf135a876188f84124835c86f011f138adee", size = 11527231, upload-time = "2026-08-27T10:35:42.669Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a5/0e7e87e0dedc68bfeecc24bea92876023db09bac1c0fe50584a7d0819fb5/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f90dc4989bd82837c80059fdce132976d4e5a80100119d16e1b00537eb55867", size = 11747428, upload-time = "2026-08-27T14:32:28.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/c7/aeadca282baf51d7b9c135674bac79ded91f4db79c69c188575d22234342/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f2bd48915cc9b3b95c3c7f6ac363b6f8aaa38172962ba5f7f6885f0dfe57f2", size = 12042643, upload-time = "2026-08-27T14:33:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/9c/44/7d3eb3836b3c054280baca7ec91bd12858825de437c6319c1212c2531d94/statsmodels-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:733887a09ff878a2e29504eda684488b26bd134ba3539ac58d2136a58914c65a", size = 12079497, upload-time = "2026-08-27T14:33:47.147Z" }, + { url = "https://files.pythonhosted.org/packages/03/e3/d863228849da191d37c54bdd72634fbbcfadd12e910e29e4bdca1b952bb6/statsmodels-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba89eb3ccb1c9c1e400d5081157305495b09350b3a65f2d66d3424ad1d3c4209", size = 11322106, upload-time = "2026-08-27T10:35:58.135Z" }, + { url = "https://files.pythonhosted.org/packages/87/17/fee8cfc100c37b1c553515dbad59607a55085a5b9beac2b008e4c03b6388/statsmodels-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09fe34ac80e147cd76cb702ba672559ee3c5dd3ba15c25a232922b46e5ffe70e", size = 11696848, upload-time = "2026-08-27T10:36:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/9e/60/a885751cd3aa796ef94b60f6d3de0c9ace4190feb3e55414ee728d2a0abc/statsmodels-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ffe08752f420f12a0b03e8f7d8f3a45417615309b1f37f10561f9a53f044737", size = 11556170, upload-time = "2026-08-27T10:36:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/482701d2dc7540897b13ead827cf8499b11c7325753b8cc4ff1a82c87a18/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a7ab98634d47048c6b7c168b3f8f71369e256d7314bc386168526f34c7699d7", size = 11752604, upload-time = "2026-08-27T10:47:50.809Z" }, + { url = "https://files.pythonhosted.org/packages/cc/88/5c8819f232448d52514b76a64cf285bc608cff9179f3eeacf58750612323/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcaa1e4499697c4e023e5fa6fb04459a34e4f4a449c45ae441992e933bde24db", size = 12055386, upload-time = "2026-08-27T10:49:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/19/a8/298e9b5c993cad3b58913d61b7e7cb0e2fba12e39d3114cd8484f922a5d1/statsmodels-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88dbe345ecf317862481b3c86305f42133082e5b366a3a8cf240a6caac404daf", size = 12088356, upload-time = "2026-08-27T10:48:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/28/f5/386c6cfda27c47df81643dc9466b3e28dc2a82152e524131977275a88e0c/statsmodels-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:ac9bce461d0c8f529f8ed331a469eaa5dd7c3213c06b62623a2e5438a6383093", size = 9809418, upload-time = "2026-08-27T10:36:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/15/b4/a8a4d89918599309bebfc09a2f793a910279f3900671bc959ac8c810edd1/statsmodels-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:081adf7e5f2da63f63cbd90491447de5d4c4c2921f19814f235bcf3e736edd3c", size = 11312877, upload-time = "2026-08-27T10:37:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/7a/79/66b57afdcbf9a8b21b1a8e4414ddb006e819b2a81243cd74c12f3301eca3/statsmodels-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3e5f870037ca154a4d177aeac68704a784b24cf2c45d86b046fc28e37a2a5584", size = 10812282, upload-time = "2026-08-27T10:37:26.708Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/daf737efb6002905bd006d296f9ad031ed3861b640075bde3b734706b49d/statsmodels-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:04e65ee05475aeceb9cb954cf96c0f9361b9cdfd497b8aae47afbab0c7c786dd", size = 11698848, upload-time = "2026-08-27T10:37:46.312Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/ca42c91e427db43917d4ab5cc6991e7e50b574bcfdf6c4c8eb9590af71d3/statsmodels-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f1443cc1ff7683f3d7a9054e6dccd92040fbd4324616a0b6dbb8af1ce7889fb", size = 11571229, upload-time = "2026-08-27T10:38:04.723Z" }, + { url = "https://files.pythonhosted.org/packages/5b/04/d803e8d014d4643d9c0a5eec78343073fbfff1c1018f776dd416c07fc076/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a45026596c71afa18543ec2af40b895609c181fc3649433c1cded5ce23ec5cd4", size = 11801588, upload-time = "2026-08-27T15:00:31.603Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/40d7678858065e9ec8efd9f37f04227b22a66632ba194e4aa43fda023f6a/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:edd803f4606061af8c7a83e2326ea294cbfa802a908dfbda4a88347301fb6820", size = 12062254, upload-time = "2026-08-27T15:00:49.803Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/16c73c3c09f4b8d85f125d7d3ae0f7092d4736a83d24c0159e4fb21632ec/statsmodels-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eb7fc1147cd8d17132190db7a8e63cd76a06145dec502816d8f3c20619923f2b", size = 12101259, upload-time = "2026-08-27T15:01:08.755Z" }, + { url = "https://files.pythonhosted.org/packages/23/4c/1138aad8e3c27ebf941d732788488cce7c28d76404a7b7fc1186faad2890/statsmodels-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:c3c1138b4d0e5b0c2387b17dafd2a0fd137c67bd869f70d4ee56feecc918cb0d", size = 11348219, upload-time = "2026-08-27T15:11:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/3a/bd/53546d8c9ab013d9a0513706d2a09a03aff12034e6f898987e8ce0f38257/statsmodels-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:74c0b63448f03020cc27a8ee698fbcf2ba91e61a9f9eee7978c0c4ed33a9b17f", size = 10866645, upload-time = "2026-08-27T10:38:20.886Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/0d38328643d4704c8de7867518c3f52ec145ff62537b2ca1e14cdeecf51b/statsmodels-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6197b4cca69fd7bc23ab9430abf1c479764cfe74767467f51964a633dfade7a5", size = 11870287, upload-time = "2026-08-27T10:38:38.271Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/fac7e03895f24a0b4883ff9fc69f9421a5df1b60e7edd7e68d49f4327593/statsmodels-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5538a734dd28efd450bb38a5d5fc94d13ee709bc497e3e4c0a24b3803e3eac39", size = 11755467, upload-time = "2026-08-27T10:38:54.683Z" }, + { url = "https://files.pythonhosted.org/packages/4c/38/5321b9f6065d1c5861e87853720603d56ae81795a83d3040410ca2684305/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dde0657d18e322eedb2540a84c0582e97afd0f78c370b45eebb0d4ac228a7a83", size = 11636274, upload-time = "2026-08-27T15:03:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/56/c75e984c492509d9ab2ba5f2c0e4eab7177cd2cec424cef9dafb42f0cc70/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90e75e726be2c9fc1d1d4c43337503a35864bdf5b4693773a08c6ee92d04a153", size = 11865818, upload-time = "2026-08-27T15:04:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/9e03975701c808767c19f394f670941a962204a4f6a00eeaf56b560348e8/statsmodels-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65e64afc2655a486ea44a5ffe4e2b05ee3abbcb10ea61d1c02d3e9e519338d9b", size = 11907800, upload-time = "2026-08-27T15:05:49.786Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/c467019a8bf1ab6a07ca88004f0a1d4ea2dae89642aa804e49437b256243/statsmodels-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:edee066ac9b171d95c3de925225331a21de1deb5b6452f252bc853e44566e72b", size = 11559083, upload-time = "2026-08-27T15:06:05.872Z" }, + { url = "https://files.pythonhosted.org/packages/54/10/dc78352367cd5bb298340b57c390c944b66236f6d026cc238bc8264c7633/statsmodels-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:40b2737456e75d96866943e33017a1dffc9166025ee33b7eb373fbe8c7a85e09", size = 11029613, upload-time = "2026-08-27T10:39:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/de/cb/a1f5ad730cb66c7d911ebb45413880333d837f1cffff990a4c6d2c541b95/statsmodels-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d48ba61db0d13d3033330e6bd1f864dfa9b48c0b952b3b0f7570206b35d552e5", size = 11702125, upload-time = "2026-08-30T16:04:07.635Z" }, + { url = "https://files.pythonhosted.org/packages/ce/db/fb95f242adce112c1507e2062ad6b7b00aea338925f2a03fbe47973c4d72/statsmodels-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:24b9917a09bbe0227047e512a40652c4b9117c2cfbf19c4a5b9971a8944a8606", size = 11571622, upload-time = "2026-08-30T16:04:29.558Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3f/f4cc7e5f4d6c8f46551aee63e59d9b964881070f552400ebb76667b39ad9/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333d0c70f269ff98b647f157d2ae7fbcf0617e6f9a5b1817fc6609766502292c", size = 11780713, upload-time = "2026-08-30T07:40:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/e9/20/fa315a013f70efa6674adfaca59365ea7562371914c408551fc3c399792f/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b946d7c922f46f045f25e50daf13439f79c05e403ba50081cad509892c8b26", size = 12070117, upload-time = "2026-08-30T07:40:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/91/43/a63f7880050c2c24056fa8d663b7c8a1816b25864660abbc73afff3f3558/statsmodels-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cf897abce5616e2763ab7d7a7fa914677df87a649971f877b83a358c98f6e291", size = 12105153, upload-time = "2026-08-30T07:40:45.814Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d4/523ddb13862c228407297754486b6c9e8777e5114f4532b667b47728c544/statsmodels-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:511a4f9cadbf91a690bfa863bb3e25dce583d8140046886fdf35fbfabfbce401", size = 11352844, upload-time = "2026-08-30T16:04:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/53/59/f3058214e61b15582ef17d761c9333def46960ee521313409b2705791029/statsmodels-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:0cb6228713fe47046b606daee63ed4473a7c246fa97cf0a6d6f20d3b0c96c051", size = 10866685, upload-time = "2026-08-30T16:05:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2d/43089b37f4bbbf7373929cd198de6cbdb0b5a4580a6e5e32bf9fe74fccab/statsmodels-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:27e9467fae83ff327c9d16ae5ea7e771bcdd66a682f49aa324eeefea5584f2d5", size = 11858149, upload-time = "2026-08-30T16:05:20.585Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/ca88421b81666cdcfc3d0eb12c714f80f2be327669ececbb5930b68a71c0/statsmodels-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ecf84918dcd410ab34d3d85d34e0b16ebb10319c9fc77dbf9a1f1a49324c59f8", size = 11739931, upload-time = "2026-08-30T16:05:37.286Z" }, + { url = "https://files.pythonhosted.org/packages/ab/64/98b9e2b1fbfdc4e2392bff349718d8dc796403006a3106ec375c59a5d3f8/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5891ad077b36d6401e44967739df3d2cdd380a0e6e9e46dadec7e3d3a828af55", size = 11657996, upload-time = "2026-08-30T07:41:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/34/6d/d8c9e73013ee854031218e8a044ba679535268bc61cf59466f19ac8d980c/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af80f9658c48792947fc754a026ffbebca61a8b483253781973f604a67cf1f8c", size = 11893402, upload-time = "2026-08-30T07:41:27.553Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f5/b9b47f3c688a8af1b199af96e098b8f20897a60b45187c57ce6fcde77a5f/statsmodels-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:583eb2a46f7c7a5c34a26715c8b6d92199bfb4f04623db5fff9902a3c7c832d3", size = 11934786, upload-time = "2026-08-30T07:41:46.809Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/608871f18bc18c6cba0b484e81fd25a023ef3ca6541f6793ec839fa49e1f/statsmodels-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:38038823fbe86f11e09433b4a0f4af5f102327c3137a1604b002ce3cf722a31f", size = 11549124, upload-time = "2026-08-30T16:05:53.179Z" }, + { url = "https://files.pythonhosted.org/packages/e3/eb/90a506dbee5cde669e2ef28565ff6b7a0791ab415eab2da82f92d033cccc/statsmodels-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:90df413d7e09474f7e7cbf8807440c1bf76298cdd9daabf9141f76d7627c9151", size = 11021454, upload-time = "2026-08-30T16:06:08.124Z" }, ] [[package]] diff --git a/openbb_platform/extensions/mcp_server/openbb_mcp_server/app/config.py b/openbb_platform/extensions/mcp_server/openbb_mcp_server/app/config.py index 23044a209d2..d88af5834dc 100644 --- a/openbb_platform/extensions/mcp_server/openbb_mcp_server/app/config.py +++ b/openbb_platform/extensions/mcp_server/openbb_mcp_server/app/config.py @@ -162,13 +162,27 @@ _BOOTSTRAPPED_CONFIG: dict[str, Any] | None = None def bootstrap_launcher_config( argv: list[str] | None = None, ) -> dict[str, Any]: - """One-call bootstrap for ``main.py``.""" + """One-call bootstrap for ``main.py``. + + ``[env]`` is applied BEFORE ``apply_config_to_services`` runs. That + order matters: ``apply_config_to_services`` is what first imports + ``openbb_core.app.model.credentials``, whose ``Credentials`` model + takes a one-shot snapshot of ``os.environ`` at import time. Pushing + ``[env]`` after that import would mean a credential supplied only + via the TOML ``[env]`` table (not a real shell export) is invisible + to that snapshot. + """ global _BOOTSTRAPPED_CONFIG # noqa: PLW0603 cli_path = extract_config_file_from_argv(argv) explicit_path = resolve_explicit_config_path(cli_path) - config = load_launcher_config(explicit_path=explicit_path) + config = load_launcher_config(explicit_path=explicit_path, apply_to_services=False) apply_launcher_env(config.get("env")) + + from openbb_core.app.config.loader import apply_config_to_services + + apply_config_to_services(config) + _BOOTSTRAPPED_CONFIG = config return config diff --git a/openbb_platform/extensions/mcp_server/tests/app/test_config.py b/openbb_platform/extensions/mcp_server/tests/app/test_config.py index c0d730e7c65..71b7f2d66d3 100644 --- a/openbb_platform/extensions/mcp_server/tests/app/test_config.py +++ b/openbb_platform/extensions/mcp_server/tests/app/test_config.py @@ -301,6 +301,31 @@ class TestBootstrapLauncherConfig: bootstrap_launcher_config(["--config-file", str(cfg)]) assert os.environ.get("OPENBB_BOOT_TEST_KEY") == "yes" + def test_bootstrap_launcher_config_applies_env_before_pushing_to_services( + self, tmp_path, monkeypatch + ): + """``[env]`` reaches ``os.environ`` before ``apply_config_to_services`` + runs, so a credential supplied only via TOML ``[env]`` (not a real + shell export) is visible to ``Credentials``' one-shot env snapshot. + """ + cfg = tmp_path / "openbb.toml" + cfg.write_text('[env]\nOPENBB_ORDER_TEST_KEY = "yes"\n') + monkeypatch.delenv("OPENBB_ORDER_TEST_KEY", raising=False) + + seen: dict = {} + + def fake_apply_config_to_services(config): + seen["env_var_at_call_time"] = os.environ.get("OPENBB_ORDER_TEST_KEY") + return {"system": [], "user": []} + + with patch( + "openbb_core.app.config.loader.apply_config_to_services", + side_effect=fake_apply_config_to_services, + ): + bootstrap_launcher_config(["--config-file", str(cfg)]) + + assert seen["env_var_at_call_time"] == "yes" + def test_reset_bootstrapped_config_clears_state(self, tmp_path): """``reset_bootstrapped_config`` blanks the stored config.""" cfg = tmp_path / "openbb.toml" diff --git a/openbb_platform/extensions/platform_api/openbb_platform_api/app/app.py b/openbb_platform/extensions/platform_api/openbb_platform_api/app/app.py index b97faca791c..2de500cd8a6 100644 --- a/openbb_platform/extensions/platform_api/openbb_platform_api/app/app.py +++ b/openbb_platform/extensions/platform_api/openbb_platform_api/app/app.py @@ -416,6 +416,10 @@ def launch_api(**_kwargs): # noqa: PLR0912 f"\n{f'Documentation is available at {app.docs_url}.' if app.docs_url else ''}" ) logger.info(_msg) + # Everything left in _kwargs at this point is an unrecognized flag meant + # for uvicorn.run itself; its parameters are underscored (ssl_keyfile, + # proxy_headers, ...), but parse_args only ever strips the leading "--". + _kwargs = {k.replace("-", "_"): v for k, v in _kwargs.items()} uvicorn.run("openbb_platform_api.main:app", host=host, port=port, **_kwargs) diff --git a/openbb_platform/extensions/platform_api/openbb_platform_api/app/args.py b/openbb_platform/extensions/platform_api/openbb_platform_api/app/args.py index 2e4bd076710..9391f6389c8 100644 --- a/openbb_platform/extensions/platform_api/openbb_platform_api/app/args.py +++ b/openbb_platform/extensions/platform_api/openbb_platform_api/app/args.py @@ -420,8 +420,9 @@ def parse_args() -> dict: # noqa: PLR0912 if _kwargs.get("widgets-json"): _kwargs["editable"] = True - # If the file already exists, we assume that it is already built. - if os.path.exists(_kwargs["widgets-json"]): + # If the file already exists, assume it's already built โ€” unless the + # caller explicitly asked to (re)build it. + if os.path.exists(_kwargs["widgets-json"]) and not _kwargs.get("build"): _kwargs["no-build"] = True # Handle apps-json and templates-path in the same way as widgets-path diff --git a/openbb_platform/extensions/platform_api/openbb_platform_api/app/config.py b/openbb_platform/extensions/platform_api/openbb_platform_api/app/config.py index 9dea97bf48c..436c8a41c52 100644 --- a/openbb_platform/extensions/platform_api/openbb_platform_api/app/config.py +++ b/openbb_platform/extensions/platform_api/openbb_platform_api/app/config.py @@ -320,13 +320,26 @@ def bootstrap_launcher_config( Safe to call before any ``openbb-core`` import โ€” the only modules touched are stdlib + the core's lightweight loader module. + + ``[env]`` is applied BEFORE ``apply_config_to_services`` runs. + That order matters: ``apply_config_to_services`` is what first + imports ``openbb_core.app.model.credentials``, whose ``Credentials`` + model takes a one-shot snapshot of ``os.environ`` at import time. + Pushing ``[env]`` after that import would mean a credential + supplied only via the TOML ``[env]`` table (not a real shell + export) is invisible to that snapshot. """ global _BOOTSTRAPPED_CONFIG # noqa: PLW0603 cli_path = extract_config_file_from_argv(argv) explicit_path = resolve_explicit_config_path(cli_path) - config = load_launcher_config(explicit_path=explicit_path) + config = load_launcher_config(explicit_path=explicit_path, apply_to_services=False) apply_launcher_env(config.get("env")) + + from openbb_core.app.config.loader import apply_config_to_services + + apply_config_to_services(config) + _BOOTSTRAPPED_CONFIG = config return config diff --git a/openbb_platform/extensions/platform_api/tests/app/test_app_routes.py b/openbb_platform/extensions/platform_api/tests/app/test_app_routes.py index b090e6a8532..1d31241a06d 100644 --- a/openbb_platform/extensions/platform_api/tests/app/test_app_routes.py +++ b/openbb_platform/extensions/platform_api/tests/app/test_app_routes.py @@ -177,6 +177,30 @@ def test_launch_api_invokes_uvicorn_run_with_normalized_host_and_port(): assert "use_colors" in kwargs +def test_launch_api_normalizes_hyphenated_uvicorn_flags(): + """Flags with internal hyphens (e.g. ``--ssl-keyfile``) survive + ``parse_args`` as literally-hyphenated dict keys; uvicorn's own + signature only accepts the underscored form. + """ + from openbb_platform_api.app import app as app_module + + with ( + patch.object(app_module.uvicorn, "run") as mock_run, + patch.object(app_module, "check_port", return_value=8000), + ): + app_module.launch_api( + host="127.0.0.1", + port=8000, + **{"ssl-keyfile": "/tmp/x.pem", "proxy-headers": True}, + ) + mock_run.assert_called_once() + _, kwargs = mock_run.call_args + assert kwargs["ssl_keyfile"] == "/tmp/x.pem" + assert kwargs["proxy_headers"] is True + assert "ssl-keyfile" not in kwargs + assert "proxy-headers" not in kwargs + + def test_launch_api_uses_env_vars_when_kwargs_omit_host_and_port(monkeypatch): """``OPENBB_API_HOST`` / ``OPENBB_API_PORT`` are the env-var defaults.""" from openbb_platform_api.app import app as app_module diff --git a/openbb_platform/extensions/platform_api/tests/app/test_args.py b/openbb_platform/extensions/platform_api/tests/app/test_args.py index 1c614e99267..c2a5823ac7a 100644 --- a/openbb_platform/extensions/platform_api/tests/app/test_args.py +++ b/openbb_platform/extensions/platform_api/tests/app/test_args.py @@ -205,6 +205,21 @@ def test_parse_args_widgets_json_existing_file_implies_no_build(tmp_path): assert out["editable"] is True +def test_parse_args_widgets_json_existing_file_explicit_build_wins(tmp_path): + """An explicit ``--build true`` must not be silently overridden by the + existing-file auto-``no-build`` heuristic above. + """ + widgets_file = tmp_path / "widgets.json" + widgets_file.write_text("{}") + with patch( + "sys.argv", + ["openbb-api", "--widgets-json", str(widgets_file), "--build", "true"], + ): + out = parse_args() + assert out.get("build") is True + assert out.get("no-build") is not True + + # --------------------------------------------------------------------------- # --apps-json / --templates-path resolution # --------------------------------------------------------------------------- diff --git a/openbb_platform/extensions/platform_api/tests/app/test_config.py b/openbb_platform/extensions/platform_api/tests/app/test_config.py index 3665402d7eb..52118f86a05 100644 --- a/openbb_platform/extensions/platform_api/tests/app/test_config.py +++ b/openbb_platform/extensions/platform_api/tests/app/test_config.py @@ -442,6 +442,39 @@ def test_bootstrap_launcher_config_picks_up_api_config_env(tmp_path, monkeypatch assert os.environ.get("OPENBB_CONTAINER_TEST") == "yes" +def test_bootstrap_launcher_config_applies_env_before_pushing_to_services( + tmp_path, monkeypatch +): + """``[env]`` reaches ``os.environ`` before ``apply_config_to_services`` runs. + + ``apply_config_to_services`` is what first imports + ``openbb_core.app.model.credentials``, whose ``Credentials`` model + takes a one-shot snapshot of ``os.environ`` at import time โ€” a + credential supplied only via the TOML ``[env]`` table (not a real + shell export) would be invisible to that snapshot if the order + were reversed. + """ + from openbb_platform_api.app.config import bootstrap_launcher_config + + cfg = tmp_path / "openbb.toml" + cfg.write_text('[env]\nOPENBB_ORDER_TEST_KEY = "yes"\n') + monkeypatch.delenv("OPENBB_ORDER_TEST_KEY", raising=False) + + seen: dict = {} + + def fake_apply_config_to_services(config): + seen["env_var_at_call_time"] = os.environ.get("OPENBB_ORDER_TEST_KEY") + return {"system": [], "user": []} + + with patch( + "openbb_core.app.config.loader.apply_config_to_services", + side_effect=fake_apply_config_to_services, + ): + bootstrap_launcher_config(["--config-file", str(cfg)]) + + assert seen["env_var_at_call_time"] == "yes" + + def test_load_launcher_config_explicit_missing_path_is_tolerated(tmp_path): """A missing file at the explicit path is tolerated โ€” the cascade just falls through to the next layer. Only parse failures escalate. diff --git a/openbb_platform/extensions/quantitative/README.md b/openbb_platform/extensions/quantitative/README.md index 2190c1c05d2..6c30b8d5a3e 100644 --- a/openbb_platform/extensions/quantitative/README.md +++ b/openbb_platform/extensions/quantitative/README.md @@ -42,40 +42,62 @@ obb.quantitative.unitroot_test(data=prices, target="close") # Rolling standard deviation over a moving window. obb.quantitative.rolling.stdev(data=prices, target="close", window=21) -# Rolling Sharpe ratio. +# Rolling annualized Sharpe ratio of the closing prices. obb.quantitative.performance.sharpe_ratio(data=prices, target="close") ``` ### Working with factors -The factor endpoints take two payloads โ€” a target series and a factor matrix โ€” -plus an optional risk-free column name. Pair with any factor source; for +The factor endpoints take two payloads โ€” a target return series and a factor +return matrix โ€” plus an optional risk-free column name. Both payloads must share +the same dates, frequency, and units. Pair with any factor source; for Fama-French data the [`openbb-famafrench`](../../providers/famafrench) provider -exposes the canonical research datasets. +exposes the canonical research datasets. Its monthly factors are percentages +dated the first of each month, so convert the target to monthly returns on the +same dates and scale the factors to decimal fractions. ```python -target = obb.equity.price.historical("SPY", provider="yfinance").results -factors = obb.famafrench.factors(provider="famafrench").results +import pandas as pd + +prices = obb.equity.price.historical( + "SPY", start_date="2010-01-01", provider="yfinance" +).to_df() +prices.index = pd.to_datetime(prices.index) +monthly_returns = prices["close"].resample("MS").last().pct_change().dropna() +target = [{"date": d.date(), "return": r} for d, r in monthly_returns.items()] + +factors = [ + { + "date": row.date, + "mkt_rf": row.mkt_rf / 100, + "smb": row.smb / 100, + "hml": row.hml / 100, + "rf": row.rf / 100, + } + for row in obb.famafrench.factors( + start_date="2010-01-01", provider="famafrench" + ).results +] # Multi-period regression: betas, p-values, CIs, R-squared per named window. obb.quantitative.factors( - data=target, factors_data=factors, target="close", risk_free_column="rf" + data=target, factors_data=factors, target="return", risk_free_column="rf" ) # Share of Var(target) attributable to each factor (residual sums to 1 - R^2). obb.quantitative.risk_decomposition( - data=target, factors_data=factors, target="close", risk_free_column="rf" + data=target, factors_data=factors, target="return", risk_free_column="rf" ) # Decompose the period's total return into factor contributions + alpha + residual. obb.quantitative.attribution( - data=target, factors_data=factors, target="close", risk_free_column="rf" + data=target, factors_data=factors, target="return", risk_free_column="rf" ) -# Refit OLS on a sliding window to track time-varying factor exposures. +# Refit OLS on a 36-month sliding window to track time-varying factor exposures. obb.quantitative.rolling.factors( - data=target, factors_data=factors, target="close", - window=252, step=21, risk_free_column="rf", + data=target, factors_data=factors, target="return", + window=36, step=1, risk_free_column="rf", ) ``` @@ -88,8 +110,8 @@ All commands are available under `obb.quantitative.*`. ### Metrics -- `normality` โ€” kurtosis, skewness, Jarque-Bera, Shapiro-Wilk, and Kolmogorov-Smirnov normality tests -- `capm` โ€” Capital Asset Pricing Model risk measures +- `normality` โ€” kurtosis, skewness, Jarque-Bera, Shapiro-Wilk, and Kolmogorov-Smirnov (Lilliefors) normality tests +- `capm` โ€” Capital Asset Pricing Model risk measures from monthly returns and the Fama-French market factor - `unitroot_test` โ€” Augmented Dickey-Fuller and KPSS unit root tests - `summary` โ€” descriptive summary statistics of a series @@ -120,9 +142,11 @@ All commands are available under `obb.quantitative.*`. ### Performance -- `performance.omega_ratio` โ€” Omega ratio across a range of return thresholds -- `performance.sharpe_ratio` โ€” rolling Sharpe ratio -- `performance.sortino_ratio` โ€” rolling Sortino ratio +- `performance.omega_ratio` โ€” Omega ratio of a periodic return series across a range of annualized return thresholds +- `performance.sharpe_ratio` โ€” rolling annualized Sharpe ratio of a price series +- `performance.sortino_ratio` โ€” rolling annualized Sortino ratio of a price series + +The performance ratios annualize assuming 252 periods per year. ### Charts diff --git a/openbb_platform/extensions/quantitative/integration/test_quantitative_api.py b/openbb_platform/extensions/quantitative/integration/test_quantitative_api.py index 00ff777b69e..cb6d90767b5 100644 --- a/openbb_platform/extensions/quantitative/integration/test_quantitative_api.py +++ b/openbb_platform/extensions/quantitative/integration/test_quantitative_api.py @@ -1,5 +1,3 @@ -"""Integration tests for the openbb-quantitative HTTP API interface.""" - import base64 import pytest @@ -11,7 +9,6 @@ _headers: dict = {} def get_headers(): - """Build the Basic-auth headers for the local API server.""" if _headers: return _headers @@ -22,7 +19,6 @@ def get_headers(): def _post(endpoint: str, payload: dict) -> requests.Response: - """POST a QueryParams payload to a quantitative endpoint.""" return requests.post( f"{_BASE_URL}/{endpoint}", headers=get_headers(), timeout=30, json=payload ) @@ -158,7 +154,11 @@ def test_quantitative_stats_quantile(prices_data): @pytest.mark.integration def test_quantitative_performance_omega_ratio(prices_data): - result = _post("performance/omega_ratio", {"data": prices_data, "target": "close"}) + returns = [ + {"date": current["date"], "return": current["close"] / previous["close"] - 1} + for previous, current in zip(prices_data, prices_data[1:]) + ] + result = _post("performance/omega_ratio", {"data": returns, "target": "return"}) assert result.status_code == 200 assert result.json()["results"] diff --git a/openbb_platform/extensions/quantitative/integration/test_quantitative_python.py b/openbb_platform/extensions/quantitative/integration/test_quantitative_python.py index 5a83aa34e6b..62f5c2fea47 100644 --- a/openbb_platform/extensions/quantitative/integration/test_quantitative_python.py +++ b/openbb_platform/extensions/quantitative/integration/test_quantitative_python.py @@ -1,12 +1,9 @@ -"""Integration tests for the openbb-quantitative Python interface.""" - import pytest from openbb_core.app.model.obbject import OBBject @pytest.fixture(scope="session") def obb(pytestconfig): - """Return the obb application object for the integration session.""" if pytestconfig.getoption("markexpr") != "not integration": import openbb @@ -136,7 +133,11 @@ def test_quantitative_stats_quantile(obb, prices_data): @pytest.mark.integration def test_quantitative_performance_omega_ratio(obb, prices_data): - result = obb.quantitative.performance.omega_ratio(data=prices_data, target="close") + returns = [ + {"date": current["date"], "return": current["close"] / previous["close"] - 1} + for previous, current in zip(prices_data, prices_data[1:]) + ] + result = obb.quantitative.performance.omega_ratio(data=returns, target="return") assert isinstance(result, OBBject) assert result.results diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/_stats_helpers.py b/openbb_platform/extensions/quantitative/openbb_quantitative/_stats_helpers.py index 36b5a4174bb..3071798c69b 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/_stats_helpers.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/_stats_helpers.py @@ -11,12 +11,12 @@ from scipy import stats def kurtosis_(data: DataFrame | Series | ndarray) -> float: - """Compute kurtosis - the tailedness of a distribution.""" + """Compute the excess kurtosis.""" return float(stats.kurtosis(data)) def skew_(data: DataFrame | Series | ndarray) -> float: - """Compute skewness - the asymmetry of a distribution about its mean.""" + """Compute the skewness.""" return float(stats.skew(data)) @@ -26,10 +26,10 @@ def mean_(data: DataFrame | Series | ndarray) -> float: def std_dev_(data: DataFrame | Series | ndarray) -> float: - """Compute the standard deviation - the dispersion of a set of values.""" - return float(std_np(data)) + """Compute the sample standard deviation.""" + return float(std_np(data, ddof=1)) def var_(data: DataFrame | Series | ndarray) -> float: - """Compute the variance - the squared dispersion of a set of values.""" - return float(var_np(data)) + """Compute the sample variance.""" + return float(var_np(data, ddof=1)) diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/attribution.py b/openbb_platform/extensions/quantitative/openbb_quantitative/attribution.py index 71de501cf31..cd8fee9ea43 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/attribution.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/attribution.py @@ -26,14 +26,15 @@ class ReturnAttributionQueryParams(QueryParams): __output_columns__ = ("period", "factor", "contribution", "share") data: list[Data] = Field( - description="Target time series (index column plus the target column)." + description="Target periodic return series (index column plus the target column)." ) factors_data: list[Data] = Field( - description="Factor matrix (index column plus one column per factor)." + description="Factor return matrix (index column plus one column per factor)," + " in the same units as the target." ) target: str = Field( default="close", - description="Name of the column in `data` to attribute across the factors.", + description="Name of the return column in `data` to attribute across the factors.", ) index: str = Field( default="date", diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/factors.py b/openbb_platform/extensions/quantitative/openbb_quantitative/factors.py index f9042f58d99..582afd22b77 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/factors.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/factors.py @@ -31,14 +31,15 @@ class FactorRegressionQueryParams(QueryParams): ) data: list[Data] = Field( - description="Target time series (index column plus the target column)." + description="Target periodic return series (index column plus the target column)." ) factors_data: list[Data] = Field( - description="Factor matrix (index column plus one column per factor)." + description="Factor return matrix (index column plus one column per factor)," + " in the same units as the target." ) target: str = Field( default="close", - description="Name of the column in `data` to regress on the factor matrix.", + description="Name of the return column in `data` to regress on the factor matrix.", ) index: str = Field( default="date", diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/metrics.py b/openbb_platform/extensions/quantitative/openbb_quantitative/metrics.py index 33dda3e86ea..4bd6fa3d25d 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/metrics.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/metrics.py @@ -1,6 +1,6 @@ """Quantitative analysis metrics commands.""" -from typing import Literal +from typing import Literal, cast from openbb_core.app.model.example import APIEx from openbb_core.app.model.obbject import OBBject @@ -11,6 +11,8 @@ from pydantic import Field, NonNegativeInt router = Router(prefix="", description="Quantitative analysis commands.") +_CAPM_MIN_OBSERVATIONS = 3 + class NormalityQueryParams(QueryParams): """Query parameters for the normality endpoint.""" @@ -45,10 +47,12 @@ class NormalityData(Data): shapiro_wilk_statistic: float = Field(description="Shapiro-Wilk test statistic.") shapiro_wilk_p_value: float = Field(description="p-value of the Shapiro-Wilk test.") kolmogorov_smirnov_statistic: float = Field( - description="Kolmogorov-Smirnov test statistic." + description="Kolmogorov-Smirnov test statistic against a normal distribution" + + " with the sample mean and variance (Lilliefors test)." ) kolmogorov_smirnov_p_value: float = Field( - description="p-value of the Kolmogorov-Smirnov test." + description="p-value of the Lilliefors-corrected Kolmogorov-Smirnov test," + + " bounded to the range 0.001 to 0.99." ) @@ -59,7 +63,7 @@ class CapmQueryParams(QueryParams): __output_columns__ = ("market_risk", "systematic_risk", "idiosyncratic_risk") data: list[Data] = Field(description="Input dataset.") - target: str = Field(description="Name of the column to analyze.") + target: str = Field(description="Name of the price column to analyze.") class CapmData(Data): @@ -168,8 +172,8 @@ class SummaryData(Data): count: int = Field(description="Number of observations.") mean: float = Field(description="Arithmetic mean.") - std: float = Field(description="Standard deviation.") - var: float = Field(description="Variance.") + std: float = Field(description="Sample standard deviation.") + var: float = Field(description="Sample variance.") min: float = Field(description="Minimum value.") p25: float = Field(description="25th percentile.") p50: float = Field(description="50th percentile (median).") @@ -192,6 +196,7 @@ def normality(params: NormalityQueryParams) -> OBBject[NormalityData]: """ from openbb_core.app.utils import basemodel_to_df, get_target_column from scipy import stats + from statsmodels.stats.diagnostic import lilliefors series = get_target_column(basemodel_to_df(params.data), params.target) try: @@ -203,8 +208,8 @@ def normality(params: NormalityQueryParams) -> OBBject[NormalityData]: skewness_statistic, skewness_p_value = stats.skewtest(series) jarque_bera_statistic, jarque_bera_p_value = stats.jarque_bera(series) shapiro_wilk_statistic, shapiro_wilk_p_value = stats.shapiro(series) - kolmogorov_smirnov_statistic, kolmogorov_smirnov_p_value = stats.kstest( - series, "norm" + kolmogorov_smirnov_statistic, kolmogorov_smirnov_p_value = lilliefors( + series, dist="norm" ) return OBBject( @@ -253,12 +258,22 @@ def capm(params: CapmQueryParams) -> OBBject[CapmData]: .dropna() ) monthly_return.name = "asset_return" + if len(monthly_return) < _CAPM_MIN_OBSERVATIONS: + raise ValueError( + f"CAPM requires at least {_CAPM_MIN_OBSERVATIONS} monthly returns;" + f" the data produced {len(monthly_return)}." + ) start_date = monthly_return.index.min().strftime("%Y-%m-%d") end_date = monthly_return.index.max().strftime("%Y-%m-%d") factors = get_fama_raw(start_date, end_date) merged = monthly_return.to_frame().merge(factors, left_index=True, right_index=True) merged["excess_return"] = merged["asset_return"] - merged["rf"] merged = merged.dropna() + if len(merged) < _CAPM_MIN_OBSERVATIONS: + raise ValueError( + f"CAPM requires at least {_CAPM_MIN_OBSERVATIONS} monthly returns" + f" overlapping the Fama-French factors; found {len(merged)}." + ) model = sm.OLS(merged[["excess_return"]], sm.add_constant(merged["mkt_rf"])).fit() return OBBject( @@ -289,6 +304,7 @@ def unitroot_test(params: UnitRootTestQueryParams) -> OBBject[UnitRootData]: from openbb_core.app.utils import basemodel_to_df, get_target_column from statsmodels.tools.sm_exceptions import InterpolationWarning from statsmodels.tsa import stattools + from statsmodels.tsa.stattools import KPSSResult series = get_target_column(basemodel_to_df(params.data), params.target) adf = stattools.adfuller( @@ -296,12 +312,18 @@ def unitroot_test(params: UnitRootTestQueryParams) -> OBBject[UnitRootData]: maxlag=params.maxlag, regression=params.fuller_reg, autolag=params.autolag, + result_object=True, ) - adf_icbest = adf[5] if len(adf) > 5 else None with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always", InterpolationWarning) - kpss_result = stattools.kpss( - series, regression=params.kpss_reg, nlags=params.nlags + kpss_result = cast( + KPSSResult, + stattools.kpss( + series, + regression=params.kpss_reg, + nlags=params.nlags, + result_object=True, + ), ) kpss_p_value_interpolated = not any( issubclass(entry.category, InterpolationWarning) for entry in caught @@ -309,14 +331,14 @@ def unitroot_test(params: UnitRootTestQueryParams) -> OBBject[UnitRootData]: return OBBject( results=UnitRootData( - adf_statistic=float(adf[0]), - adf_p_value=float(adf[1]), - adf_nlags=int(adf[2]), - adf_nobs=int(adf[3]), - adf_icbest=float(adf_icbest) if adf_icbest is not None else None, - kpss_statistic=float(kpss_result[0]), - kpss_p_value=float(kpss_result[1]), - kpss_nlags=int(kpss_result[2]), + adf_statistic=float(adf.statistic), + adf_p_value=float(adf.pvalue), + adf_nlags=int(adf.lags), + adf_nobs=int(adf.nobs), + adf_icbest=float(adf.icbest) if adf.icbest is not None else None, + kpss_statistic=float(kpss_result.statistic), + kpss_p_value=float(kpss_result.pvalue), + kpss_nlags=int(kpss_result.lags), kpss_p_value_interpolated=kpss_p_value_interpolated, ) ) diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/performance.py b/openbb_platform/extensions/quantitative/openbb_quantitative/performance.py index 9d147c7a119..f102a5a8e50 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/performance.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/performance.py @@ -17,6 +17,8 @@ router = Router( description="Quantitative performance-metric commands.", ) +_PERIODS_PER_YEAR = 252 + class OmegaRatioQueryParams(QueryParams): """Query parameters for the Omega ratio endpoint.""" @@ -25,12 +27,16 @@ class OmegaRatioQueryParams(QueryParams): __output_columns__ = ("threshold", "omega") data: list[Data] = Field(description="Input dataset.") - target: str = Field(description="Name of the column to analyze.") + target: str = Field(description="Name of the periodic return column to analyze.") threshold_start: float = Field( - default=0.0, description="Lower bound of the return-threshold range." + default=0.0, + description="Lower bound of the annualized return-threshold range," + + " as a decimal fraction.", ) threshold_end: float = Field( - default=1.5, description="Upper bound of the return-threshold range." + default=1.5, + description="Upper bound of the annualized return-threshold range," + + " as a decimal fraction.", ) bins: PositiveInt = Field( default=50, description="Number of evenly spaced thresholds to evaluate." @@ -40,7 +46,9 @@ class OmegaRatioQueryParams(QueryParams): class OmegaRatioData(Data): """One Omega ratio evaluated at a return threshold.""" - threshold: float = Field(description="Return threshold the ratio is evaluated at.") + threshold: float = Field( + description="Annualized return threshold the ratio is evaluated at." + ) omega: float = Field(description="Omega ratio at the threshold.") @@ -51,9 +59,9 @@ class SharpeRatioQueryParams(QueryParams): __output_columns__ = ("date", "sharpe_ratio") data: list[Data] = Field(description="Input dataset.") - target: str = Field(description="Name of the column to analyze.") + target: str = Field(description="Name of the price column to analyze.") rfr: float = Field( - default=0.0, description="Risk-free rate, as a decimal fraction." + default=0.0, description="Annualized risk-free rate, as a decimal fraction." ) window: PositiveInt = Field( default=252, description="Number of observations in each rolling window." @@ -65,7 +73,7 @@ class SharpeRatioData(Data): """One rolling Sharpe ratio observation.""" date: datetime | dateType | str = Field(description="Observation date.") - sharpe_ratio: float = Field(description="Rolling Sharpe ratio.") + sharpe_ratio: float = Field(description="Rolling annualized Sharpe ratio.") class SortinoRatioQueryParams(QueryParams): @@ -75,9 +83,10 @@ class SortinoRatioQueryParams(QueryParams): __output_columns__ = ("date", "sortino_ratio") data: list[Data] = Field(description="Input dataset.") - target: str = Field(description="Name of the column to analyze.") + target: str = Field(description="Name of the price column to analyze.") target_return: float = Field( - default=0.0, description="Minimum acceptable return, as a decimal fraction." + default=0.0, + description="Annualized minimum acceptable return, as a decimal fraction.", ) window: PositiveInt = Field( default=252, description="Number of observations in each rolling window." @@ -94,7 +103,7 @@ class SortinoRatioData(Data): """One rolling Sortino ratio observation.""" date: datetime | dateType | str = Field(description="Observation date.") - sortino_ratio: float = Field(description="Rolling Sortino ratio.") + sortino_ratio: float = Field(description="Rolling annualized Sortino ratio.") @router.command( @@ -102,20 +111,24 @@ class SortinoRatioData(Data): examples=[ APIEx( parameters={ - "target": "close", - "data": APIEx.mock_data("timeseries"), + "target": "return", + "data": APIEx.mock_data( + "timeseries", sample={"date": "2023-01-01", "return": 0.01} + ), } ), ], ) def omega_ratio(params: OmegaRatioQueryParams) -> OBBject[list[OmegaRatioData]]: - """Calculate the Omega ratio across a range of return thresholds. + """Calculate the Omega ratio of a periodic return series across return thresholds. The Omega ratio measures the probability-weighted gains above a threshold relative - to the losses below it. The ratio is evaluated at 50 thresholds spanning the - requested range, giving a profile of risk and reward rather than a single number. + to the losses below it. Each annualized threshold is converted to a per-period + threshold assuming 252 periods per year. The ratio is evaluated at `bins` + thresholds spanning the requested range, giving a profile of risk and reward + rather than a single number. """ - from numpy import linspace, sqrt + from numpy import linspace from openbb_core.app.utils import basemodel_to_df, get_target_column series = get_target_column(basemodel_to_df(params.data), params.target) @@ -123,8 +136,8 @@ def omega_ratio(params: OmegaRatioQueryParams) -> OBBject[list[OmegaRatioData]]: def get_omega_ratio(df_target, threshold: float) -> float: """Get omega ratio.""" - daily_threshold = (threshold + 1) ** sqrt(1 / 252) - 1 - excess = df_target - daily_threshold + period_threshold = (threshold + 1) ** (1 / _PERIODS_PER_YEAR) - 1 + excess = df_target - period_threshold numerator = excess[excess > 0].sum() denominator = -excess[excess < 0].sum() + epsilon return numerator / denominator @@ -151,13 +164,13 @@ def omega_ratio(params: OmegaRatioQueryParams) -> OBBject[list[OmegaRatioData]]: ], ) def sharpe_ratio(params: SharpeRatioQueryParams) -> OBBject[list[SharpeRatioData]]: - """Calculate the rolling Sharpe ratio of a return series. + """Calculate the rolling annualized Sharpe ratio of a price series. - The Sharpe ratio measures the excess return earned per unit of total volatility. - It is computed over a rolling window so the result tracks how risk-adjusted - performance evolves over time. + The prices are converted to periodic returns. In each rolling window, the mean + return in excess of the risk-free rate is divided by the standard deviation of + the returns and annualized assuming 252 periods per year. """ - from numpy import sqrt + from numpy import isfinite, sqrt from openbb_core.app.utils import basemodel_to_df, get_target_column from pandas import DataFrame @@ -165,10 +178,12 @@ def sharpe_ratio(params: SharpeRatioQueryParams) -> OBBject[list[SharpeRatioData df = basemodel_to_df(params.data, index=params.index) series = get_target_column(df, params.target) - validate_window(series, params.window) - returns = series.pct_change(fill_method=None).dropna().rolling(params.window).sum() - std = series.rolling(params.window).std() / sqrt(params.window) - ratio = ((returns - params.rfr) / std).dropna() + returns = series.pct_change(fill_method=None).dropna() + validate_window(returns, params.window) + period_rfr = (1 + params.rfr) ** (1 / _PERIODS_PER_YEAR) - 1 + window = returns.rolling(params.window) + ratio = (window.mean() - period_rfr) / window.std() * sqrt(_PERIODS_PER_YEAR) + ratio = ratio[isfinite(ratio)] frame = DataFrame({"date": ratio.index, "sharpe_ratio": ratio.to_numpy()}) out = [ @@ -192,13 +207,15 @@ def sharpe_ratio(params: SharpeRatioQueryParams) -> OBBject[list[SharpeRatioData ], ) def sortino_ratio(params: SortinoRatioQueryParams) -> OBBject[list[SortinoRatioData]]: - """Calculate the rolling Sortino ratio of a return series. + """Calculate the rolling annualized Sortino ratio of a price series. - The Sortino ratio refines the Sharpe ratio by penalizing only downside volatility - measured against a minimum acceptable return. When adjusted, the ratio is scaled by - 1/sqrt(2) so it can be compared directly with the Sharpe ratio. + The prices are converted to periodic returns. In each rolling window, the mean + return in excess of the minimum acceptable return is divided by the downside + deviation below that return and annualized assuming 252 periods per year. When + adjusted, the ratio is scaled by 1/sqrt(2) so it can be compared directly with + the Sharpe ratio. """ - from numpy import isfinite, nan, sqrt + from numpy import isfinite, sqrt from openbb_core.app.utils import basemodel_to_df, get_target_column from pandas import DataFrame @@ -206,24 +223,18 @@ def sortino_ratio(params: SortinoRatioQueryParams) -> OBBject[list[SortinoRatioD df = basemodel_to_df(params.data, index=params.index) series = get_target_column(df, params.target) - validate_window(series, params.window) - returns = ( - series.pct_change(fill_method=None) - .dropna() - .rolling(params.window) - .sum() - .dropna() + returns = series.pct_change(fill_method=None).dropna() + validate_window(returns, params.window) + period_target = (1 + params.target_return) ** (1 / _PERIODS_PER_YEAR) - 1 + excess = returns - period_target + downside_deviation = (excess.clip(upper=0.0) ** 2).rolling( + params.window + ).mean() ** 0.5 + ratio = ( + excess.rolling(params.window).mean() + / downside_deviation + * sqrt(_PERIODS_PER_YEAR) ) - - def downside_deviation(values) -> float: - """Annualized standard deviation of the negative returns in a window.""" - negative = values[values < 0] - if negative.size < 2: - return nan - return float(negative.std() / sqrt(252) * 100) - - deviation = returns.rolling(params.window).apply(downside_deviation, raw=True) - ratio = (returns - params.target_return) / deviation ratio = ratio[isfinite(ratio)] if params.adjusted: diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/risk_decomposition.py b/openbb_platform/extensions/quantitative/openbb_quantitative/risk_decomposition.py index 1bfa746592d..137a02c03d0 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/risk_decomposition.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/risk_decomposition.py @@ -25,14 +25,15 @@ class RiskDecompositionQueryParams(QueryParams): __output_columns__ = ("period", "factor", "contribution", "share") data: list[Data] = Field( - description="Target time series (index column plus the target column)." + description="Target periodic return series (index column plus the target column)." ) factors_data: list[Data] = Field( - description="Factor matrix (index column plus one column per factor)." + description="Factor return matrix (index column plus one column per factor)," + " in the same units as the target." ) target: str = Field( default="close", - description="Name of the column in `data` to decompose against the factors.", + description="Name of the return column in `data` to decompose against the factors.", ) index: str = Field( default="date", diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/rolling.py b/openbb_platform/extensions/quantitative/openbb_quantitative/rolling.py index 0e61088f10c..bc7c27352be 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/rolling.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/rolling.py @@ -56,7 +56,7 @@ class RollingVarianceData(Data): """One rolling variance observation.""" date: datetime | dateType | str = Field(description="Observation date.") - variance: float = Field(description="Rolling variance value.") + variance: float = Field(description="Rolling sample variance value.") class RollingStdevQueryParams(QueryParams): @@ -77,7 +77,7 @@ class RollingStdevData(Data): """One rolling standard deviation observation.""" date: datetime | dateType | str = Field(description="Observation date.") - stdev: float = Field(description="Rolling standard deviation value.") + stdev: float = Field(description="Rolling sample standard deviation value.") class RollingKurtosisQueryParams(QueryParams): @@ -357,7 +357,7 @@ def quantile( quantile for analyzing trends, outliers, and risk. """ from openbb_core.app.utils import basemodel_to_df, get_target_column - from pandas import concat + from pandas import DataFrame, concat from openbb_quantitative.helpers import validate_window @@ -365,22 +365,25 @@ def quantile( series = get_target_column(df, params.target) validate_window(series, params.window) roll = series.rolling(params.window) - result = ( - concat( - [ - roll.median().rename("median"), - roll.quantile(params.quantile_pct).rename("quantile"), - ], - axis=1, - ) - .dropna() - .reset_index() + result = concat( + [ + roll.median().rename("median"), + roll.quantile(params.quantile_pct).rename("quantile"), + ], + axis=1, + ).dropna() + frame = DataFrame( + { + "date": result.index, + "median": result["median"].to_numpy(), + "quantile": result["quantile"].to_numpy(), + } ) out = [ RollingQuantileData( date=r["date"], median=float(r["median"]), quantile=float(r["quantile"]) ) - for r in result.to_dict(orient="records") + for r in frame.to_dict(orient="records") ] return OBBject(results=out) @@ -392,14 +395,15 @@ class RollingFactorsQueryParams(QueryParams): __output_columns__ = ("date", "factor", "coefficient", "t_statistic") data: list[Data] = Field( - description="Target time series (index column plus the target column)." + description="Target periodic return series (index column plus the target column)." ) factors_data: list[Data] = Field( - description="Factor matrix (index column plus one column per factor)." + description="Factor return matrix (index column plus one column per factor)," + " in the same units as the target." ) target: str = Field( default="close", - description="Name of the column in `data` to regress on the factor matrix.", + description="Name of the return column in `data` to regress on the factor matrix.", ) index: str = Field( default="date", diff --git a/openbb_platform/extensions/quantitative/openbb_quantitative/stats.py b/openbb_platform/extensions/quantitative/openbb_quantitative/stats.py index 17d49417021..0272bcc903c 100644 --- a/openbb_platform/extensions/quantitative/openbb_quantitative/stats.py +++ b/openbb_platform/extensions/quantitative/openbb_quantitative/stats.py @@ -39,7 +39,7 @@ class StatsVarianceQueryParams(QueryParams): class StatsVarianceData(Data): """Variance of the analyzed series.""" - variance: float = Field(description="Variance of the distribution.") + variance: float = Field(description="Sample variance of the distribution.") class StatsStdevQueryParams(QueryParams): @@ -55,7 +55,7 @@ class StatsStdevQueryParams(QueryParams): class StatsStdevData(Data): """Standard deviation of the analyzed series.""" - stdev: float = Field(description="Standard deviation of the distribution.") + stdev: float = Field(description="Sample standard deviation of the distribution.") class StatsKurtosisQueryParams(QueryParams): diff --git a/openbb_platform/extensions/quantitative/pyproject.toml b/openbb_platform/extensions/quantitative/pyproject.toml index 3270fd8881e..140bf4f1061 100644 --- a/openbb_platform/extensions/quantitative/pyproject.toml +++ b/openbb_platform/extensions/quantitative/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "openbb-core[pandas]>=2.0.0", "numpy>=1.24", "scipy>=1.11", - "statsmodels>=0.14", + "statsmodels>=0.15", ] [project.entry-points."openbb_core_extension"] diff --git a/openbb_platform/extensions/quantitative/tests/test_metrics.py b/openbb_platform/extensions/quantitative/tests/test_metrics.py index 818abfad7c7..a028b1cf749 100644 --- a/openbb_platform/extensions/quantitative/tests/test_metrics.py +++ b/openbb_platform/extensions/quantitative/tests/test_metrics.py @@ -1,14 +1,31 @@ -"""Tests for ``openbb_quantitative.metrics`` - quantitative analysis commands.""" - import numpy as np import pandas as pd import pytest +from statsmodels.stats.diagnostic import lilliefors -from openbb_quantitative import metrics +from openbb_quantitative import metrics, stats + +MONTH_STARTS = pd.date_range("2023-02-01", periods=7, freq="MS") +MONTH_ENDS = MONTH_STARTS - pd.Timedelta(days=1) + + +def _month_end_prices(market_excess, risk_free, alpha=0.002, beta=1.5): + prices = [100.0] + for market, rf in zip(market_excess, risk_free): + prices.append(prices[-1] * (1 + rf + alpha + beta * market)) + return [ + {"date": day.date(), "close": price} for day, price in zip(MONTH_ENDS, prices) + ] + + +def _factors(market_excess, risk_free, index): + return pd.DataFrame( + {"mkt_rf": market_excess, "smb": 0.0, "hml": 0.0, "rf": risk_free}, + index=index, + ) def test_normality(prices_data): - """Normality returns finite test statistics and p-values for every test.""" out = metrics.normality( metrics.NormalityQueryParams(data=prices_data, target="close") ) @@ -20,7 +37,6 @@ def test_normality(prices_data): def test_normality_non_numeric_raises(): - """A non-numeric target column raises a clear ValueError.""" data = [{"close": "a"}, {"close": "b"}, {"close": "c"}] params = metrics.NormalityQueryParams(data=data, target="close") with pytest.raises(ValueError, match="must be numeric"): @@ -28,8 +44,6 @@ def test_normality_non_numeric_raises(): def test_capm(prices_data, monkeypatch): - """CAPM reports finite risk measures from mocked monthly Fama-French factors.""" - def fake(start_date, end_date): rng = np.random.default_rng(0) index = pd.date_range(start_date, end_date, freq="MS") @@ -52,8 +66,89 @@ def test_capm(prices_data, monkeypatch): assert np.isfinite(value) +def test_capm_recovers_known_beta(monkeypatch): + market_excess = [-0.03, -0.01, 0.01, 0.02, 0.04, -0.02] + risk_free = [0.001, 0.002, 0.001, 0.003, 0.002, 0.001] + factors = _factors(market_excess, risk_free, MONTH_STARTS[:6]) + monkeypatch.setattr( + "openbb_quantitative.helpers.get_fama_raw", lambda start, end: factors + ) + out = metrics.capm( + metrics.CapmQueryParams( + data=_month_end_prices(market_excess, risk_free), target="close" + ) + ).results + assert out.market_risk == pytest.approx(1.5) + assert out.systematic_risk == pytest.approx(1.0) + assert out.idiosyncratic_risk == pytest.approx(0.0, abs=1e-9) + + +def test_capm_single_month_of_daily_prices_raises(): + days = pd.bdate_range("2023-03-01", "2023-03-31") + data = [{"date": day.date(), "close": 100.0 + i} for i, day in enumerate(days)] + with pytest.raises( + ValueError, match="at least 3 monthly returns; the data produced 0" + ): + metrics.capm(metrics.CapmQueryParams(data=data, target="close")) + + +def test_capm_two_monthly_returns_raises(): + data = _month_end_prices([0.01, 0.02], [0.001, 0.001]) + with pytest.raises( + ValueError, match="at least 3 monthly returns; the data produced 2" + ): + metrics.capm(metrics.CapmQueryParams(data=data, target="close")) + + +def test_capm_insufficient_factor_overlap_raises(monkeypatch): + market_excess = [-0.03, -0.01, 0.01, 0.02, 0.04, -0.02] + risk_free = [0.001] * 6 + factors = _factors([0.01, 0.02], [0.001, 0.001], MONTH_STARTS[:2]) + monkeypatch.setattr( + "openbb_quantitative.helpers.get_fama_raw", lambda start, end: factors + ) + with pytest.raises( + ValueError, match="overlapping the Fama-French factors; found 2" + ): + metrics.capm( + metrics.CapmQueryParams( + data=_month_end_prices(market_excess, risk_free), target="close" + ) + ) + + +def test_normality_kolmogorov_smirnov_uses_sample_moments(): + sample = np.random.default_rng(3).normal(100.0, 2.0, 400) + data = [{"close": float(value)} for value in sample] + out = metrics.normality( + metrics.NormalityQueryParams(data=data, target="close") + ).results + statistic, p_value = lilliefors(sample, dist="norm") + assert out.kolmogorov_smirnov_statistic == pytest.approx(statistic) + assert out.kolmogorov_smirnov_p_value == pytest.approx(p_value) + assert out.kolmogorov_smirnov_p_value > 0.05 + + +def test_normality_kolmogorov_smirnov_rejects_heavy_tails(): + sample = np.random.default_rng(3).standard_t(2, 400) + data = [{"close": float(value)} for value in sample] + out = metrics.normality( + metrics.NormalityQueryParams(data=data, target="close") + ).results + assert out.kolmogorov_smirnov_p_value < 0.05 + + +def test_stats_dispersion_matches_summary(): + data = [{"close": float(value)} for value in [1, 2, 3, 4, 5]] + summary = metrics.summary(metrics.SummaryQueryParams(data=data, target="close")) + stdev = stats.stdev(stats.StatsStdevQueryParams(data=data, target="close")) + variance = stats.variance(stats.StatsVarianceQueryParams(data=data, target="close")) + assert stdev.results.stdev == pytest.approx(summary.results.std) + assert variance.results.variance == pytest.approx(summary.results.var) + assert summary.results.var == pytest.approx(2.5) + + def test_unitroot_test(prices_data): - """The unit root test reports finite ADF and KPSS statistics.""" out = metrics.unitroot_test( metrics.UnitRootTestQueryParams(data=prices_data, target="close") ) @@ -77,7 +172,6 @@ def test_unitroot_test(prices_data): def test_unitroot_test_regression_options(prices_data): - """The unit root test accepts non-default ADF and KPSS regression terms.""" out = metrics.unitroot_test( metrics.UnitRootTestQueryParams( data=prices_data, @@ -92,7 +186,6 @@ def test_unitroot_test_regression_options(prices_data): def test_unitroot_test_no_autolag(prices_data): - """With autolag disabled the ADF test uses maxlag directly and has no icbest.""" out = metrics.unitroot_test( metrics.UnitRootTestQueryParams( data=prices_data, target="close", maxlag=4, autolag=None @@ -104,7 +197,6 @@ def test_unitroot_test_no_autolag(prices_data): def test_unitroot_test_explicit_kpss_nlags(prices_data): - """The KPSS test accepts an explicit integer lag count.""" out = metrics.unitroot_test( metrics.UnitRootTestQueryParams(data=prices_data, target="close", nlags=6) ) @@ -112,7 +204,6 @@ def test_unitroot_test_explicit_kpss_nlags(prices_data): def test_summary(prices_data): - """Summary reports finite descriptive statistics for the target column.""" out = metrics.summary(metrics.SummaryQueryParams(data=prices_data, target="close")) dumped = out.results.model_dump() assert isinstance(dumped["count"], int) diff --git a/openbb_platform/extensions/quantitative/tests/test_performance.py b/openbb_platform/extensions/quantitative/tests/test_performance.py index 42ebcc313cb..98308907dc6 100644 --- a/openbb_platform/extensions/quantitative/tests/test_performance.py +++ b/openbb_platform/extensions/quantitative/tests/test_performance.py @@ -1,13 +1,31 @@ -"""Tests for ``openbb_quantitative.performance`` - performance-metric commands.""" +from math import isfinite, isnan, sqrt -from math import isfinite, isnan +import numpy as np +import pandas as pd +import pytest +from openbb_core.app.utils import df_to_basemodel from openbb_quantitative import performance +PERIODS_PER_YEAR = 252 + + +def _returns(prices_df): + closes = pd.Series( + prices_df["close"].to_numpy(), index=pd.to_datetime(prices_df["date"]) + ) + return closes.pct_change().dropna() + + +def _returns_data(prices_df): + return [ + {"date": day.date(), "return": float(value)} + for day, value in _returns(prices_df).items() + ] + def test_query_params_defaults(): - """The performance QueryParams expose the documented default values.""" - omega = performance.OmegaRatioQueryParams(data=[], target="close") + omega = performance.OmegaRatioQueryParams(data=[], target="return") assert omega.threshold_start == 0.0 assert omega.threshold_end == 1.5 assert omega.bins == 50 @@ -24,11 +42,11 @@ def test_query_params_defaults(): assert sortino.index == "date" -def test_omega_ratio(prices_data): - """The Omega ratio returns one finite ratio per threshold across 50 thresholds.""" - params = performance.OmegaRatioQueryParams(data=prices_data, target="close") - out = performance.omega_ratio(params) - results = out.results +def test_omega_ratio(prices_df): + params = performance.OmegaRatioQueryParams( + data=_returns_data(prices_df), target="return" + ) + results = performance.omega_ratio(params).results assert isinstance(results, list) assert len(results) == 50 for row in results: @@ -37,21 +55,36 @@ def test_omega_ratio(prices_data): assert isfinite(row.omega) -def test_omega_ratio_bins(prices_data): - """The Omega ratio honors an explicit threshold count.""" +def test_omega_ratio_bins(prices_df): out = performance.omega_ratio( - performance.OmegaRatioQueryParams(data=prices_data, target="close", bins=10) + performance.OmegaRatioQueryParams( + data=_returns_data(prices_df), target="return", bins=10 + ) ) assert len(out.results) == 10 +def test_omega_ratio_uses_per_period_threshold(prices_df): + out = performance.omega_ratio( + performance.OmegaRatioQueryParams( + data=_returns_data(prices_df), + target="return", + threshold_start=0.1, + threshold_end=0.1, + bins=1, + ) + ).results + excess = _returns(prices_df) - (1.1 ** (1 / PERIODS_PER_YEAR) - 1) + expected = excess[excess > 0].sum() / (-excess[excess < 0].sum() + 1e-6) + assert out[0].threshold == pytest.approx(0.1) + assert out[0].omega == pytest.approx(expected) + + def test_sharpe_ratio(prices_data): - """The rolling Sharpe ratio returns finite dated observations.""" params = performance.SharpeRatioQueryParams( data=prices_data, target="close", window=20 ) - out = performance.sharpe_ratio(params) - results = out.results + results = performance.sharpe_ratio(params).results assert isinstance(results, list) assert len(results) > 0 for row in results: @@ -60,13 +93,61 @@ def test_sharpe_ratio(prices_data): assert isfinite(row.sharpe_ratio) +def test_sharpe_ratio_matches_annualized_formula(prices_data, prices_df): + rfr = 0.03 + results = performance.sharpe_ratio( + performance.SharpeRatioQueryParams( + data=prices_data, target="close", window=60, rfr=rfr + ) + ).results + returns = _returns(prices_df) + window = returns.iloc[-60:] + period_rfr = (1 + rfr) ** (1 / PERIODS_PER_YEAR) - 1 + expected = (window.mean() - period_rfr) / window.std() * sqrt(PERIODS_PER_YEAR) + assert len(results) == len(returns) - 60 + 1 + assert results[-1].sharpe_ratio == pytest.approx(expected) + + +def test_sharpe_ratio_is_invariant_to_price_scale(prices_df): + base = performance.sharpe_ratio( + performance.SharpeRatioQueryParams( + data=df_to_basemodel(prices_df), target="close", window=60 + ) + ).results + scaled = performance.sharpe_ratio( + performance.SharpeRatioQueryParams( + data=df_to_basemodel(prices_df.assign(close=prices_df["close"] * 10)), + target="close", + window=60, + ) + ).results + assert [row.sharpe_ratio for row in scaled] == pytest.approx( + [row.sharpe_ratio for row in base] + ) + + +def test_sharpe_ratio_window_longer_than_returns_raises(prices_data): + with pytest.raises(ValueError, match="larger than the data length '249'"): + performance.sharpe_ratio( + performance.SharpeRatioQueryParams( + data=prices_data, target="close", window=250 + ) + ) + + +def test_sharpe_ratio_drops_undefined_values(prices_df): + flat = df_to_basemodel(prices_df.assign(close=100.0)) + results = performance.sharpe_ratio( + performance.SharpeRatioQueryParams(data=flat, target="close", window=20) + ).results + assert results == [] + + def test_sortino_ratio(prices_data): - """The rolling Sortino ratio returns dated numeric observations.""" params = performance.SortinoRatioQueryParams( data=prices_data, target="close", window=20 ) - out = performance.sortino_ratio(params) - results = out.results + results = performance.sortino_ratio(params).results assert isinstance(results, list) assert len(results) > 0 for row in results: @@ -75,10 +156,31 @@ def test_sortino_ratio(prices_data): assert isfinite(row.sortino_ratio) -def test_sortino_ratio_adjusted(prices_data): - """The adjusted Sortino ratio scales the unadjusted ratio by 1/sqrt(2).""" - from math import sqrt +def test_sortino_ratio_matches_downside_deviation_formula(prices_data, prices_df): + target_return = 0.02 + results = performance.sortino_ratio( + performance.SortinoRatioQueryParams( + data=prices_data, target="close", window=60, target_return=target_return + ) + ).results + returns = _returns(prices_df) + period_target = (1 + target_return) ** (1 / PERIODS_PER_YEAR) - 1 + excess = returns.iloc[-60:].to_numpy() - period_target + downside = np.sqrt(np.mean(np.minimum(excess, 0.0) ** 2)) + expected = excess.mean() / downside * sqrt(PERIODS_PER_YEAR) + assert len(results) == len(returns) - 60 + 1 + assert results[-1].sortino_ratio == pytest.approx(expected) + +def test_sortino_ratio_drops_undefined_values(prices_df): + flat = df_to_basemodel(prices_df.assign(close=100.0)) + results = performance.sortino_ratio( + performance.SortinoRatioQueryParams(data=flat, target="close", window=20) + ).results + assert results == [] + + +def test_sortino_ratio_adjusted(prices_data): unadjusted = performance.sortino_ratio( performance.SortinoRatioQueryParams( data=prices_data, target="close", window=20, adjusted=False diff --git a/openbb_platform/extensions/quantitative/tests/test_rolling.py b/openbb_platform/extensions/quantitative/tests/test_rolling.py index 88af85195c5..8194745a8fa 100644 --- a/openbb_platform/extensions/quantitative/tests/test_rolling.py +++ b/openbb_platform/extensions/quantitative/tests/test_rolling.py @@ -1,12 +1,41 @@ -"""Tests for ``openbb_quantitative.rolling`` - rolling-statistics commands.""" - from math import isfinite +import pytest +from openbb_core.app.utils import df_to_basemodel + from openbb_quantitative import rolling +def test_rolling_quantile_custom_index(prices_df): + frame = prices_df.rename(columns={"date": "timestamp"}) + out = rolling.quantile( + rolling.RollingQuantileQueryParams( + data=df_to_basemodel(frame), + target="close", + window=20, + quantile_pct=0.75, + index="timestamp", + ) + ).results + expected = frame.set_index("timestamp")["close"].rolling(20) + assert len(out) == len(frame) - 19 + assert out[-1].median == pytest.approx(expected.median().iloc[-1]) + assert out[-1].quantile == pytest.approx(expected.quantile(0.75).iloc[-1]) + + +def test_rolling_dispersion_is_sample_statistic(prices_data, prices_df): + variance = rolling.variance( + rolling.RollingVarianceQueryParams(data=prices_data, target="close", window=20) + ).results + stdev = rolling.stdev( + rolling.RollingStdevQueryParams(data=prices_data, target="close", window=20) + ).results + expected = prices_df["close"].rolling(20) + assert variance[-1].variance == pytest.approx(expected.var().iloc[-1]) + assert stdev[-1].stdev == pytest.approx(expected.std().iloc[-1]) + + def test_rolling_skew(prices_data): - """Rolling skew returns finite values for each completed window.""" params = rolling.RollingSkewQueryParams(data=prices_data, target="close", window=20) out = rolling.skew(params) results = out.results @@ -19,7 +48,6 @@ def test_rolling_skew(prices_data): def test_rolling_variance(prices_data): - """Rolling variance returns finite, non-negative values.""" params = rolling.RollingVarianceQueryParams( data=prices_data, target="close", window=20 ) @@ -35,7 +63,6 @@ def test_rolling_variance(prices_data): def test_rolling_stdev(prices_data): - """Rolling standard deviation returns finite, non-negative values.""" params = rolling.RollingStdevQueryParams( data=prices_data, target="close", window=20 ) @@ -51,7 +78,6 @@ def test_rolling_stdev(prices_data): def test_rolling_kurtosis(prices_data): - """Rolling kurtosis returns finite values for each completed window.""" params = rolling.RollingKurtosisQueryParams( data=prices_data, target="close", window=20 ) @@ -66,7 +92,6 @@ def test_rolling_kurtosis(prices_data): def test_rolling_mean(prices_data): - """Rolling mean returns finite values for each completed window.""" params = rolling.RollingMeanQueryParams(data=prices_data, target="close", window=20) out = rolling.mean(params) results = out.results @@ -79,7 +104,6 @@ def test_rolling_mean(prices_data): def test_rolling_quantile(prices_data): - """Rolling quantile returns finite median and quantile values.""" params = rolling.RollingQuantileQueryParams( data=prices_data, target="close", window=20 ) @@ -95,7 +119,6 @@ def test_rolling_quantile(prices_data): def test_rolling_query_params_defaults(): - """The rolling QueryParams expose the documented default values.""" skew_params = rolling.RollingSkewQueryParams(data=[], target="close") assert skew_params.window == 21 assert skew_params.index == "date" @@ -118,7 +141,6 @@ def test_rolling_query_params_defaults(): def test_rolling_factors_happy_path(target_returns_data, factor_matrix_data): - """Rolling factors emits one row per (window-end, regressor) with finite stats.""" params = rolling.RollingFactorsQueryParams( data=target_returns_data, factors_data=factor_matrix_data, @@ -141,7 +163,6 @@ def test_rolling_factors_happy_path(target_returns_data, factor_matrix_data): def test_rolling_factors_default_params(): - """Default ``window`` and ``step`` match the documented values.""" params = rolling.RollingFactorsQueryParams(data=[], factors_data=[], target="close") assert params.window == 252 assert params.step == 21 @@ -150,9 +171,6 @@ def test_rolling_factors_default_params(): def test_rolling_factors_window_too_large(target_returns_data, factor_matrix_data): - """A window longer than the aligned data raises ValueError.""" - import pytest - params = rolling.RollingFactorsQueryParams( data=target_returns_data, factors_data=factor_matrix_data, @@ -164,9 +182,6 @@ def test_rolling_factors_window_too_large(target_returns_data, factor_matrix_dat def test_rolling_factors_window_too_small(target_returns_data, factor_matrix_data): - """A window not large enough to fit regressors+1 raises ValueError.""" - import pytest - params = rolling.RollingFactorsQueryParams( data=target_returns_data, factors_data=factor_matrix_data, diff --git a/openbb_platform/extensions/quantitative/tests/test_stats_helpers.py b/openbb_platform/extensions/quantitative/tests/test_stats_helpers.py index 93dc0cad496..1f7f886de82 100644 --- a/openbb_platform/extensions/quantitative/tests/test_stats_helpers.py +++ b/openbb_platform/extensions/quantitative/tests/test_stats_helpers.py @@ -1,5 +1,3 @@ -"""Tests for ``openbb_quantitative._stats_helpers``.""" - import pandas as pd import pytest @@ -15,25 +13,22 @@ test_data = pd.Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) def test_kurtosis(): - """Test the kurtosis function.""" assert kurtosis_(test_data) == pytest.approx(-1.224, abs=1e-3) def test_skew(): - """Test the skewness function.""" assert skew_(test_data) == pytest.approx(0.0, abs=1e-3) def test_std_dev(): - """Test the standard deviation function.""" - assert std_dev_(test_data) == pytest.approx(2.872, abs=1e-3) + assert std_dev_(test_data) == pytest.approx(test_data.std()) + assert std_dev_(test_data.to_numpy()) == pytest.approx(3.028, abs=1e-3) def test_mean(): - """Test the mean function.""" assert mean_(test_data) == pytest.approx(5.5, abs=1e-3) def test_var(): - """Test the variance function.""" - assert var_(test_data) == pytest.approx(8.25, abs=1e-3) + assert var_(test_data) == pytest.approx(test_data.var()) + assert var_(test_data.to_numpy()) == pytest.approx(9.167, abs=1e-3) diff --git a/openbb_platform/extensions/quantitative/uv.lock b/openbb_platform/extensions/quantitative/uv.lock index b4d356b14dd..60d40a9fcc0 100644 --- a/openbb_platform/extensions/quantitative/uv.lock +++ b/openbb_platform/extensions/quantitative/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10, <4" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -8,11 +8,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -774,6 +777,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] +[[package]] +name = "formulaic" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "interface-meta" }, + { name = "narwhals" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/87/e7b9b39d218793f159d7425e264c0ac196da07f1decc333001502fac02fb/formulaic-1.2.2.tar.gz", hash = "sha256:c99e8f11ff7d327eaecaf63855ca69b7fa0da100ad6c0041ef80912fbac667e6", size = 657522, upload-time = "2026-06-02T18:24:42.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl", hash = "sha256:0f84ff49e3fc9dc0e68ab08a0a9427874021aa6c558e66b44dc634a35739b09b", size = 118939, upload-time = "2026-06-02T18:24:41.344Z" }, +] + [[package]] name = "freezegun" version = "1.5.5" @@ -994,6 +1018,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "interface-meta" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/5b/202a48a1ccdef721a95357fbe263c54ef87c18c4972df1ab5c22fe0f33d5/interface_meta-2.0.1.tar.gz", hash = "sha256:902bd9a95a12f195f15753a1080075d4eca7a2cb934fac7ac03c9e362b50796a", size = 15281, upload-time = "2026-04-30T22:35:36.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl", hash = "sha256:f38016bef9a4429b6d0792d809be7b65e9781820c674bf7f463999086b6e6323", size = 15330, upload-time = "2026-04-30T22:35:35.201Z" }, +] + [[package]] name = "ipykernel" version = "7.2.0" @@ -1055,11 +1088,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -1387,11 +1423,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } @@ -1471,28 +1510,27 @@ wheels = [ [[package]] name = "openbb-charting" -version = "3.0.0" +version = "4.0.0" source = { editable = "../../obbject_extensions/charting" } dependencies = [ { name = "openbb-core", extra = ["pandas"] }, { name = "pandas-ta-openbb" }, { name = "plotly" }, + { name = "statsmodels" }, ] [package.metadata] requires-dist = [ { name = "openbb-core", extras = ["pandas"], specifier = ">=2.0.0" }, - { name = "pandas-ta-openbb", specifier = ">=0.4.24" }, + { name = "pandas-ta-openbb", specifier = ">=0.4.25" }, { name = "plotly", specifier = ">=6.3.1" }, - { name = "pywry", marker = "extra == 'pywry'", specifier = ">=2.0.0" }, + { name = "pywry", marker = "extra == 'pywry'", specifier = ">=2.0.4" }, + { name = "statsmodels", specifier = ">=0.15" }, ] provides-extras = ["pywry"] [package.metadata.requires-dev] -dev = [ - { name = "openbb-devtools", editable = "../devtools" }, - { name = "openbb-technical", editable = "../technical" }, -] +dev = [{ name = "openbb-devtools", specifier = ">=2.0.1" }] [[package]] name = "openbb-core" @@ -1577,7 +1615,7 @@ requires-dist = [ { name = "numpy", specifier = ">=1.24" }, { name = "openbb-core", extras = ["pandas"], specifier = ">=2.0.0" }, { name = "scipy", specifier = ">=1.11" }, - { name = "statsmodels", specifier = ">=0.14" }, + { name = "statsmodels", specifier = ">=0.15" }, ] [package.metadata.requires-dev] @@ -1670,11 +1708,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -1735,7 +1776,7 @@ wheels = [ [[package]] name = "pandas-ta-openbb" -version = "0.4.24" +version = "0.4.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1746,9 +1787,9 @@ dependencies = [ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "statsmodels" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/b3/c8812123c1463480085929aebfeab0f2400cce2c0803223dcfe51f1af7f2/pandas_ta_openbb-0.4.24.tar.gz", hash = "sha256:69ff98beccbbe0ab238371af25a5c0be9f123aa474888cef74892441c353da1b", size = 135689, upload-time = "2026-03-27T08:24:46.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/9c/a3e2c4b20d9df713462dbf7f260d49fded025e69604990dd3a77f8c848a6/pandas_ta_openbb-0.4.25.tar.gz", hash = "sha256:1e71e7e64fdf00441965dd2b6c3d850be607a058f326e666dd54bf718049d8fb", size = 135752, upload-time = "2026-09-28T07:28:24.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/60/d785cb8519613fa50c4cda58cdcabb8078dfa414c3cb3663acebd1a4da04/pandas_ta_openbb-0.4.24-py3-none-any.whl", hash = "sha256:0e6538af573c9ab84a6b13c965ccfc0a930974b876f90473e71b49dcb7a38920", size = 259501, upload-time = "2026-03-27T08:24:47.915Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d6/68035f2581dbbaeae47dcdc89613eef026734fc8475563f5364e8804ddb4/pandas_ta_openbb-0.4.25-py3-none-any.whl", hash = "sha256:cebce28cdd2f69fab5f1a1455f16b52f7961d7c3255f5183c15a2f973fc8ec46", size = 259351, upload-time = "2026-09-28T07:28:10.171Z" }, ] [[package]] @@ -2617,11 +2658,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -2738,9 +2782,10 @@ wheels = [ [[package]] name = "statsmodels" -version = "0.14.6" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "formulaic" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, @@ -2750,38 +2795,62 @@ dependencies = [ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/1f/a3cbf6ed7cf6286afec694bfef561f3e51483197716717fb8476f1f0c50e/statsmodels-0.15.0.tar.gz", hash = "sha256:5d257fe58d0772bc46a557880ca78e2a8e07fec7bfd9d11074aef8e33e1aecbc", size = 15736853, upload-time = "2026-08-27T10:45:21.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/6d/9ec309a175956f88eb8420ac564297f37cf9b1f73f89db74da861052dc29/statsmodels-0.14.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4ff0649a2df674c7ffb6fa1a06bffdb82a6adf09a48e90e000a15a6aaa734b0", size = 10142419, upload-time = "2025-12-05T19:27:35.625Z" }, - { url = "https://files.pythonhosted.org/packages/86/8f/338c5568315ec5bf3ac7cd4b71e34b98cb3b0f834919c0c04a0762f878a1/statsmodels-0.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:109012088b3e370080846ab053c76d125268631410142daad2f8c10770e8e8d9", size = 10022819, upload-time = "2025-12-05T19:27:49.385Z" }, - { url = "https://files.pythonhosted.org/packages/b0/77/5fc4cbc2d608f9b483b0675f82704a8bcd672962c379fe4d82100d388dbf/statsmodels-0.14.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e93bd5d220f3cb6fc5fc1bffd5b094966cab8ee99f6c57c02e95710513d6ac3f", size = 10118927, upload-time = "2025-12-05T23:07:51.256Z" }, - { url = "https://files.pythonhosted.org/packages/94/55/b86c861c32186403fe121d9ab27bc16d05839b170d92a978beb33abb995e/statsmodels-0.14.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06eec42d682fdb09fe5d70a05930857efb141754ec5a5056a03304c1b5e32fd9", size = 10413015, upload-time = "2025-12-05T23:08:53.95Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/daf0dba729ccdc4176605f4a0fd5cfe71cdda671749dca10e74a732b8b1c/statsmodels-0.14.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0444e88557df735eda7db330806fe09d51c9f888bb1f5906cb3a61fb1a3ed4a8", size = 10441248, upload-time = "2025-12-05T23:09:09.353Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1c/2e10b7c7cc44fa418272996bf0427b8016718fd62f995d9c1f7ab37adf35/statsmodels-0.14.6-cp310-cp310-win_amd64.whl", hash = "sha256:e83a9abe653835da3b37fb6ae04b45480c1de11b3134bd40b09717192a1456ea", size = 9583410, upload-time = "2025-12-05T19:28:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/a9/4d/df4dd089b406accfc3bb5ee53ba29bb3bdf5ae61643f86f8f604baa57656/statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4", size = 10121514, upload-time = "2025-12-05T19:28:16.521Z" }, - { url = "https://files.pythonhosted.org/packages/82/af/ec48daa7f861f993b91a0dcc791d66e1cf56510a235c5cbd2ab991a31d5c/statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6", size = 10003346, upload-time = "2025-12-05T19:28:29.568Z" }, - { url = "https://files.pythonhosted.org/packages/a9/2c/c8f7aa24cd729970728f3f98822fb45149adc216f445a9301e441f7ac760/statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb", size = 10129872, upload-time = "2025-12-05T23:09:25.724Z" }, - { url = "https://files.pythonhosted.org/packages/40/c6/9ae8e9b0721e9b6eb5f340c3a0ce8cd7cce4f66e03dd81f80d60f111987f/statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca", size = 10381964, upload-time = "2025-12-05T23:09:41.326Z" }, - { url = "https://files.pythonhosted.org/packages/28/8c/cf3d30c8c2da78e2ad1f50ade8b7fabec3ff4cdfc56fbc02e097c4577f90/statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d", size = 10409611, upload-time = "2025-12-05T23:09:57.131Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cc/018f14ecb58c6cb89de9d52695740b7d1f5a982aa9ea312483ea3c3d5f77/statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7", size = 9580385, upload-time = "2025-12-05T19:28:42.778Z" }, - { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, - { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, - { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, - { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, - { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" }, - { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" }, - { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" }, - { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" }, - { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" }, - { url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" }, - { url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" }, - { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/8c/b6af48649a55aae10df1affa44c47a83a9f3109f9a32d84451f7d9b9b8d5/statsmodels-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b5561f454d6b629bb45cb505df6fdb498f960d0de111bd81f215fa47b15a72d", size = 11744547, upload-time = "2026-08-27T10:34:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/51e4be3772a7767699a4e6864e884f314c4c55e1c169775587d99f6f03c5/statsmodels-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0a42d58efadd47352f805fcf7eeb000f3399fe9cda260055a81105a922443bdd", size = 11592057, upload-time = "2026-08-27T10:34:35.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/88/ed18a489086ca94c2238424f5dc149d6eaddc1198a5b9efae71626096cba/statsmodels-0.15.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc37bb71f4b1e8c9b07e9ce0d32e01fbabe231d323484d35b58e93683a5123e1", size = 11858408, upload-time = "2026-08-27T14:28:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/92/f2/15a4d323326a52cc7d4a7b8e9fe72c85497fae514162ae2b1d8ee35aa3a2/statsmodels-0.15.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:972c33d9fe6bf907b853ba0eccb2f7cf3da90fdddbee002b8478bcaccf637e2a", size = 12109474, upload-time = "2026-08-27T14:29:22.624Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/c6cad0ef9b628340e1ac6ee16e508cff87e5e41d352035c856b7f3d4ad85/statsmodels-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d37d574412a47a71561c0b28c5f876137a86f26fa91e889a879950e22b4e9b4f", size = 12124128, upload-time = "2026-08-27T14:29:47.511Z" }, + { url = "https://files.pythonhosted.org/packages/f7/18/7ed9486977781b3e81a9447cbd04bd4a5fd7ff1807d97929778fb481e644/statsmodels-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:0e108365c5062ba1cf568a183cfc8da551ab8b2e84dbb00e1e87a6a714dd4cb3", size = 11367159, upload-time = "2026-08-27T15:08:25.734Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2e/bba0e676ff1df16be77fc70f652c0261d8ca3a7467dda5ecf39afa7bded0/statsmodels-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:103a83723f998897fbb5d5d0ff728cbe3755a90d9b1f22406bf58f305faff2e7", size = 11714869, upload-time = "2026-08-27T10:34:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/58/c8/08a71f7ad3f0506967d0ab0cdc16a6dec0fe94d0b317f5567836010a10f6/statsmodels-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0ca5d27711f171ee478d547d5a8c5424d1820a1b73c2a3d6bf63f71ad4525f71", size = 11567437, upload-time = "2026-08-27T10:35:08.711Z" }, + { url = "https://files.pythonhosted.org/packages/51/bd/e9292d873ed61f6d02864dd6a936e53c4129e10eee39117d7cd2d0da845c/statsmodels-0.15.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ef37437f740620a8da03437a29b1fc21bd9a70eeb231cd0d223cc4d766adb78", size = 11827696, upload-time = "2026-08-27T14:30:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/fa/6f/235505a633776a2c1188e583e5a507a548b4fbd21abc14e199f1a84e8ee2/statsmodels-0.15.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b67886b66d9c7ca118526accedb5c6de7ffd74c015dc0492ea0b1690192b65da", size = 12087914, upload-time = "2026-08-27T14:30:57.648Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/d64a5a3eef6cd7b1cc71645b3f625c16e024113e7ffcd1b4ec3da71605ec/statsmodels-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd184e911a1a8c52e5c1c27b43ccd10c24faec08053271ca1a35276cae0a3ada", size = 12109911, upload-time = "2026-08-27T14:31:25.914Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/77c4b5d3f61eab5676981d20f705acee8222ad31de8aca42a331dcd4cfdb/statsmodels-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:5e1214312d6362cebe0b5c53b4f478d9072559709000369dfc528211cb993e8a", size = 11361029, upload-time = "2026-08-27T10:35:24.869Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/14121b56377f2881ce94c0d0a01466585468923340d94841337d324f6be6/statsmodels-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e8deb1e3d4ab89fec2a52815f1ec924920c4e0cdb307421a4e47cfed87c03ff", size = 11654329, upload-time = "2026-08-27T15:09:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f5/6b550b5c936dd9ec5c859477698b4fcbccc826e9a4a4a52847fa51673357/statsmodels-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6e20788141df134b83ffb43b8cccf135a876188f84124835c86f011f138adee", size = 11527231, upload-time = "2026-08-27T10:35:42.669Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a5/0e7e87e0dedc68bfeecc24bea92876023db09bac1c0fe50584a7d0819fb5/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f90dc4989bd82837c80059fdce132976d4e5a80100119d16e1b00537eb55867", size = 11747428, upload-time = "2026-08-27T14:32:28.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/c7/aeadca282baf51d7b9c135674bac79ded91f4db79c69c188575d22234342/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f2bd48915cc9b3b95c3c7f6ac363b6f8aaa38172962ba5f7f6885f0dfe57f2", size = 12042643, upload-time = "2026-08-27T14:33:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/9c/44/7d3eb3836b3c054280baca7ec91bd12858825de437c6319c1212c2531d94/statsmodels-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:733887a09ff878a2e29504eda684488b26bd134ba3539ac58d2136a58914c65a", size = 12079497, upload-time = "2026-08-27T14:33:47.147Z" }, + { url = "https://files.pythonhosted.org/packages/03/e3/d863228849da191d37c54bdd72634fbbcfadd12e910e29e4bdca1b952bb6/statsmodels-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba89eb3ccb1c9c1e400d5081157305495b09350b3a65f2d66d3424ad1d3c4209", size = 11322106, upload-time = "2026-08-27T10:35:58.135Z" }, + { url = "https://files.pythonhosted.org/packages/87/17/fee8cfc100c37b1c553515dbad59607a55085a5b9beac2b008e4c03b6388/statsmodels-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09fe34ac80e147cd76cb702ba672559ee3c5dd3ba15c25a232922b46e5ffe70e", size = 11696848, upload-time = "2026-08-27T10:36:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/9e/60/a885751cd3aa796ef94b60f6d3de0c9ace4190feb3e55414ee728d2a0abc/statsmodels-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ffe08752f420f12a0b03e8f7d8f3a45417615309b1f37f10561f9a53f044737", size = 11556170, upload-time = "2026-08-27T10:36:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/482701d2dc7540897b13ead827cf8499b11c7325753b8cc4ff1a82c87a18/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a7ab98634d47048c6b7c168b3f8f71369e256d7314bc386168526f34c7699d7", size = 11752604, upload-time = "2026-08-27T10:47:50.809Z" }, + { url = "https://files.pythonhosted.org/packages/cc/88/5c8819f232448d52514b76a64cf285bc608cff9179f3eeacf58750612323/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcaa1e4499697c4e023e5fa6fb04459a34e4f4a449c45ae441992e933bde24db", size = 12055386, upload-time = "2026-08-27T10:49:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/19/a8/298e9b5c993cad3b58913d61b7e7cb0e2fba12e39d3114cd8484f922a5d1/statsmodels-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88dbe345ecf317862481b3c86305f42133082e5b366a3a8cf240a6caac404daf", size = 12088356, upload-time = "2026-08-27T10:48:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/28/f5/386c6cfda27c47df81643dc9466b3e28dc2a82152e524131977275a88e0c/statsmodels-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:ac9bce461d0c8f529f8ed331a469eaa5dd7c3213c06b62623a2e5438a6383093", size = 9809418, upload-time = "2026-08-27T10:36:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/15/b4/a8a4d89918599309bebfc09a2f793a910279f3900671bc959ac8c810edd1/statsmodels-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:081adf7e5f2da63f63cbd90491447de5d4c4c2921f19814f235bcf3e736edd3c", size = 11312877, upload-time = "2026-08-27T10:37:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/7a/79/66b57afdcbf9a8b21b1a8e4414ddb006e819b2a81243cd74c12f3301eca3/statsmodels-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3e5f870037ca154a4d177aeac68704a784b24cf2c45d86b046fc28e37a2a5584", size = 10812282, upload-time = "2026-08-27T10:37:26.708Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/daf737efb6002905bd006d296f9ad031ed3861b640075bde3b734706b49d/statsmodels-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:04e65ee05475aeceb9cb954cf96c0f9361b9cdfd497b8aae47afbab0c7c786dd", size = 11698848, upload-time = "2026-08-27T10:37:46.312Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/ca42c91e427db43917d4ab5cc6991e7e50b574bcfdf6c4c8eb9590af71d3/statsmodels-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f1443cc1ff7683f3d7a9054e6dccd92040fbd4324616a0b6dbb8af1ce7889fb", size = 11571229, upload-time = "2026-08-27T10:38:04.723Z" }, + { url = "https://files.pythonhosted.org/packages/5b/04/d803e8d014d4643d9c0a5eec78343073fbfff1c1018f776dd416c07fc076/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a45026596c71afa18543ec2af40b895609c181fc3649433c1cded5ce23ec5cd4", size = 11801588, upload-time = "2026-08-27T15:00:31.603Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/40d7678858065e9ec8efd9f37f04227b22a66632ba194e4aa43fda023f6a/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:edd803f4606061af8c7a83e2326ea294cbfa802a908dfbda4a88347301fb6820", size = 12062254, upload-time = "2026-08-27T15:00:49.803Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/16c73c3c09f4b8d85f125d7d3ae0f7092d4736a83d24c0159e4fb21632ec/statsmodels-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eb7fc1147cd8d17132190db7a8e63cd76a06145dec502816d8f3c20619923f2b", size = 12101259, upload-time = "2026-08-27T15:01:08.755Z" }, + { url = "https://files.pythonhosted.org/packages/23/4c/1138aad8e3c27ebf941d732788488cce7c28d76404a7b7fc1186faad2890/statsmodels-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:c3c1138b4d0e5b0c2387b17dafd2a0fd137c67bd869f70d4ee56feecc918cb0d", size = 11348219, upload-time = "2026-08-27T15:11:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/3a/bd/53546d8c9ab013d9a0513706d2a09a03aff12034e6f898987e8ce0f38257/statsmodels-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:74c0b63448f03020cc27a8ee698fbcf2ba91e61a9f9eee7978c0c4ed33a9b17f", size = 10866645, upload-time = "2026-08-27T10:38:20.886Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/0d38328643d4704c8de7867518c3f52ec145ff62537b2ca1e14cdeecf51b/statsmodels-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6197b4cca69fd7bc23ab9430abf1c479764cfe74767467f51964a633dfade7a5", size = 11870287, upload-time = "2026-08-27T10:38:38.271Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/fac7e03895f24a0b4883ff9fc69f9421a5df1b60e7edd7e68d49f4327593/statsmodels-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5538a734dd28efd450bb38a5d5fc94d13ee709bc497e3e4c0a24b3803e3eac39", size = 11755467, upload-time = "2026-08-27T10:38:54.683Z" }, + { url = "https://files.pythonhosted.org/packages/4c/38/5321b9f6065d1c5861e87853720603d56ae81795a83d3040410ca2684305/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dde0657d18e322eedb2540a84c0582e97afd0f78c370b45eebb0d4ac228a7a83", size = 11636274, upload-time = "2026-08-27T15:03:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/56/c75e984c492509d9ab2ba5f2c0e4eab7177cd2cec424cef9dafb42f0cc70/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90e75e726be2c9fc1d1d4c43337503a35864bdf5b4693773a08c6ee92d04a153", size = 11865818, upload-time = "2026-08-27T15:04:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/9e03975701c808767c19f394f670941a962204a4f6a00eeaf56b560348e8/statsmodels-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65e64afc2655a486ea44a5ffe4e2b05ee3abbcb10ea61d1c02d3e9e519338d9b", size = 11907800, upload-time = "2026-08-27T15:05:49.786Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/c467019a8bf1ab6a07ca88004f0a1d4ea2dae89642aa804e49437b256243/statsmodels-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:edee066ac9b171d95c3de925225331a21de1deb5b6452f252bc853e44566e72b", size = 11559083, upload-time = "2026-08-27T15:06:05.872Z" }, + { url = "https://files.pythonhosted.org/packages/54/10/dc78352367cd5bb298340b57c390c944b66236f6d026cc238bc8264c7633/statsmodels-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:40b2737456e75d96866943e33017a1dffc9166025ee33b7eb373fbe8c7a85e09", size = 11029613, upload-time = "2026-08-27T10:39:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/de/cb/a1f5ad730cb66c7d911ebb45413880333d837f1cffff990a4c6d2c541b95/statsmodels-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d48ba61db0d13d3033330e6bd1f864dfa9b48c0b952b3b0f7570206b35d552e5", size = 11702125, upload-time = "2026-08-30T16:04:07.635Z" }, + { url = "https://files.pythonhosted.org/packages/ce/db/fb95f242adce112c1507e2062ad6b7b00aea338925f2a03fbe47973c4d72/statsmodels-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:24b9917a09bbe0227047e512a40652c4b9117c2cfbf19c4a5b9971a8944a8606", size = 11571622, upload-time = "2026-08-30T16:04:29.558Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3f/f4cc7e5f4d6c8f46551aee63e59d9b964881070f552400ebb76667b39ad9/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333d0c70f269ff98b647f157d2ae7fbcf0617e6f9a5b1817fc6609766502292c", size = 11780713, upload-time = "2026-08-30T07:40:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/e9/20/fa315a013f70efa6674adfaca59365ea7562371914c408551fc3c399792f/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b946d7c922f46f045f25e50daf13439f79c05e403ba50081cad509892c8b26", size = 12070117, upload-time = "2026-08-30T07:40:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/91/43/a63f7880050c2c24056fa8d663b7c8a1816b25864660abbc73afff3f3558/statsmodels-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cf897abce5616e2763ab7d7a7fa914677df87a649971f877b83a358c98f6e291", size = 12105153, upload-time = "2026-08-30T07:40:45.814Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d4/523ddb13862c228407297754486b6c9e8777e5114f4532b667b47728c544/statsmodels-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:511a4f9cadbf91a690bfa863bb3e25dce583d8140046886fdf35fbfabfbce401", size = 11352844, upload-time = "2026-08-30T16:04:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/53/59/f3058214e61b15582ef17d761c9333def46960ee521313409b2705791029/statsmodels-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:0cb6228713fe47046b606daee63ed4473a7c246fa97cf0a6d6f20d3b0c96c051", size = 10866685, upload-time = "2026-08-30T16:05:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2d/43089b37f4bbbf7373929cd198de6cbdb0b5a4580a6e5e32bf9fe74fccab/statsmodels-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:27e9467fae83ff327c9d16ae5ea7e771bcdd66a682f49aa324eeefea5584f2d5", size = 11858149, upload-time = "2026-08-30T16:05:20.585Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/ca88421b81666cdcfc3d0eb12c714f80f2be327669ececbb5930b68a71c0/statsmodels-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ecf84918dcd410ab34d3d85d34e0b16ebb10319c9fc77dbf9a1f1a49324c59f8", size = 11739931, upload-time = "2026-08-30T16:05:37.286Z" }, + { url = "https://files.pythonhosted.org/packages/ab/64/98b9e2b1fbfdc4e2392bff349718d8dc796403006a3106ec375c59a5d3f8/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5891ad077b36d6401e44967739df3d2cdd380a0e6e9e46dadec7e3d3a828af55", size = 11657996, upload-time = "2026-08-30T07:41:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/34/6d/d8c9e73013ee854031218e8a044ba679535268bc61cf59466f19ac8d980c/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af80f9658c48792947fc754a026ffbebca61a8b483253781973f604a67cf1f8c", size = 11893402, upload-time = "2026-08-30T07:41:27.553Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f5/b9b47f3c688a8af1b199af96e098b8f20897a60b45187c57ce6fcde77a5f/statsmodels-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:583eb2a46f7c7a5c34a26715c8b6d92199bfb4f04623db5fff9902a3c7c832d3", size = 11934786, upload-time = "2026-08-30T07:41:46.809Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/608871f18bc18c6cba0b484e81fd25a023ef3ca6541f6793ec839fa49e1f/statsmodels-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:38038823fbe86f11e09433b4a0f4af5f102327c3137a1604b002ce3cf722a31f", size = 11549124, upload-time = "2026-08-30T16:05:53.179Z" }, + { url = "https://files.pythonhosted.org/packages/e3/eb/90a506dbee5cde669e2ef28565ff6b7a0791ab415eab2da82f92d033cccc/statsmodels-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:90df413d7e09474f7e7cbf8807440c1bf76298cdd9daabf9141f76d7627c9151", size = 11021454, upload-time = "2026-08-30T16:06:08.124Z" }, ] [[package]] diff --git a/openbb_platform/extensions/technical/openbb_technical/indicators/statistics.py b/openbb_platform/extensions/technical/openbb_technical/indicators/statistics.py index 3f9e4edec0d..35b52fb5d1d 100644 --- a/openbb_platform/extensions/technical/openbb_technical/indicators/statistics.py +++ b/openbb_platform/extensions/technical/openbb_technical/indicators/statistics.py @@ -4,7 +4,7 @@ from datetime import ( date as dateType, datetime, ) -from typing import Literal +from typing import Literal, cast from openbb_core.app.model.example import APIEx, PythonEx from openbb_core.app.model.obbject import OBBject @@ -547,11 +547,12 @@ def _adf_block(values, regression: str) -> dict: """Run ADF and package the result.""" from statsmodels.tsa.stattools import adfuller - result = adfuller(values, regression=regression) - stat, pvalue, _, _, crit, _ = result + result = adfuller(values, regression=regression, result_object=True) + pvalue = result.pvalue + crit = result.critical_values verdict = "stationary" if pvalue < 0.05 else "non_stationary" return { - "adf_statistic": float(stat), + "adf_statistic": float(result.statistic), "adf_pvalue": float(pvalue), "adf_critical_1pct": float(crit.get("1%")), "adf_critical_5pct": float(crit.get("5%")), @@ -561,19 +562,23 @@ def _adf_block(values, regression: str) -> dict: def _kpss_block(values, regression: str) -> dict: - """Run KPSS and package the result. KPSS supports ``c`` or ``ct`` only.""" + """Run KPSS and package the result.""" import warnings - from statsmodels.tsa.stattools import kpss + from statsmodels.tsa.stattools import KPSSResult, kpss kpss_reg: Literal["c", "ct"] = "c" if regression not in {"c", "ct"} else regression with warnings.catch_warnings(): warnings.simplefilter("ignore") - stat, pvalue, _, crit = kpss(values, regression=kpss_reg, nlags="auto") + result = cast( + KPSSResult, + kpss(values, regression=kpss_reg, nlags="auto", result_object=True), + ) + pvalue = result.pvalue verdict = "non_stationary" if pvalue < 0.05 else "stationary" - crit_map: dict[str, float] = dict(crit) + crit_map: dict[str, float] = dict(result.critical_values) return { - "kpss_statistic": float(stat), + "kpss_statistic": float(result.statistic), "kpss_pvalue": float(pvalue), "kpss_critical_1pct": crit_map.get("1%", float("nan")), "kpss_critical_5pct": crit_map.get("5%", float("nan")), @@ -585,7 +590,7 @@ def _kpss_block(values, regression: str) -> dict: def _overall_verdict(adf, kpss_, regression: str) -> str: """Combine ADF + KPSS verdicts into an overall verdict.""" if adf is None and kpss_ is None: - return "inconclusive" # pragma: no cover - guarded by the caller + return "inconclusive" # pragma: no cover if adf is not None and kpss_ is None: return "stationary" if adf == "stationary" else "non_stationary" if kpss_ is not None and adf is None: @@ -908,6 +913,7 @@ def autocorrelation( ) -> OBBject[list[AutocorrelationData]]: """Compute autocorrelation (ACF) and partial autocorrelation (PACF) with bands.""" from statsmodels.tsa.stattools import ( + PacfResult, acf as _acf, pacf as _pacf, ) @@ -921,12 +927,18 @@ def autocorrelation( acf_lower = None acf_upper = None if params.method in {"acf", "both"}: - acf_vals, conf = _acf(series.to_numpy(), nlags=params.max_lag, alpha=0.05) + acf_result = _acf( + series.to_numpy(), nlags=params.max_lag, alpha=0.05, result_object=True + ) + acf_vals, conf = acf_result.acf, acf_result.confint acf_lower = conf[:, 0] - acf_vals acf_upper = conf[:, 1] - acf_vals pacf_vals = None if params.method in {"pacf", "both"}: - pacf_vals = _pacf(series.to_numpy(), nlags=params.max_lag) + pacf_vals = cast( + PacfResult, + _pacf(series.to_numpy(), nlags=params.max_lag, result_object=True), + ).pacf out: list[AutocorrelationData] = [] for lag in range(params.max_lag + 1): acf_v = float(acf_vals[lag]) if acf_vals is not None else None diff --git a/openbb_platform/extensions/technical/openbb_technical/technical_views.py b/openbb_platform/extensions/technical/openbb_technical/technical_views.py index 6a466109a16..9d908fcb485 100644 --- a/openbb_platform/extensions/technical/openbb_technical/technical_views.py +++ b/openbb_platform/extensions/technical/openbb_technical/technical_views.py @@ -8,6 +8,30 @@ if TYPE_CHECKING: ) +def _resolve_price_data(kwargs: dict, index: str = "date"): + """Return the original OHLCV series a chart should draw against. + + The caller's own ``data`` argument is repacked by ``CommandRunner._chart`` + into ``kwargs["extra_params"]["data"]`` before reaching the chart function, + so the indicator's own (price-column-free) result is checked last. + """ + from openbb_core.app.utils import basemodel_to_df + from pandas import DataFrame + + data = kwargs.get("data") + if data is None: + data = kwargs.get("extra_params", {}).get("data") + + if isinstance(data, DataFrame) and not data.empty: + return data.set_index(index) if index in data.columns else data + + if isinstance(data, list) and data: + df = DataFrame(data) if isinstance(data[0], dict) else basemodel_to_df(data) + return df.set_index(index) if index in df.columns else df + + return basemodel_to_df(kwargs["obbject_item"], index=index) + + class TechnicalViews: """Technical Views.""" @@ -52,18 +76,8 @@ class TechnicalViews: from openbb_charting.core.plotly_ta.ta_class import ( PlotlyTA, ) - from openbb_core.app.utils import basemodel_to_df - from pandas import DataFrame - if "data" in kwargs and isinstance(kwargs["data"], DataFrame): - data = kwargs["data"] - else: - data = basemodel_to_df( - kwargs["obbject_item"], index=kwargs.get("index", "date") - ) - - if "date" in data.columns: - data = data.set_index("date") + data = _resolve_price_data(kwargs, kwargs.get("index", "date")) if "symbol" in data.columns and len(data.symbol.unique()) > 1: raise ValueError( @@ -98,18 +112,8 @@ class TechnicalViews: from openbb_charting.core.plotly_ta.ta_class import ( PlotlyTA, ) - from openbb_core.app.utils import basemodel_to_df - from pandas import DataFrame - if "data" in kwargs and isinstance(kwargs["data"], DataFrame): - data = kwargs["data"] - else: - data = basemodel_to_df( - kwargs["obbject_item"], index=kwargs.get("index", "date") - ) - - if "date" in data.columns: - data = data.set_index("date") + data = _resolve_price_data(kwargs, kwargs.get("index", "date")) if "symbol" in data.columns and len(data.symbol.unique()) > 1: raise ValueError( @@ -142,18 +146,8 @@ class TechnicalViews: from openbb_charting.core.plotly_ta.ta_class import ( PlotlyTA, ) - from openbb_core.app.utils import basemodel_to_df - from pandas import DataFrame - if "data" in kwargs and isinstance(kwargs["data"], DataFrame): - data = kwargs["data"] - else: - data = basemodel_to_df( - kwargs["obbject_item"], index=kwargs.get("index", "date") - ) - - if "date" in data.columns: - data = data.set_index("date") + data = _resolve_price_data(kwargs, kwargs.get("index", "date")) if "symbol" in data.columns and len(data.symbol.unique()) > 1: raise ValueError( @@ -183,18 +177,8 @@ class TechnicalViews: from openbb_charting.core.plotly_ta.ta_class import ( PlotlyTA, ) - from openbb_core.app.utils import basemodel_to_df - from pandas import DataFrame - if "data" in kwargs and isinstance(kwargs["data"], DataFrame): - data = kwargs["data"] - else: - data = basemodel_to_df( - kwargs["obbject_item"], index=kwargs.get("index", "date") - ) - - if "date" in data.columns: - data = data.set_index("date") + data = _resolve_price_data(kwargs, kwargs.get("index", "date")) if "symbol" in data.columns and len(data.symbol.unique()) > 1: raise ValueError( @@ -432,15 +416,9 @@ def _ta_ma(**kwargs): from openbb_charting.styles.colors import ( LARGE_CYCLER, ) - from openbb_core.app.utils import basemodel_to_df from pandas import DataFrame - index = ( - kwargs.get("index") - if "index" in kwargs and kwargs.get("index") is not None - else "date" - ) - data = kwargs.get("data") + index = kwargs.get("index") or "date" ma_type = ( kwargs["ma_type"] if "ma_type" in kwargs and kwargs.get("ma_type") is not None @@ -448,14 +426,7 @@ def _ta_ma(**kwargs): ) ma_types = ma_type.split(",") if isinstance(ma_type, str) else ma_type - if isinstance(data, DataFrame) and not data.empty: - data = data.set_index(index) if index in data.columns else data - - if data is None: - data = basemodel_to_df(kwargs["obbject_item"], index=index) - - if isinstance(data, list): - data = basemodel_to_df(data, index=index) + data = _resolve_price_data(kwargs, index) window = ( kwargs.get("length", []) diff --git a/openbb_platform/extensions/technical/pyproject.toml b/openbb_platform/extensions/technical/pyproject.toml index 19807069b05..2afb3ab437a 100644 --- a/openbb_platform/extensions/technical/pyproject.toml +++ b/openbb_platform/extensions/technical/pyproject.toml @@ -8,9 +8,9 @@ readme = "README.md" requires-python = ">=3.10,<4" dependencies = [ "openbb-core[pandas]>=2.0.0", - "pandas-ta-openbb>=0.4.23", + "pandas-ta-openbb>=0.4.25", "scipy>=1.11", - "statsmodels>=0.14", + "statsmodels>=0.15", ] [project.entry-points."openbb_core_extension"] diff --git a/openbb_platform/extensions/technical/tests/test_technical_views.py b/openbb_platform/extensions/technical/tests/test_technical_views.py index 149192bff4f..409935c8973 100644 --- a/openbb_platform/extensions/technical/tests/test_technical_views.py +++ b/openbb_platform/extensions/technical/tests/test_technical_views.py @@ -177,6 +177,33 @@ class TestTaMaHelper: fig, _ = _ta_ma(data=_records_to_data(ohlcv_records)) assert fig is not None + def test_falls_back_to_extra_params_data_when_obbject_item_is_thin( + self, ohlcv_records, ta_accessor + ): + """The command's own result (obbject_item) carries no price columns, + as with sma/rsi/macd/adx/aroon; the original price series should be + recovered from extra_params rather than raising on a missing target. + """ + from openbb_technical.technical_views import _ta_ma + + thin_results = [{"date": r["date"], "sma": 1.0} for r in ohlcv_records] + fig, _ = _ta_ma( + obbject_item=thin_results, + extra_params={"data": _records_to_data(ohlcv_records)}, + ) + assert fig is not None + + def test_extra_params_data_as_records(self, ohlcv_records, ta_accessor): + """extra_params.data may be plain dicts, not just Data instances.""" + from openbb_technical.technical_views import _ta_ma + + thin_results = [{"date": r["date"], "sma": 1.0} for r in ohlcv_records] + fig, _ = _ta_ma( + obbject_item=thin_results, + extra_params={"data": ohlcv_records}, + ) + assert fig is not None + def test_with_index_column_already_set(self, ohlcv_df, ta_accessor): from openbb_technical.technical_views import _ta_ma @@ -367,6 +394,22 @@ class TestTechnicalRsi: ) assert fig is not None + def test_falls_back_to_extra_params_data_when_obbject_item_is_thin( + self, ohlcv_records + ): + """rsi's own result carries no price columns; CommandRunner._chart nests + the caller's original data under extra_params, which is where the real + series must be recovered from. + """ + from openbb_technical.technical_views import TechnicalViews + + thin_results = [{"date": r["date"], "rsi": 50.0} for r in ohlcv_records] + fig, _ = TechnicalViews.technical_rsi( + obbject_item=thin_results, + extra_params={"data": _records_to_data(ohlcv_records)}, + ) + assert fig is not None + def test_date_column_reset(self, ohlcv_df): from openbb_technical.technical_views import TechnicalViews diff --git a/openbb_platform/extensions/technical/uv.lock b/openbb_platform/extensions/technical/uv.lock index a011e688af4..0eec988e158 100644 --- a/openbb_platform/extensions/technical/uv.lock +++ b/openbb_platform/extensions/technical/uv.lock @@ -8,11 +8,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -774,6 +777,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] +[[package]] +name = "formulaic" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "interface-meta" }, + { name = "narwhals" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/87/e7b9b39d218793f159d7425e264c0ac196da07f1decc333001502fac02fb/formulaic-1.2.2.tar.gz", hash = "sha256:c99e8f11ff7d327eaecaf63855ca69b7fa0da100ad6c0041ef80912fbac667e6", size = 657522, upload-time = "2026-06-02T18:24:42.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl", hash = "sha256:0f84ff49e3fc9dc0e68ab08a0a9427874021aa6c558e66b44dc634a35739b09b", size = 118939, upload-time = "2026-06-02T18:24:41.344Z" }, +] + [[package]] name = "freezegun" version = "1.5.5" @@ -994,6 +1018,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "interface-meta" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/5b/202a48a1ccdef721a95357fbe263c54ef87c18c4972df1ab5c22fe0f33d5/interface_meta-2.0.1.tar.gz", hash = "sha256:902bd9a95a12f195f15753a1080075d4eca7a2cb934fac7ac03c9e362b50796a", size = 15281, upload-time = "2026-04-30T22:35:36.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl", hash = "sha256:f38016bef9a4429b6d0792d809be7b65e9781820c674bf7f463999086b6e6323", size = 15330, upload-time = "2026-04-30T22:35:35.201Z" }, +] + [[package]] name = "ipykernel" version = "7.2.0" @@ -1055,11 +1088,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -1387,11 +1423,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/50/8e/b8041bc719f056afd864478029d52214789341ac6583437b0ee5031e9530/numpy-2.4.5.tar.gz", hash = "sha256:ca670567a5683b7c1670ec03e0ddd5862e10934e92a70751d68d7b7b74ca7f9f", size = 20735669, upload-time = "2026-05-15T20:25:19.492Z" } @@ -1563,9 +1602,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "openbb-core", extras = ["pandas"], specifier = ">=2.0.0" }, - { name = "pandas-ta-openbb", specifier = ">=0.4.23" }, + { name = "pandas-ta-openbb", specifier = ">=0.4.25" }, { name = "scipy", specifier = ">=1.11" }, - { name = "statsmodels", specifier = ">=0.14" }, + { name = "statsmodels", specifier = ">=0.15" }, ] [package.metadata.requires-dev] @@ -1658,11 +1697,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -1723,7 +1765,7 @@ wheels = [ [[package]] name = "pandas-ta-openbb" -version = "0.4.24" +version = "0.4.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1734,9 +1776,9 @@ dependencies = [ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "statsmodels" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/b3/c8812123c1463480085929aebfeab0f2400cce2c0803223dcfe51f1af7f2/pandas_ta_openbb-0.4.24.tar.gz", hash = "sha256:69ff98beccbbe0ab238371af25a5c0be9f123aa474888cef74892441c353da1b", size = 135689, upload-time = "2026-03-27T08:24:46.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/9c/a3e2c4b20d9df713462dbf7f260d49fded025e69604990dd3a77f8c848a6/pandas_ta_openbb-0.4.25.tar.gz", hash = "sha256:1e71e7e64fdf00441965dd2b6c3d850be607a058f326e666dd54bf718049d8fb", size = 135752, upload-time = "2026-09-28T07:28:24.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/60/d785cb8519613fa50c4cda58cdcabb8078dfa414c3cb3663acebd1a4da04/pandas_ta_openbb-0.4.24-py3-none-any.whl", hash = "sha256:0e6538af573c9ab84a6b13c965ccfc0a930974b876f90473e71b49dcb7a38920", size = 259501, upload-time = "2026-03-27T08:24:47.915Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d6/68035f2581dbbaeae47dcdc89613eef026734fc8475563f5364e8804ddb4/pandas_ta_openbb-0.4.25-py3-none-any.whl", hash = "sha256:cebce28cdd2f69fab5f1a1455f16b52f7961d7c3255f5183c15a2f973fc8ec46", size = 259351, upload-time = "2026-09-28T07:28:10.171Z" }, ] [[package]] @@ -2605,11 +2647,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -2726,9 +2771,10 @@ wheels = [ [[package]] name = "statsmodels" -version = "0.14.6" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "formulaic" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, @@ -2738,38 +2784,62 @@ dependencies = [ { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/1f/a3cbf6ed7cf6286afec694bfef561f3e51483197716717fb8476f1f0c50e/statsmodels-0.15.0.tar.gz", hash = "sha256:5d257fe58d0772bc46a557880ca78e2a8e07fec7bfd9d11074aef8e33e1aecbc", size = 15736853, upload-time = "2026-08-27T10:45:21.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/6d/9ec309a175956f88eb8420ac564297f37cf9b1f73f89db74da861052dc29/statsmodels-0.14.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4ff0649a2df674c7ffb6fa1a06bffdb82a6adf09a48e90e000a15a6aaa734b0", size = 10142419, upload-time = "2025-12-05T19:27:35.625Z" }, - { url = "https://files.pythonhosted.org/packages/86/8f/338c5568315ec5bf3ac7cd4b71e34b98cb3b0f834919c0c04a0762f878a1/statsmodels-0.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:109012088b3e370080846ab053c76d125268631410142daad2f8c10770e8e8d9", size = 10022819, upload-time = "2025-12-05T19:27:49.385Z" }, - { url = "https://files.pythonhosted.org/packages/b0/77/5fc4cbc2d608f9b483b0675f82704a8bcd672962c379fe4d82100d388dbf/statsmodels-0.14.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e93bd5d220f3cb6fc5fc1bffd5b094966cab8ee99f6c57c02e95710513d6ac3f", size = 10118927, upload-time = "2025-12-05T23:07:51.256Z" }, - { url = "https://files.pythonhosted.org/packages/94/55/b86c861c32186403fe121d9ab27bc16d05839b170d92a978beb33abb995e/statsmodels-0.14.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06eec42d682fdb09fe5d70a05930857efb141754ec5a5056a03304c1b5e32fd9", size = 10413015, upload-time = "2025-12-05T23:08:53.95Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/daf0dba729ccdc4176605f4a0fd5cfe71cdda671749dca10e74a732b8b1c/statsmodels-0.14.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0444e88557df735eda7db330806fe09d51c9f888bb1f5906cb3a61fb1a3ed4a8", size = 10441248, upload-time = "2025-12-05T23:09:09.353Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1c/2e10b7c7cc44fa418272996bf0427b8016718fd62f995d9c1f7ab37adf35/statsmodels-0.14.6-cp310-cp310-win_amd64.whl", hash = "sha256:e83a9abe653835da3b37fb6ae04b45480c1de11b3134bd40b09717192a1456ea", size = 9583410, upload-time = "2025-12-05T19:28:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/a9/4d/df4dd089b406accfc3bb5ee53ba29bb3bdf5ae61643f86f8f604baa57656/statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4", size = 10121514, upload-time = "2025-12-05T19:28:16.521Z" }, - { url = "https://files.pythonhosted.org/packages/82/af/ec48daa7f861f993b91a0dcc791d66e1cf56510a235c5cbd2ab991a31d5c/statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6", size = 10003346, upload-time = "2025-12-05T19:28:29.568Z" }, - { url = "https://files.pythonhosted.org/packages/a9/2c/c8f7aa24cd729970728f3f98822fb45149adc216f445a9301e441f7ac760/statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb", size = 10129872, upload-time = "2025-12-05T23:09:25.724Z" }, - { url = "https://files.pythonhosted.org/packages/40/c6/9ae8e9b0721e9b6eb5f340c3a0ce8cd7cce4f66e03dd81f80d60f111987f/statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca", size = 10381964, upload-time = "2025-12-05T23:09:41.326Z" }, - { url = "https://files.pythonhosted.org/packages/28/8c/cf3d30c8c2da78e2ad1f50ade8b7fabec3ff4cdfc56fbc02e097c4577f90/statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d", size = 10409611, upload-time = "2025-12-05T23:09:57.131Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cc/018f14ecb58c6cb89de9d52695740b7d1f5a982aa9ea312483ea3c3d5f77/statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7", size = 9580385, upload-time = "2025-12-05T19:28:42.778Z" }, - { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, - { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, - { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, - { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, - { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" }, - { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" }, - { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" }, - { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" }, - { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" }, - { url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" }, - { url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" }, - { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/8c/b6af48649a55aae10df1affa44c47a83a9f3109f9a32d84451f7d9b9b8d5/statsmodels-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b5561f454d6b629bb45cb505df6fdb498f960d0de111bd81f215fa47b15a72d", size = 11744547, upload-time = "2026-08-27T10:34:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/51e4be3772a7767699a4e6864e884f314c4c55e1c169775587d99f6f03c5/statsmodels-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0a42d58efadd47352f805fcf7eeb000f3399fe9cda260055a81105a922443bdd", size = 11592057, upload-time = "2026-08-27T10:34:35.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/88/ed18a489086ca94c2238424f5dc149d6eaddc1198a5b9efae71626096cba/statsmodels-0.15.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc37bb71f4b1e8c9b07e9ce0d32e01fbabe231d323484d35b58e93683a5123e1", size = 11858408, upload-time = "2026-08-27T14:28:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/92/f2/15a4d323326a52cc7d4a7b8e9fe72c85497fae514162ae2b1d8ee35aa3a2/statsmodels-0.15.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:972c33d9fe6bf907b853ba0eccb2f7cf3da90fdddbee002b8478bcaccf637e2a", size = 12109474, upload-time = "2026-08-27T14:29:22.624Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/c6cad0ef9b628340e1ac6ee16e508cff87e5e41d352035c856b7f3d4ad85/statsmodels-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d37d574412a47a71561c0b28c5f876137a86f26fa91e889a879950e22b4e9b4f", size = 12124128, upload-time = "2026-08-27T14:29:47.511Z" }, + { url = "https://files.pythonhosted.org/packages/f7/18/7ed9486977781b3e81a9447cbd04bd4a5fd7ff1807d97929778fb481e644/statsmodels-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:0e108365c5062ba1cf568a183cfc8da551ab8b2e84dbb00e1e87a6a714dd4cb3", size = 11367159, upload-time = "2026-08-27T15:08:25.734Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2e/bba0e676ff1df16be77fc70f652c0261d8ca3a7467dda5ecf39afa7bded0/statsmodels-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:103a83723f998897fbb5d5d0ff728cbe3755a90d9b1f22406bf58f305faff2e7", size = 11714869, upload-time = "2026-08-27T10:34:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/58/c8/08a71f7ad3f0506967d0ab0cdc16a6dec0fe94d0b317f5567836010a10f6/statsmodels-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0ca5d27711f171ee478d547d5a8c5424d1820a1b73c2a3d6bf63f71ad4525f71", size = 11567437, upload-time = "2026-08-27T10:35:08.711Z" }, + { url = "https://files.pythonhosted.org/packages/51/bd/e9292d873ed61f6d02864dd6a936e53c4129e10eee39117d7cd2d0da845c/statsmodels-0.15.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ef37437f740620a8da03437a29b1fc21bd9a70eeb231cd0d223cc4d766adb78", size = 11827696, upload-time = "2026-08-27T14:30:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/fa/6f/235505a633776a2c1188e583e5a507a548b4fbd21abc14e199f1a84e8ee2/statsmodels-0.15.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b67886b66d9c7ca118526accedb5c6de7ffd74c015dc0492ea0b1690192b65da", size = 12087914, upload-time = "2026-08-27T14:30:57.648Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/d64a5a3eef6cd7b1cc71645b3f625c16e024113e7ffcd1b4ec3da71605ec/statsmodels-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd184e911a1a8c52e5c1c27b43ccd10c24faec08053271ca1a35276cae0a3ada", size = 12109911, upload-time = "2026-08-27T14:31:25.914Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/77c4b5d3f61eab5676981d20f705acee8222ad31de8aca42a331dcd4cfdb/statsmodels-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:5e1214312d6362cebe0b5c53b4f478d9072559709000369dfc528211cb993e8a", size = 11361029, upload-time = "2026-08-27T10:35:24.869Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/14121b56377f2881ce94c0d0a01466585468923340d94841337d324f6be6/statsmodels-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e8deb1e3d4ab89fec2a52815f1ec924920c4e0cdb307421a4e47cfed87c03ff", size = 11654329, upload-time = "2026-08-27T15:09:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f5/6b550b5c936dd9ec5c859477698b4fcbccc826e9a4a4a52847fa51673357/statsmodels-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6e20788141df134b83ffb43b8cccf135a876188f84124835c86f011f138adee", size = 11527231, upload-time = "2026-08-27T10:35:42.669Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a5/0e7e87e0dedc68bfeecc24bea92876023db09bac1c0fe50584a7d0819fb5/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f90dc4989bd82837c80059fdce132976d4e5a80100119d16e1b00537eb55867", size = 11747428, upload-time = "2026-08-27T14:32:28.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/c7/aeadca282baf51d7b9c135674bac79ded91f4db79c69c188575d22234342/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f2bd48915cc9b3b95c3c7f6ac363b6f8aaa38172962ba5f7f6885f0dfe57f2", size = 12042643, upload-time = "2026-08-27T14:33:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/9c/44/7d3eb3836b3c054280baca7ec91bd12858825de437c6319c1212c2531d94/statsmodels-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:733887a09ff878a2e29504eda684488b26bd134ba3539ac58d2136a58914c65a", size = 12079497, upload-time = "2026-08-27T14:33:47.147Z" }, + { url = "https://files.pythonhosted.org/packages/03/e3/d863228849da191d37c54bdd72634fbbcfadd12e910e29e4bdca1b952bb6/statsmodels-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba89eb3ccb1c9c1e400d5081157305495b09350b3a65f2d66d3424ad1d3c4209", size = 11322106, upload-time = "2026-08-27T10:35:58.135Z" }, + { url = "https://files.pythonhosted.org/packages/87/17/fee8cfc100c37b1c553515dbad59607a55085a5b9beac2b008e4c03b6388/statsmodels-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09fe34ac80e147cd76cb702ba672559ee3c5dd3ba15c25a232922b46e5ffe70e", size = 11696848, upload-time = "2026-08-27T10:36:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/9e/60/a885751cd3aa796ef94b60f6d3de0c9ace4190feb3e55414ee728d2a0abc/statsmodels-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ffe08752f420f12a0b03e8f7d8f3a45417615309b1f37f10561f9a53f044737", size = 11556170, upload-time = "2026-08-27T10:36:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/482701d2dc7540897b13ead827cf8499b11c7325753b8cc4ff1a82c87a18/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a7ab98634d47048c6b7c168b3f8f71369e256d7314bc386168526f34c7699d7", size = 11752604, upload-time = "2026-08-27T10:47:50.809Z" }, + { url = "https://files.pythonhosted.org/packages/cc/88/5c8819f232448d52514b76a64cf285bc608cff9179f3eeacf58750612323/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcaa1e4499697c4e023e5fa6fb04459a34e4f4a449c45ae441992e933bde24db", size = 12055386, upload-time = "2026-08-27T10:49:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/19/a8/298e9b5c993cad3b58913d61b7e7cb0e2fba12e39d3114cd8484f922a5d1/statsmodels-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88dbe345ecf317862481b3c86305f42133082e5b366a3a8cf240a6caac404daf", size = 12088356, upload-time = "2026-08-27T10:48:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/28/f5/386c6cfda27c47df81643dc9466b3e28dc2a82152e524131977275a88e0c/statsmodels-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:ac9bce461d0c8f529f8ed331a469eaa5dd7c3213c06b62623a2e5438a6383093", size = 9809418, upload-time = "2026-08-27T10:36:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/15/b4/a8a4d89918599309bebfc09a2f793a910279f3900671bc959ac8c810edd1/statsmodels-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:081adf7e5f2da63f63cbd90491447de5d4c4c2921f19814f235bcf3e736edd3c", size = 11312877, upload-time = "2026-08-27T10:37:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/7a/79/66b57afdcbf9a8b21b1a8e4414ddb006e819b2a81243cd74c12f3301eca3/statsmodels-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3e5f870037ca154a4d177aeac68704a784b24cf2c45d86b046fc28e37a2a5584", size = 10812282, upload-time = "2026-08-27T10:37:26.708Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/daf737efb6002905bd006d296f9ad031ed3861b640075bde3b734706b49d/statsmodels-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:04e65ee05475aeceb9cb954cf96c0f9361b9cdfd497b8aae47afbab0c7c786dd", size = 11698848, upload-time = "2026-08-27T10:37:46.312Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/ca42c91e427db43917d4ab5cc6991e7e50b574bcfdf6c4c8eb9590af71d3/statsmodels-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f1443cc1ff7683f3d7a9054e6dccd92040fbd4324616a0b6dbb8af1ce7889fb", size = 11571229, upload-time = "2026-08-27T10:38:04.723Z" }, + { url = "https://files.pythonhosted.org/packages/5b/04/d803e8d014d4643d9c0a5eec78343073fbfff1c1018f776dd416c07fc076/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a45026596c71afa18543ec2af40b895609c181fc3649433c1cded5ce23ec5cd4", size = 11801588, upload-time = "2026-08-27T15:00:31.603Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/40d7678858065e9ec8efd9f37f04227b22a66632ba194e4aa43fda023f6a/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:edd803f4606061af8c7a83e2326ea294cbfa802a908dfbda4a88347301fb6820", size = 12062254, upload-time = "2026-08-27T15:00:49.803Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/16c73c3c09f4b8d85f125d7d3ae0f7092d4736a83d24c0159e4fb21632ec/statsmodels-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eb7fc1147cd8d17132190db7a8e63cd76a06145dec502816d8f3c20619923f2b", size = 12101259, upload-time = "2026-08-27T15:01:08.755Z" }, + { url = "https://files.pythonhosted.org/packages/23/4c/1138aad8e3c27ebf941d732788488cce7c28d76404a7b7fc1186faad2890/statsmodels-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:c3c1138b4d0e5b0c2387b17dafd2a0fd137c67bd869f70d4ee56feecc918cb0d", size = 11348219, upload-time = "2026-08-27T15:11:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/3a/bd/53546d8c9ab013d9a0513706d2a09a03aff12034e6f898987e8ce0f38257/statsmodels-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:74c0b63448f03020cc27a8ee698fbcf2ba91e61a9f9eee7978c0c4ed33a9b17f", size = 10866645, upload-time = "2026-08-27T10:38:20.886Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/0d38328643d4704c8de7867518c3f52ec145ff62537b2ca1e14cdeecf51b/statsmodels-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6197b4cca69fd7bc23ab9430abf1c479764cfe74767467f51964a633dfade7a5", size = 11870287, upload-time = "2026-08-27T10:38:38.271Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/fac7e03895f24a0b4883ff9fc69f9421a5df1b60e7edd7e68d49f4327593/statsmodels-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5538a734dd28efd450bb38a5d5fc94d13ee709bc497e3e4c0a24b3803e3eac39", size = 11755467, upload-time = "2026-08-27T10:38:54.683Z" }, + { url = "https://files.pythonhosted.org/packages/4c/38/5321b9f6065d1c5861e87853720603d56ae81795a83d3040410ca2684305/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dde0657d18e322eedb2540a84c0582e97afd0f78c370b45eebb0d4ac228a7a83", size = 11636274, upload-time = "2026-08-27T15:03:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/56/c75e984c492509d9ab2ba5f2c0e4eab7177cd2cec424cef9dafb42f0cc70/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90e75e726be2c9fc1d1d4c43337503a35864bdf5b4693773a08c6ee92d04a153", size = 11865818, upload-time = "2026-08-27T15:04:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/9e03975701c808767c19f394f670941a962204a4f6a00eeaf56b560348e8/statsmodels-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65e64afc2655a486ea44a5ffe4e2b05ee3abbcb10ea61d1c02d3e9e519338d9b", size = 11907800, upload-time = "2026-08-27T15:05:49.786Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/c467019a8bf1ab6a07ca88004f0a1d4ea2dae89642aa804e49437b256243/statsmodels-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:edee066ac9b171d95c3de925225331a21de1deb5b6452f252bc853e44566e72b", size = 11559083, upload-time = "2026-08-27T15:06:05.872Z" }, + { url = "https://files.pythonhosted.org/packages/54/10/dc78352367cd5bb298340b57c390c944b66236f6d026cc238bc8264c7633/statsmodels-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:40b2737456e75d96866943e33017a1dffc9166025ee33b7eb373fbe8c7a85e09", size = 11029613, upload-time = "2026-08-27T10:39:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/de/cb/a1f5ad730cb66c7d911ebb45413880333d837f1cffff990a4c6d2c541b95/statsmodels-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d48ba61db0d13d3033330e6bd1f864dfa9b48c0b952b3b0f7570206b35d552e5", size = 11702125, upload-time = "2026-08-30T16:04:07.635Z" }, + { url = "https://files.pythonhosted.org/packages/ce/db/fb95f242adce112c1507e2062ad6b7b00aea338925f2a03fbe47973c4d72/statsmodels-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:24b9917a09bbe0227047e512a40652c4b9117c2cfbf19c4a5b9971a8944a8606", size = 11571622, upload-time = "2026-08-30T16:04:29.558Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3f/f4cc7e5f4d6c8f46551aee63e59d9b964881070f552400ebb76667b39ad9/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333d0c70f269ff98b647f157d2ae7fbcf0617e6f9a5b1817fc6609766502292c", size = 11780713, upload-time = "2026-08-30T07:40:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/e9/20/fa315a013f70efa6674adfaca59365ea7562371914c408551fc3c399792f/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b946d7c922f46f045f25e50daf13439f79c05e403ba50081cad509892c8b26", size = 12070117, upload-time = "2026-08-30T07:40:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/91/43/a63f7880050c2c24056fa8d663b7c8a1816b25864660abbc73afff3f3558/statsmodels-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cf897abce5616e2763ab7d7a7fa914677df87a649971f877b83a358c98f6e291", size = 12105153, upload-time = "2026-08-30T07:40:45.814Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d4/523ddb13862c228407297754486b6c9e8777e5114f4532b667b47728c544/statsmodels-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:511a4f9cadbf91a690bfa863bb3e25dce583d8140046886fdf35fbfabfbce401", size = 11352844, upload-time = "2026-08-30T16:04:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/53/59/f3058214e61b15582ef17d761c9333def46960ee521313409b2705791029/statsmodels-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:0cb6228713fe47046b606daee63ed4473a7c246fa97cf0a6d6f20d3b0c96c051", size = 10866685, upload-time = "2026-08-30T16:05:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2d/43089b37f4bbbf7373929cd198de6cbdb0b5a4580a6e5e32bf9fe74fccab/statsmodels-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:27e9467fae83ff327c9d16ae5ea7e771bcdd66a682f49aa324eeefea5584f2d5", size = 11858149, upload-time = "2026-08-30T16:05:20.585Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/ca88421b81666cdcfc3d0eb12c714f80f2be327669ececbb5930b68a71c0/statsmodels-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ecf84918dcd410ab34d3d85d34e0b16ebb10319c9fc77dbf9a1f1a49324c59f8", size = 11739931, upload-time = "2026-08-30T16:05:37.286Z" }, + { url = "https://files.pythonhosted.org/packages/ab/64/98b9e2b1fbfdc4e2392bff349718d8dc796403006a3106ec375c59a5d3f8/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5891ad077b36d6401e44967739df3d2cdd380a0e6e9e46dadec7e3d3a828af55", size = 11657996, upload-time = "2026-08-30T07:41:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/34/6d/d8c9e73013ee854031218e8a044ba679535268bc61cf59466f19ac8d980c/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af80f9658c48792947fc754a026ffbebca61a8b483253781973f604a67cf1f8c", size = 11893402, upload-time = "2026-08-30T07:41:27.553Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f5/b9b47f3c688a8af1b199af96e098b8f20897a60b45187c57ce6fcde77a5f/statsmodels-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:583eb2a46f7c7a5c34a26715c8b6d92199bfb4f04623db5fff9902a3c7c832d3", size = 11934786, upload-time = "2026-08-30T07:41:46.809Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/608871f18bc18c6cba0b484e81fd25a023ef3ca6541f6793ec839fa49e1f/statsmodels-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:38038823fbe86f11e09433b4a0f4af5f102327c3137a1604b002ce3cf722a31f", size = 11549124, upload-time = "2026-08-30T16:05:53.179Z" }, + { url = "https://files.pythonhosted.org/packages/e3/eb/90a506dbee5cde669e2ef28565ff6b7a0791ab415eab2da82f92d033cccc/statsmodels-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:90df413d7e09474f7e7cbf8807440c1bf76298cdd9daabf9141f76d7627c9151", size = 11021454, upload-time = "2026-08-30T16:06:08.124Z" }, ] [[package]] diff --git a/openbb_platform/obbject_extensions/charting/openbb_charting/core/openbb_figure.py b/openbb_platform/obbject_extensions/charting/openbb_charting/core/openbb_figure.py index 0b28dffcb2b..9d8eec71ef3 100644 --- a/openbb_platform/obbject_extensions/charting/openbb_charting/core/openbb_figure.py +++ b/openbb_platform/obbject_extensions/charting/openbb_charting/core/openbb_figure.py @@ -1467,11 +1467,15 @@ class OpenBBFigure(go.Figure): series, # ty: ignore[invalid-argument-type] nlags=nlags, alpha=alpha, + result_object=True, **kwargs, ) acf_x, confint = acf_x[:2] if not pacf else acf_x + if confint is None: + raise ValueError("`alpha` is required to plot the confidence band.") + if irregular: acf_x = acf_x[lags] @@ -1531,5 +1535,5 @@ class OpenBBFigure(go.Figure): ) self.update_traces(showlegend=False) - except ValueError: # pragma: no cover - defensive; real statsmodels acf/pacf output is consistent + except ValueError: # pragma: no cover pass diff --git a/openbb_platform/obbject_extensions/charting/pyproject.toml b/openbb_platform/obbject_extensions/charting/pyproject.toml index 001c579209e..6ce14d329fd 100644 --- a/openbb_platform/obbject_extensions/charting/pyproject.toml +++ b/openbb_platform/obbject_extensions/charting/pyproject.toml @@ -8,8 +8,9 @@ readme = "README.md" requires-python = ">=3.10,<4" # scipy forces python <4.0 explicitly dependencies = [ "openbb-core[pandas]>=2.0.0", - "pandas-ta-openbb>=0.4.24", + "pandas-ta-openbb>=0.4.25", "plotly>=6.3.1", + "statsmodels>=0.15", ] [project.optional-dependencies] diff --git a/openbb_platform/obbject_extensions/charting/tests/test_openbb_figure.py b/openbb_platform/obbject_extensions/charting/tests/test_openbb_figure.py index 42a69ab0ebf..9f247aa58bb 100644 --- a/openbb_platform/obbject_extensions/charting/tests/test_openbb_figure.py +++ b/openbb_platform/obbject_extensions/charting/tests/test_openbb_figure.py @@ -1,5 +1,3 @@ -"""Tests for ``openbb_charting.core.openbb_figure.OpenBBFigure``.""" - import numpy as np import pandas as pd import plotly.graph_objects as go @@ -12,13 +10,11 @@ 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") @@ -26,7 +22,6 @@ def daily_close(daily_index) -> pd.Series: @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))) @@ -45,31 +40,21 @@ def intraday_df() -> pd.DataFrame: 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 @@ -78,26 +63,22 @@ class TestInit: 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 @@ -107,7 +88,6 @@ class TestInit: 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 @@ -122,49 +102,38 @@ class TestInit: 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 "
" 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 @@ -173,66 +142,53 @@ class TestTitlesAndAxes: 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( @@ -243,7 +199,6 @@ class TestTrend: 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 ) @@ -253,22 +208,17 @@ class TestTrend: 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(), @@ -281,7 +231,6 @@ class TestHistPlot: 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()], @@ -293,7 +242,6 @@ class TestHistPlot: 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(), @@ -305,10 +253,7 @@ class TestHistPlot: 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) @@ -316,14 +261,12 @@ class TestVolume: 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}], [{}]] ) @@ -332,10 +275,7 @@ class TestVolume: 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() @@ -343,7 +283,6 @@ class TestDateHandling: 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" @@ -355,13 +294,11 @@ class TestDateHandling: 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}]] ) @@ -374,7 +311,6 @@ class TestDateHandling: 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") @@ -383,7 +319,6 @@ class TestDateHandling: 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() @@ -391,7 +326,6 @@ class TestDateHandling: 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)) @@ -400,7 +334,6 @@ class TestDateHandling: 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() @@ -408,7 +341,6 @@ class TestDateHandling: 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() @@ -416,14 +348,12 @@ class TestDateHandling: 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() @@ -433,10 +363,7 @@ class TestDateHandling: 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() @@ -444,7 +371,6 @@ class TestTickFormatStops: 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() @@ -452,14 +378,12 @@ class TestTickFormatStops: 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") @@ -468,32 +392,24 @@ class TestTickFormatStops: 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") @@ -503,7 +419,6 @@ class TestToSubplot: 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") @@ -512,10 +427,7 @@ class TestToSubplot: 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() @@ -523,7 +435,6 @@ class TestSerialization: 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() @@ -531,7 +442,6 @@ class TestSerialization: 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)) @@ -540,11 +450,8 @@ class TestSerialization: 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( @@ -555,40 +462,33 @@ class TestTable: 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] == "date" 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 ) @@ -596,23 +496,18 @@ class TestTable: 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 @@ -620,7 +515,6 @@ class TestMarginsAndAnnotations: 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() @@ -628,7 +522,6 @@ class TestMarginsAndAnnotations: 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}], [{}]] ) @@ -636,7 +529,6 @@ class TestMarginsAndAnnotations: 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") @@ -645,7 +537,6 @@ class TestMarginsAndAnnotations: 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") @@ -654,7 +545,6 @@ class TestMarginsAndAnnotations: 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)) @@ -664,7 +554,6 @@ class TestMarginsAndAnnotations: 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}]] ) @@ -677,14 +566,12 @@ class TestMarginsAndAnnotations: 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 @@ -693,10 +580,7 @@ class TestMarginsAndAnnotations: 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 @@ -704,44 +588,40 @@ class TestLogScaleMenus: 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 + @pytest.mark.parametrize("pacf", [False, True]) + def test_corr_plot_without_alpha_raises(self, daily_close, pacf): + fig = OpenBBFigure() + with pytest.raises(ValueError, match="`alpha` is required"): + fig.add_corr_plot(daily_close, max_lag=10, alpha=None, pacf=pacf) + 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) @@ -749,14 +629,12 @@ class TestShow: 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) @@ -765,7 +643,6 @@ class TestShow: 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") @@ -775,7 +652,6 @@ class TestShow: 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) @@ -788,7 +664,6 @@ class TestShow: 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) diff --git a/openbb_platform/obbject_extensions/charting/uv.lock b/openbb_platform/obbject_extensions/charting/uv.lock index b3e47a1db6f..30a9d0b921b 100644 --- a/openbb_platform/obbject_extensions/charting/uv.lock +++ b/openbb_platform/obbject_extensions/charting/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10, <4" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -8,11 +8,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.11'", ] @@ -774,6 +777,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, ] +[[package]] +name = "formulaic" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "interface-meta" }, + { name = "narwhals" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/87/e7b9b39d218793f159d7425e264c0ac196da07f1decc333001502fac02fb/formulaic-1.2.2.tar.gz", hash = "sha256:c99e8f11ff7d327eaecaf63855ca69b7fa0da100ad6c0041ef80912fbac667e6", size = 657522, upload-time = "2026-06-02T18:24:42.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/75/0576b03f7889ad25b5385b4f6c69e0543713425a6f193b056c9b0a2e65ce/formulaic-1.2.2-py3-none-any.whl", hash = "sha256:0f84ff49e3fc9dc0e68ab08a0a9427874021aa6c558e66b44dc634a35739b09b", size = 118939, upload-time = "2026-06-02T18:24:41.344Z" }, +] + [[package]] name = "freezegun" version = "1.5.5" @@ -1022,6 +1047,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "interface-meta" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/5b/202a48a1ccdef721a95357fbe263c54ef87c18c4972df1ab5c22fe0f33d5/interface_meta-2.0.1.tar.gz", hash = "sha256:902bd9a95a12f195f15753a1080075d4eca7a2cb934fac7ac03c9e362b50796a", size = 15281, upload-time = "2026-04-30T22:35:36.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/d3/20a61a248feb1249cd81f83ce731f0bdf5170ed96a08a298f7081cda9e90/interface_meta-2.0.1-py3-none-any.whl", hash = "sha256:f38016bef9a4429b6d0792d809be7b65e9781820c674bf7f463999086b6e6323", size = 15330, upload-time = "2026-04-30T22:35:35.201Z" }, +] + [[package]] name = "ipykernel" version = "7.3.0" @@ -1083,11 +1117,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -1416,11 +1453,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } @@ -1506,6 +1546,7 @@ dependencies = [ { name = "openbb-core", extra = ["pandas"] }, { name = "pandas-ta-openbb" }, { name = "plotly" }, + { name = "statsmodels" }, ] [package.optional-dependencies] @@ -1521,9 +1562,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "openbb-core", extras = ["pandas"], specifier = ">=2.0.0" }, - { name = "pandas-ta-openbb", specifier = ">=0.4.24" }, + { name = "pandas-ta-openbb", specifier = ">=0.4.25" }, { name = "plotly", specifier = ">=6.3.1" }, { name = "pywry", marker = "extra == 'pywry'", specifier = ">=2.0.4" }, + { name = "statsmodels", specifier = ">=0.15" }, ] provides-extras = ["pywry"] @@ -1673,11 +1715,14 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ @@ -1738,7 +1783,7 @@ wheels = [ [[package]] name = "pandas-ta-openbb" -version = "0.4.24" +version = "0.4.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1750,9 +1795,9 @@ dependencies = [ { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "statsmodels" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/b3/c8812123c1463480085929aebfeab0f2400cce2c0803223dcfe51f1af7f2/pandas_ta_openbb-0.4.24.tar.gz", hash = "sha256:69ff98beccbbe0ab238371af25a5c0be9f123aa474888cef74892441c353da1b", size = 135689, upload-time = "2026-03-27T08:24:46.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/9c/a3e2c4b20d9df713462dbf7f260d49fded025e69604990dd3a77f8c848a6/pandas_ta_openbb-0.4.25.tar.gz", hash = "sha256:1e71e7e64fdf00441965dd2b6c3d850be607a058f326e666dd54bf718049d8fb", size = 135752, upload-time = "2026-09-28T07:28:24.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/60/d785cb8519613fa50c4cda58cdcabb8078dfa414c3cb3663acebd1a4da04/pandas_ta_openbb-0.4.24-py3-none-any.whl", hash = "sha256:0e6538af573c9ab84a6b13c965ccfc0a930974b876f90473e71b49dcb7a38920", size = 259501, upload-time = "2026-03-27T08:24:47.915Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d6/68035f2581dbbaeae47dcdc89613eef026734fc8475563f5364e8804ddb4/pandas_ta_openbb-0.4.25-py3-none-any.whl", hash = "sha256:cebce28cdd2f69fab5f1a1455f16b52f7961d7c3255f5183c15a2f973fc8ec46", size = 259351, upload-time = "2026-09-28T07:28:10.171Z" }, ] [[package]] @@ -2887,9 +2932,12 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, @@ -3069,9 +3117,10 @@ wheels = [ [[package]] name = "statsmodels" -version = "0.14.6" +version = "0.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "formulaic" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, @@ -3082,38 +3131,62 @@ dependencies = [ { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/1f/a3cbf6ed7cf6286afec694bfef561f3e51483197716717fb8476f1f0c50e/statsmodels-0.15.0.tar.gz", hash = "sha256:5d257fe58d0772bc46a557880ca78e2a8e07fec7bfd9d11074aef8e33e1aecbc", size = 15736853, upload-time = "2026-08-27T10:45:21.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/6d/9ec309a175956f88eb8420ac564297f37cf9b1f73f89db74da861052dc29/statsmodels-0.14.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4ff0649a2df674c7ffb6fa1a06bffdb82a6adf09a48e90e000a15a6aaa734b0", size = 10142419, upload-time = "2025-12-05T19:27:35.625Z" }, - { url = "https://files.pythonhosted.org/packages/86/8f/338c5568315ec5bf3ac7cd4b71e34b98cb3b0f834919c0c04a0762f878a1/statsmodels-0.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:109012088b3e370080846ab053c76d125268631410142daad2f8c10770e8e8d9", size = 10022819, upload-time = "2025-12-05T19:27:49.385Z" }, - { url = "https://files.pythonhosted.org/packages/b0/77/5fc4cbc2d608f9b483b0675f82704a8bcd672962c379fe4d82100d388dbf/statsmodels-0.14.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e93bd5d220f3cb6fc5fc1bffd5b094966cab8ee99f6c57c02e95710513d6ac3f", size = 10118927, upload-time = "2025-12-05T23:07:51.256Z" }, - { url = "https://files.pythonhosted.org/packages/94/55/b86c861c32186403fe121d9ab27bc16d05839b170d92a978beb33abb995e/statsmodels-0.14.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06eec42d682fdb09fe5d70a05930857efb141754ec5a5056a03304c1b5e32fd9", size = 10413015, upload-time = "2025-12-05T23:08:53.95Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/daf0dba729ccdc4176605f4a0fd5cfe71cdda671749dca10e74a732b8b1c/statsmodels-0.14.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0444e88557df735eda7db330806fe09d51c9f888bb1f5906cb3a61fb1a3ed4a8", size = 10441248, upload-time = "2025-12-05T23:09:09.353Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1c/2e10b7c7cc44fa418272996bf0427b8016718fd62f995d9c1f7ab37adf35/statsmodels-0.14.6-cp310-cp310-win_amd64.whl", hash = "sha256:e83a9abe653835da3b37fb6ae04b45480c1de11b3134bd40b09717192a1456ea", size = 9583410, upload-time = "2025-12-05T19:28:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/a9/4d/df4dd089b406accfc3bb5ee53ba29bb3bdf5ae61643f86f8f604baa57656/statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4", size = 10121514, upload-time = "2025-12-05T19:28:16.521Z" }, - { url = "https://files.pythonhosted.org/packages/82/af/ec48daa7f861f993b91a0dcc791d66e1cf56510a235c5cbd2ab991a31d5c/statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6", size = 10003346, upload-time = "2025-12-05T19:28:29.568Z" }, - { url = "https://files.pythonhosted.org/packages/a9/2c/c8f7aa24cd729970728f3f98822fb45149adc216f445a9301e441f7ac760/statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb", size = 10129872, upload-time = "2025-12-05T23:09:25.724Z" }, - { url = "https://files.pythonhosted.org/packages/40/c6/9ae8e9b0721e9b6eb5f340c3a0ce8cd7cce4f66e03dd81f80d60f111987f/statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca", size = 10381964, upload-time = "2025-12-05T23:09:41.326Z" }, - { url = "https://files.pythonhosted.org/packages/28/8c/cf3d30c8c2da78e2ad1f50ade8b7fabec3ff4cdfc56fbc02e097c4577f90/statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d", size = 10409611, upload-time = "2025-12-05T23:09:57.131Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cc/018f14ecb58c6cb89de9d52695740b7d1f5a982aa9ea312483ea3c3d5f77/statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7", size = 9580385, upload-time = "2025-12-05T19:28:42.778Z" }, - { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, - { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, - { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, - { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, - { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, - { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, - { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" }, - { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" }, - { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" }, - { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" }, - { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/71/de/09540e870318e0c7b58316561d417be45eff731263b4234fdd2eee3511a8/statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae", size = 10069403, upload-time = "2025-12-05T23:12:48.424Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f0/63c1bfda75dc53cee858006e1f46bd6d6f883853bea1b97949d0087766ca/statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37", size = 9989253, upload-time = "2025-12-05T23:13:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/c1/98/b0dfb4f542b2033a3341aa5f1bdd97024230a4ad3670c5b0839d54e3dcab/statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad", size = 10090802, upload-time = "2025-12-05T23:13:20.653Z" }, - { url = "https://files.pythonhosted.org/packages/34/0e/2408735aca9e764643196212f9069912100151414dd617d39ffc72d77eee/statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4", size = 10337587, upload-time = "2025-12-05T23:13:37.597Z" }, - { url = "https://files.pythonhosted.org/packages/0f/36/4d44f7035ab3c0b2b6a4c4ebb98dedf36246ccbc1b3e2f51ebcd7ac83abb/statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19", size = 10363350, upload-time = "2025-12-05T23:13:53.08Z" }, - { url = "https://files.pythonhosted.org/packages/26/33/f1652d0c59fa51de18492ee2345b65372550501ad061daa38f950be390b6/statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721", size = 9588010, upload-time = "2025-12-05T23:14:07.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/8c/b6af48649a55aae10df1affa44c47a83a9f3109f9a32d84451f7d9b9b8d5/statsmodels-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b5561f454d6b629bb45cb505df6fdb498f960d0de111bd81f215fa47b15a72d", size = 11744547, upload-time = "2026-08-27T10:34:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/51e4be3772a7767699a4e6864e884f314c4c55e1c169775587d99f6f03c5/statsmodels-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0a42d58efadd47352f805fcf7eeb000f3399fe9cda260055a81105a922443bdd", size = 11592057, upload-time = "2026-08-27T10:34:35.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/88/ed18a489086ca94c2238424f5dc149d6eaddc1198a5b9efae71626096cba/statsmodels-0.15.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc37bb71f4b1e8c9b07e9ce0d32e01fbabe231d323484d35b58e93683a5123e1", size = 11858408, upload-time = "2026-08-27T14:28:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/92/f2/15a4d323326a52cc7d4a7b8e9fe72c85497fae514162ae2b1d8ee35aa3a2/statsmodels-0.15.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:972c33d9fe6bf907b853ba0eccb2f7cf3da90fdddbee002b8478bcaccf637e2a", size = 12109474, upload-time = "2026-08-27T14:29:22.624Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/c6cad0ef9b628340e1ac6ee16e508cff87e5e41d352035c856b7f3d4ad85/statsmodels-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d37d574412a47a71561c0b28c5f876137a86f26fa91e889a879950e22b4e9b4f", size = 12124128, upload-time = "2026-08-27T14:29:47.511Z" }, + { url = "https://files.pythonhosted.org/packages/f7/18/7ed9486977781b3e81a9447cbd04bd4a5fd7ff1807d97929778fb481e644/statsmodels-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:0e108365c5062ba1cf568a183cfc8da551ab8b2e84dbb00e1e87a6a714dd4cb3", size = 11367159, upload-time = "2026-08-27T15:08:25.734Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2e/bba0e676ff1df16be77fc70f652c0261d8ca3a7467dda5ecf39afa7bded0/statsmodels-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:103a83723f998897fbb5d5d0ff728cbe3755a90d9b1f22406bf58f305faff2e7", size = 11714869, upload-time = "2026-08-27T10:34:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/58/c8/08a71f7ad3f0506967d0ab0cdc16a6dec0fe94d0b317f5567836010a10f6/statsmodels-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0ca5d27711f171ee478d547d5a8c5424d1820a1b73c2a3d6bf63f71ad4525f71", size = 11567437, upload-time = "2026-08-27T10:35:08.711Z" }, + { url = "https://files.pythonhosted.org/packages/51/bd/e9292d873ed61f6d02864dd6a936e53c4129e10eee39117d7cd2d0da845c/statsmodels-0.15.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7ef37437f740620a8da03437a29b1fc21bd9a70eeb231cd0d223cc4d766adb78", size = 11827696, upload-time = "2026-08-27T14:30:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/fa/6f/235505a633776a2c1188e583e5a507a548b4fbd21abc14e199f1a84e8ee2/statsmodels-0.15.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b67886b66d9c7ca118526accedb5c6de7ffd74c015dc0492ea0b1690192b65da", size = 12087914, upload-time = "2026-08-27T14:30:57.648Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/d64a5a3eef6cd7b1cc71645b3f625c16e024113e7ffcd1b4ec3da71605ec/statsmodels-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd184e911a1a8c52e5c1c27b43ccd10c24faec08053271ca1a35276cae0a3ada", size = 12109911, upload-time = "2026-08-27T14:31:25.914Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/77c4b5d3f61eab5676981d20f705acee8222ad31de8aca42a331dcd4cfdb/statsmodels-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:5e1214312d6362cebe0b5c53b4f478d9072559709000369dfc528211cb993e8a", size = 11361029, upload-time = "2026-08-27T10:35:24.869Z" }, + { url = "https://files.pythonhosted.org/packages/1f/17/14121b56377f2881ce94c0d0a01466585468923340d94841337d324f6be6/statsmodels-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e8deb1e3d4ab89fec2a52815f1ec924920c4e0cdb307421a4e47cfed87c03ff", size = 11654329, upload-time = "2026-08-27T15:09:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f5/6b550b5c936dd9ec5c859477698b4fcbccc826e9a4a4a52847fa51673357/statsmodels-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6e20788141df134b83ffb43b8cccf135a876188f84124835c86f011f138adee", size = 11527231, upload-time = "2026-08-27T10:35:42.669Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a5/0e7e87e0dedc68bfeecc24bea92876023db09bac1c0fe50584a7d0819fb5/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f90dc4989bd82837c80059fdce132976d4e5a80100119d16e1b00537eb55867", size = 11747428, upload-time = "2026-08-27T14:32:28.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/c7/aeadca282baf51d7b9c135674bac79ded91f4db79c69c188575d22234342/statsmodels-0.15.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f2bd48915cc9b3b95c3c7f6ac363b6f8aaa38172962ba5f7f6885f0dfe57f2", size = 12042643, upload-time = "2026-08-27T14:33:06.022Z" }, + { url = "https://files.pythonhosted.org/packages/9c/44/7d3eb3836b3c054280baca7ec91bd12858825de437c6319c1212c2531d94/statsmodels-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:733887a09ff878a2e29504eda684488b26bd134ba3539ac58d2136a58914c65a", size = 12079497, upload-time = "2026-08-27T14:33:47.147Z" }, + { url = "https://files.pythonhosted.org/packages/03/e3/d863228849da191d37c54bdd72634fbbcfadd12e910e29e4bdca1b952bb6/statsmodels-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba89eb3ccb1c9c1e400d5081157305495b09350b3a65f2d66d3424ad1d3c4209", size = 11322106, upload-time = "2026-08-27T10:35:58.135Z" }, + { url = "https://files.pythonhosted.org/packages/87/17/fee8cfc100c37b1c553515dbad59607a55085a5b9beac2b008e4c03b6388/statsmodels-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09fe34ac80e147cd76cb702ba672559ee3c5dd3ba15c25a232922b46e5ffe70e", size = 11696848, upload-time = "2026-08-27T10:36:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/9e/60/a885751cd3aa796ef94b60f6d3de0c9ace4190feb3e55414ee728d2a0abc/statsmodels-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ffe08752f420f12a0b03e8f7d8f3a45417615309b1f37f10561f9a53f044737", size = 11556170, upload-time = "2026-08-27T10:36:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/482701d2dc7540897b13ead827cf8499b11c7325753b8cc4ff1a82c87a18/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a7ab98634d47048c6b7c168b3f8f71369e256d7314bc386168526f34c7699d7", size = 11752604, upload-time = "2026-08-27T10:47:50.809Z" }, + { url = "https://files.pythonhosted.org/packages/cc/88/5c8819f232448d52514b76a64cf285bc608cff9179f3eeacf58750612323/statsmodels-0.15.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bcaa1e4499697c4e023e5fa6fb04459a34e4f4a449c45ae441992e933bde24db", size = 12055386, upload-time = "2026-08-27T10:49:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/19/a8/298e9b5c993cad3b58913d61b7e7cb0e2fba12e39d3114cd8484f922a5d1/statsmodels-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:88dbe345ecf317862481b3c86305f42133082e5b366a3a8cf240a6caac404daf", size = 12088356, upload-time = "2026-08-27T10:48:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/28/f5/386c6cfda27c47df81643dc9466b3e28dc2a82152e524131977275a88e0c/statsmodels-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:ac9bce461d0c8f529f8ed331a469eaa5dd7c3213c06b62623a2e5438a6383093", size = 9809418, upload-time = "2026-08-27T10:36:50.646Z" }, + { url = "https://files.pythonhosted.org/packages/15/b4/a8a4d89918599309bebfc09a2f793a910279f3900671bc959ac8c810edd1/statsmodels-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:081adf7e5f2da63f63cbd90491447de5d4c4c2921f19814f235bcf3e736edd3c", size = 11312877, upload-time = "2026-08-27T10:37:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/7a/79/66b57afdcbf9a8b21b1a8e4414ddb006e819b2a81243cd74c12f3301eca3/statsmodels-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3e5f870037ca154a4d177aeac68704a784b24cf2c45d86b046fc28e37a2a5584", size = 10812282, upload-time = "2026-08-27T10:37:26.708Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/daf737efb6002905bd006d296f9ad031ed3861b640075bde3b734706b49d/statsmodels-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:04e65ee05475aeceb9cb954cf96c0f9361b9cdfd497b8aae47afbab0c7c786dd", size = 11698848, upload-time = "2026-08-27T10:37:46.312Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/ca42c91e427db43917d4ab5cc6991e7e50b574bcfdf6c4c8eb9590af71d3/statsmodels-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f1443cc1ff7683f3d7a9054e6dccd92040fbd4324616a0b6dbb8af1ce7889fb", size = 11571229, upload-time = "2026-08-27T10:38:04.723Z" }, + { url = "https://files.pythonhosted.org/packages/5b/04/d803e8d014d4643d9c0a5eec78343073fbfff1c1018f776dd416c07fc076/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a45026596c71afa18543ec2af40b895609c181fc3649433c1cded5ce23ec5cd4", size = 11801588, upload-time = "2026-08-27T15:00:31.603Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/40d7678858065e9ec8efd9f37f04227b22a66632ba194e4aa43fda023f6a/statsmodels-0.15.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:edd803f4606061af8c7a83e2326ea294cbfa802a908dfbda4a88347301fb6820", size = 12062254, upload-time = "2026-08-27T15:00:49.803Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/16c73c3c09f4b8d85f125d7d3ae0f7092d4736a83d24c0159e4fb21632ec/statsmodels-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:eb7fc1147cd8d17132190db7a8e63cd76a06145dec502816d8f3c20619923f2b", size = 12101259, upload-time = "2026-08-27T15:01:08.755Z" }, + { url = "https://files.pythonhosted.org/packages/23/4c/1138aad8e3c27ebf941d732788488cce7c28d76404a7b7fc1186faad2890/statsmodels-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:c3c1138b4d0e5b0c2387b17dafd2a0fd137c67bd869f70d4ee56feecc918cb0d", size = 11348219, upload-time = "2026-08-27T15:11:12.924Z" }, + { url = "https://files.pythonhosted.org/packages/3a/bd/53546d8c9ab013d9a0513706d2a09a03aff12034e6f898987e8ce0f38257/statsmodels-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:74c0b63448f03020cc27a8ee698fbcf2ba91e61a9f9eee7978c0c4ed33a9b17f", size = 10866645, upload-time = "2026-08-27T10:38:20.886Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/0d38328643d4704c8de7867518c3f52ec145ff62537b2ca1e14cdeecf51b/statsmodels-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6197b4cca69fd7bc23ab9430abf1c479764cfe74767467f51964a633dfade7a5", size = 11870287, upload-time = "2026-08-27T10:38:38.271Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/fac7e03895f24a0b4883ff9fc69f9421a5df1b60e7edd7e68d49f4327593/statsmodels-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5538a734dd28efd450bb38a5d5fc94d13ee709bc497e3e4c0a24b3803e3eac39", size = 11755467, upload-time = "2026-08-27T10:38:54.683Z" }, + { url = "https://files.pythonhosted.org/packages/4c/38/5321b9f6065d1c5861e87853720603d56ae81795a83d3040410ca2684305/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dde0657d18e322eedb2540a84c0582e97afd0f78c370b45eebb0d4ac228a7a83", size = 11636274, upload-time = "2026-08-27T15:03:41.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/56/c75e984c492509d9ab2ba5f2c0e4eab7177cd2cec424cef9dafb42f0cc70/statsmodels-0.15.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90e75e726be2c9fc1d1d4c43337503a35864bdf5b4693773a08c6ee92d04a153", size = 11865818, upload-time = "2026-08-27T15:04:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/9e03975701c808767c19f394f670941a962204a4f6a00eeaf56b560348e8/statsmodels-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65e64afc2655a486ea44a5ffe4e2b05ee3abbcb10ea61d1c02d3e9e519338d9b", size = 11907800, upload-time = "2026-08-27T15:05:49.786Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/c467019a8bf1ab6a07ca88004f0a1d4ea2dae89642aa804e49437b256243/statsmodels-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:edee066ac9b171d95c3de925225331a21de1deb5b6452f252bc853e44566e72b", size = 11559083, upload-time = "2026-08-27T15:06:05.872Z" }, + { url = "https://files.pythonhosted.org/packages/54/10/dc78352367cd5bb298340b57c390c944b66236f6d026cc238bc8264c7633/statsmodels-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:40b2737456e75d96866943e33017a1dffc9166025ee33b7eb373fbe8c7a85e09", size = 11029613, upload-time = "2026-08-27T10:39:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/de/cb/a1f5ad730cb66c7d911ebb45413880333d837f1cffff990a4c6d2c541b95/statsmodels-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:d48ba61db0d13d3033330e6bd1f864dfa9b48c0b952b3b0f7570206b35d552e5", size = 11702125, upload-time = "2026-08-30T16:04:07.635Z" }, + { url = "https://files.pythonhosted.org/packages/ce/db/fb95f242adce112c1507e2062ad6b7b00aea338925f2a03fbe47973c4d72/statsmodels-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:24b9917a09bbe0227047e512a40652c4b9117c2cfbf19c4a5b9971a8944a8606", size = 11571622, upload-time = "2026-08-30T16:04:29.558Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3f/f4cc7e5f4d6c8f46551aee63e59d9b964881070f552400ebb76667b39ad9/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333d0c70f269ff98b647f157d2ae7fbcf0617e6f9a5b1817fc6609766502292c", size = 11780713, upload-time = "2026-08-30T07:40:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/e9/20/fa315a013f70efa6674adfaca59365ea7562371914c408551fc3c399792f/statsmodels-0.15.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b946d7c922f46f045f25e50daf13439f79c05e403ba50081cad509892c8b26", size = 12070117, upload-time = "2026-08-30T07:40:27.04Z" }, + { url = "https://files.pythonhosted.org/packages/91/43/a63f7880050c2c24056fa8d663b7c8a1816b25864660abbc73afff3f3558/statsmodels-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cf897abce5616e2763ab7d7a7fa914677df87a649971f877b83a358c98f6e291", size = 12105153, upload-time = "2026-08-30T07:40:45.814Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d4/523ddb13862c228407297754486b6c9e8777e5114f4532b667b47728c544/statsmodels-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:511a4f9cadbf91a690bfa863bb3e25dce583d8140046886fdf35fbfabfbce401", size = 11352844, upload-time = "2026-08-30T16:04:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/53/59/f3058214e61b15582ef17d761c9333def46960ee521313409b2705791029/statsmodels-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:0cb6228713fe47046b606daee63ed4473a7c246fa97cf0a6d6f20d3b0c96c051", size = 10866685, upload-time = "2026-08-30T16:05:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2d/43089b37f4bbbf7373929cd198de6cbdb0b5a4580a6e5e32bf9fe74fccab/statsmodels-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:27e9467fae83ff327c9d16ae5ea7e771bcdd66a682f49aa324eeefea5584f2d5", size = 11858149, upload-time = "2026-08-30T16:05:20.585Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/ca88421b81666cdcfc3d0eb12c714f80f2be327669ececbb5930b68a71c0/statsmodels-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ecf84918dcd410ab34d3d85d34e0b16ebb10319c9fc77dbf9a1f1a49324c59f8", size = 11739931, upload-time = "2026-08-30T16:05:37.286Z" }, + { url = "https://files.pythonhosted.org/packages/ab/64/98b9e2b1fbfdc4e2392bff349718d8dc796403006a3106ec375c59a5d3f8/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5891ad077b36d6401e44967739df3d2cdd380a0e6e9e46dadec7e3d3a828af55", size = 11657996, upload-time = "2026-08-30T07:41:05.608Z" }, + { url = "https://files.pythonhosted.org/packages/34/6d/d8c9e73013ee854031218e8a044ba679535268bc61cf59466f19ac8d980c/statsmodels-0.15.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af80f9658c48792947fc754a026ffbebca61a8b483253781973f604a67cf1f8c", size = 11893402, upload-time = "2026-08-30T07:41:27.553Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f5/b9b47f3c688a8af1b199af96e098b8f20897a60b45187c57ce6fcde77a5f/statsmodels-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:583eb2a46f7c7a5c34a26715c8b6d92199bfb4f04623db5fff9902a3c7c832d3", size = 11934786, upload-time = "2026-08-30T07:41:46.809Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/608871f18bc18c6cba0b484e81fd25a023ef3ca6541f6793ec839fa49e1f/statsmodels-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:38038823fbe86f11e09433b4a0f4af5f102327c3137a1604b002ce3cf722a31f", size = 11549124, upload-time = "2026-08-30T16:05:53.179Z" }, + { url = "https://files.pythonhosted.org/packages/e3/eb/90a506dbee5cde669e2ef28565ff6b7a0791ab415eab2da82f92d033cccc/statsmodels-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:90df413d7e09474f7e7cbf8807440c1bf76298cdd9daabf9141f76d7627c9151", size = 11021454, upload-time = "2026-08-30T16:06:08.124Z" }, ] [[package]] diff --git a/openbb_platform/openbb.docker.toml b/openbb_platform/openbb.docker.toml new file mode 100644 index 00000000000..1e48c719bfb --- /dev/null +++ b/openbb_platform/openbb.docker.toml @@ -0,0 +1,8 @@ +[launcher] +host = "0.0.0.0" +port = 6900 + +[mcp] +host = "0.0.0.0" +port = 8001 +transport = "streamable-http" diff --git a/openbb_platform/providers/famafrench/openbb_famafrench/models/country_portfolio_returns.py b/openbb_platform/providers/famafrench/openbb_famafrench/models/country_portfolio_returns.py index 2fba8c7b06c..6b5fa575db2 100644 --- a/openbb_platform/providers/famafrench/openbb_famafrench/models/country_portfolio_returns.py +++ b/openbb_platform/providers/famafrench/openbb_famafrench/models/country_portfolio_returns.py @@ -10,7 +10,7 @@ from openbb_core.provider.abstract.fetcher import Fetcher from openbb_core.provider.abstract.query_params import QueryParams from pydantic import Field -from openbb_famafrench.utils.constants import CountryPortfolios +from openbb_famafrench.utils.constants import PERCENT_UNIT, CountryPortfolios class FamaFrenchCountryPortfolioReturnsQueryParams(QueryParams): @@ -90,15 +90,12 @@ class FamaFrenchCountryPortfolioReturnsData(Data): ) mkt: float | None = Field( default=None, - description=""" - The market return (Mkt) for the first set is the value weighted average - of the returns for only firms with all four ratios. - - The market return for the second set includes all firms with book-to-market data, - and Firms is the number of firms with B/M data. - - Not returned if `measure` is set to 'ratios'. - """, + description="Value-weighted market return (Mkt)." + + " Includes only firms with all four ratios when `all_data_items_required`" + + " is True, otherwise all firms with book-to-market data." + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, ) firms: int | None = Field( default=None, @@ -108,79 +105,101 @@ class FamaFrenchCountryPortfolioReturnsData(Data): bm: float | None = Field( default=None, description="Book to Market equity ratio." - + " Not returned if `measure` is set to 'ratios'.", + + " Only returned when `measure` is set to 'ratios'." + + " Reported as the ratio multiplied by 100.", title="B/M", ) be_me_high: float | None = Field( default=None, description="Book Equity to Market Equity returns for the value portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="BE/ME High", ) be_me_low: float | None = Field( default=None, description="Book Equity to Market Equity returns for the growth portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="BE/ME Low", ) ep: float | None = Field( default=None, description="Earnings to Price ratio." - + " Only returned when `measure` is set to 'ratios'.", + + " Only returned when `measure` is set to 'ratios'." + + " Reported as the ratio multiplied by 100.", title="E/P", ) e_p_high: float | None = Field( default=None, description="Earnings to Price returns for the value portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="E/P High", ) e_p_low: float | None = Field( default=None, description="Earnings to Price returns for the growth portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="E/P Low", ) ce_p: float | None = Field( default=None, description="Cash Earnings to Price ratio." - + " Only returned when `measure` is set to 'ratios'.", + + " Only returned when `measure` is set to 'ratios'." + + " Reported as the ratio multiplied by 100.", title="CE/P", ) ce_p_high: float | None = Field( default=None, description="Cash Earnings to Price returns for the value portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="CE/P High", ) ce_p_low: float | None = Field( default=None, description="Cash Earnings to Price returns for the growth portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="CE/P Low", ) yld: float | None = Field( default=None, description="Dividend Yield ratio." - + " Only returned when `measure` is set to 'ratios'.", + + " Only returned when `measure` is set to 'ratios'." + + " Reported as the ratio multiplied by 100.", title="Yld", ) yld_high: float | None = Field( default=None, description="Dividend Yield returns for the value portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="Yld High", ) yld_low: float | None = Field( default=None, description="Dividend Yield returns for the growth portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="Yld Low", ) yld_zero: float | None = Field( default=None, description="Dividend Yield returns for firms not paying dividends." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="Yld Zero", ) @@ -238,6 +257,11 @@ class FamaFrenchCountryPortfolioReturnsFetcher( """Transform the extracted data.""" from pandas import MultiIndex + from openbb_famafrench.utils.missing_values import ( + is_missing_value, + replace_missing_values, + ) + dfs, meta = data if not dfs: @@ -247,19 +271,11 @@ class FamaFrenchCountryPortfolioReturnsFetcher( ) returns_data = dfs[0] if isinstance(dfs, list) else dfs - # Values of -99.99 or -999 indicate no data, - # Drop columns that have no data. for col in returns_data.columns: - if all(returns_data[col].values == "-99.99") or all( - returns_data[col].values == "-999" - ): + if all(is_missing_value(value) for value in returns_data[col].values): returns_data = returns_data.drop(columns=[col]) else: - returns_data[col] = ( - returns_data[col].astype(int) - if query.measure == "ratios" and col == "firms" - else returns_data[col].astype(float) - ) + returns_data[col] = returns_data[col].astype(float) if query.start_date: returns_data = returns_data[ @@ -309,7 +325,9 @@ Bloomberg for 2007 to present. return AnnotatedResult( result=[ FamaFrenchCountryPortfolioReturnsData(**d) - for d in returns_data.reset_index().to_dict(orient="records") + for d in replace_missing_values( + returns_data.reset_index().to_dict(orient="records") + ) ], metadata=meta[0] if isinstance(meta, list) else meta, ) diff --git a/openbb_platform/providers/famafrench/openbb_famafrench/models/factors.py b/openbb_platform/providers/famafrench/openbb_famafrench/models/factors.py index e1d16151a09..be0b62f0017 100644 --- a/openbb_platform/providers/famafrench/openbb_famafrench/models/factors.py +++ b/openbb_platform/providers/famafrench/openbb_famafrench/models/factors.py @@ -11,7 +11,11 @@ from openbb_core.provider.abstract.fetcher import Fetcher from openbb_core.provider.abstract.query_params import QueryParams from pydantic import Field, model_validator -from openbb_famafrench.utils.constants import FACTOR_REGION_MAP, REGIONS_MAP +from openbb_famafrench.utils.constants import ( + FACTOR_REGION_MAP, + PERCENT_UNIT, + REGIONS_MAP, +) api_prefix = SystemService().system_settings.api_settings.prefix factors_dict = { @@ -180,21 +184,27 @@ class FamaFrenchFactorsData(Data): default=None, description="Excess return on the market, value-weighted return of all firms," + " minus the one-month Treasury bill rate." - + " Not returned for momentum or reversal factors.", + + " Not returned for momentum or reversal factors." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="Mkt-RF", ) smb: float | None = Field( default=None, description="Small minus big (SMB) factor returns." + " Average return of small minus big stock portfolios." - + " Not returned for momentum or reversal factors.", + + " Not returned for momentum or reversal factors." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="SMB", ) hml: float | None = Field( default=None, description="High minus low (HML) factor returns." + " Average return on value minus average return on growth portfolios." - + " Not returned for momentum or reversal factors.", + + " Not returned for momentum or reversal factors." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="HML", ) rmw: float | None = Field( @@ -202,7 +212,9 @@ class FamaFrenchFactorsData(Data): description="Robust minus weak (RMW) factor returns." + " Average return on robust operating profitability portfolios," " minus average return on weak operating profitability portfolios." - + " Only returned when 5 Factor model is selected.", + + " Only returned when 5 Factor model is selected." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="RMW", ) cma: float | None = Field( @@ -210,20 +222,26 @@ class FamaFrenchFactorsData(Data): description="Conservative minus aggressive (CMA) factor returns." + " Average return on conservative investment portfolios," " minus average return on aggressive investment portfolios." - + " Only returned when 5 Factor model is selected.", + + " Only returned when 5 Factor model is selected." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="CMA", ) rf: float | None = Field( default=None, description="Risk-free rate (RF) returns." + " The one-month US Treasury bill rate." - + " Not returned when momentum or reversal factors are selected.", + + " Not returned when momentum or reversal factors are selected." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="RF", ) mom: float | None = Field( default=None, description="Momentum (Mom) factor returns." - + " Returned only when the momentum factor is selected and the region is 'america'.", + + " Returned only when the momentum factor is selected and the region is 'america'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="Mom", ) wml: float | None = Field( @@ -231,21 +249,27 @@ class FamaFrenchFactorsData(Data): description="Winners minus losers (WML) factor returns." + " Equal-weight average of the returns for the winner portfolios" + " minus the average of the returns for the loser portfolios." - + " Returned only when the momentum factor is selected, and the region is not 'america'.", + + " Returned only when the momentum factor is selected, and the region is not 'america'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="WML", ) lt_rev: float | None = Field( default=None, description="Long-term reversal (LT_Rev) factor returns." + " Returned only when the long-term reversal factor is selected," - + " and the region is 'america'.", + + " and the region is 'america'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="LT_Rev", ) st_rev: float | None = Field( default=None, description="Short-term reversal (ST_Rev) factor returns." + " Returned only when the short-term reversal factor is selected," - + " and the region is 'america'.", + + " and the region is 'america'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="ST_Rev", ) @@ -295,6 +319,8 @@ class FamaFrenchFactorsFetcher( """Transform the raw data and insert metadata.""" from pandas import to_datetime + from openbb_famafrench.utils.missing_values import replace_missing_values + table = data[0][0].reset_index() if query.start_date: @@ -303,7 +329,7 @@ class FamaFrenchFactorsFetcher( if query.end_date: table = table[to_datetime(table.Date) <= to_datetime(query.end_date)] - records = table.to_dict(orient="records") + records = replace_missing_values(table.to_dict(orient="records")) metadata = data[1][0] return AnnotatedResult( diff --git a/openbb_platform/providers/famafrench/openbb_famafrench/models/international_index_returns.py b/openbb_platform/providers/famafrench/openbb_famafrench/models/international_index_returns.py index 21f5ca8a675..4c09eaba830 100644 --- a/openbb_platform/providers/famafrench/openbb_famafrench/models/international_index_returns.py +++ b/openbb_platform/providers/famafrench/openbb_famafrench/models/international_index_returns.py @@ -10,7 +10,7 @@ from openbb_core.provider.abstract.fetcher import Fetcher from openbb_core.provider.abstract.query_params import QueryParams from pydantic import Field -from openbb_famafrench.utils.constants import InternationalIndexPortfolios +from openbb_famafrench.utils.constants import PERCENT_UNIT, InternationalIndexPortfolios class FamaFrenchInternationalIndexReturnsQueryParams(QueryParams): @@ -93,15 +93,12 @@ class FamaFrenchInternationalIndexReturnsData(Data): ) mkt: float | None = Field( default=None, - description=""" - The market return (Mkt) for the first set is the value weighted average - of the returns for only firms with all four ratios. - - The market return for the second set includes all firms with book-to-market data, - and Firms is the number of firms with B/M data. - - Not returned if `measure` is set to 'ratios'. - """, + description="Value-weighted market return (Mkt)." + + " Includes only firms with all four ratios when `all_data_items_required`" + + " is True, otherwise all firms with book-to-market data." + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, ) firms: int | None = Field( default=None, @@ -111,79 +108,101 @@ class FamaFrenchInternationalIndexReturnsData(Data): bm: float | None = Field( default=None, description="Book to Market equity ratio." - + " Not returned if `measure` is set to 'ratios'.", + + " Only returned when `measure` is set to 'ratios'." + + " Reported as the ratio multiplied by 100.", title="B/M", ) be_me_high: float | None = Field( default=None, description="Book Equity to Market Equity returns for the value portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="BE/ME High", ) be_me_low: float | None = Field( default=None, description="Book Equity to Market Equity returns for the growth portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="BE/ME Low", ) ep: float | None = Field( default=None, description="Earnings to Price ratio." - + " Only returned when `measure` is set to 'ratios'.", + + " Only returned when `measure` is set to 'ratios'." + + " Reported as the ratio multiplied by 100.", title="E/P", ) e_p_high: float | None = Field( default=None, description="Earnings to Price returns for the value portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="E/P High", ) e_p_low: float | None = Field( default=None, description="Earnings to Price returns for the growth portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="E/P Low", ) ce_p: float | None = Field( default=None, description="Cash Earnings to Price ratio." - + " Only returned when `measure` is set to 'ratios'.", + + " Only returned when `measure` is set to 'ratios'." + + " Reported as the ratio multiplied by 100.", title="CE/P", ) ce_p_high: float | None = Field( default=None, description="Cash Earnings to Price returns for the value portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="CE/P High", ) ce_p_low: float | None = Field( default=None, description="Cash Earnings to Price returns for the growth portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="CE/P Low", ) yld: float | None = Field( default=None, description="Dividend Yield ratio." - + " Only returned when `measure` is set to 'ratios'.", + + " Only returned when `measure` is set to 'ratios'." + + " Reported as the ratio multiplied by 100.", title="Yld", ) yld_high: float | None = Field( default=None, description="Dividend Yield returns for the value portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="Yld High", ) yld_low: float | None = Field( default=None, description="Dividend Yield returns for the growth portfolio." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="Yld Low", ) yld_zero: float | None = Field( default=None, description="Dividend Yield returns for firms not paying dividends." - + " Not returned if `measure` is set to 'ratios'.", + + " Not returned if `measure` is set to 'ratios'." + + " Values are in percent.", + json_schema_extra=PERCENT_UNIT, title="Yld Zero", ) @@ -241,6 +260,11 @@ class FamaFrenchInternationalIndexReturnsFetcher( """Transform the extracted data.""" from pandas import MultiIndex + from openbb_famafrench.utils.missing_values import ( + is_missing_value, + replace_missing_values, + ) + dfs, meta = data if not dfs: @@ -250,19 +274,11 @@ class FamaFrenchInternationalIndexReturnsFetcher( ) returns_data = dfs[0] if isinstance(dfs, list) else dfs - # Values of -99.99 or -999 indicate no data, - # Drop columns that have no data. for col in returns_data.columns: - if all(returns_data[col].values == "-99.99") or all( - returns_data[col].values == "-999" - ): + if all(is_missing_value(value) for value in returns_data[col].values): returns_data = returns_data.drop(columns=[col]) else: - returns_data[col] = ( - returns_data[col].astype(int) - if query.measure == "ratios" and col == "firms" - else returns_data[col].astype(float) - ) + returns_data[col] = returns_data[col].astype(float) if query.start_date: returns_data = returns_data[ @@ -304,7 +320,9 @@ Bloomberg for 2007 to present. return AnnotatedResult( result=[ FamaFrenchInternationalIndexReturnsData(**d) - for d in returns_data.reset_index().to_dict(orient="records") + for d in replace_missing_values( + returns_data.reset_index().to_dict(orient="records") + ) ], metadata=meta[0] if isinstance(meta, list) else meta, ) diff --git a/openbb_platform/providers/famafrench/openbb_famafrench/models/regional_portfolio_returns.py b/openbb_platform/providers/famafrench/openbb_famafrench/models/regional_portfolio_returns.py index 203b17a985d..64c5ccca092 100644 --- a/openbb_platform/providers/famafrench/openbb_famafrench/models/regional_portfolio_returns.py +++ b/openbb_platform/providers/famafrench/openbb_famafrench/models/regional_portfolio_returns.py @@ -77,9 +77,11 @@ class FamaFrenchRegionalPortfolioReturnsData(Data): ] = Field( description="The measure of the portfolio.", ) - value: int | float = Field( + value: int | float | None = Field( + default=None, description="The value represented by the 'measure'." - + " Missing data are indicated by -99.99 or -999", + + " Returns are in percent for the 'value' and 'equal' measures." + + " None where the source reports missing data.", ) @@ -130,6 +132,11 @@ class FamaFrenchRegionalPortfolioReturnsFetcher( **kwargs: Any, ) -> AnnotatedResult[list[FamaFrenchRegionalPortfolioReturnsData]]: """Transform the extracted data.""" + from openbb_famafrench.utils.missing_values import ( + is_missing_value, + replace_missing_values, + ) + dfs, meta = data if not dfs: @@ -139,19 +146,11 @@ class FamaFrenchRegionalPortfolioReturnsFetcher( ) returns_data = dfs[0] if isinstance(dfs, list) else dfs - # Values of -99.99 or -999 indicate no data, - # Drop columns that have no data. for col in returns_data.columns: - if all(returns_data[col].values == "-99.99") or all( - returns_data[col].values == "-999" - ): + if all(is_missing_value(value) for value in returns_data[col].values): returns_data = returns_data.drop(columns=[col]) else: - returns_data[col] = ( - returns_data[col].astype(int) - if query.measure == "number_of_firms" - else returns_data[col].astype(float) - ) + returns_data[col] = returns_data[col].astype(float) if query.start_date: returns_data = returns_data[ @@ -163,8 +162,6 @@ class FamaFrenchRegionalPortfolioReturnsFetcher( returns_data.index <= query.end_date.strftime("%Y-%m-%d") ] - # Flatten the DataFrame to conform to the Data model - # This avoids having undefined fields. flattened_data = ( returns_data.reset_index() .melt( @@ -179,7 +176,12 @@ class FamaFrenchRegionalPortfolioReturnsFetcher( return AnnotatedResult( result=[ FamaFrenchRegionalPortfolioReturnsData(**d) - for d in flattened_data.to_dict(orient="records") + for d in replace_missing_values( + flattened_data.to_dict(orient="records"), + integer_fields=( + ("value",) if query.measure == "number_of_firms" else () + ), + ) ], metadata=meta[0] if isinstance(meta, list) else meta, ) diff --git a/openbb_platform/providers/famafrench/openbb_famafrench/models/us_portfolio_returns.py b/openbb_platform/providers/famafrench/openbb_famafrench/models/us_portfolio_returns.py index b9abf71fa65..a023bacfbaf 100644 --- a/openbb_platform/providers/famafrench/openbb_famafrench/models/us_portfolio_returns.py +++ b/openbb_platform/providers/famafrench/openbb_famafrench/models/us_portfolio_returns.py @@ -77,9 +77,11 @@ class FamaFrenchUSPortfolioReturnsData(Data): ] = Field( description="The measure of the portfolio.", ) - value: int | float = Field( + value: int | float | None = Field( + default=None, description="The value represented by the 'measure'." - + " Missing data are indicated by -99.99 or -999", + + " Returns are in percent for the 'value' and 'equal' measures." + + " None where the source reports missing data.", ) @@ -133,6 +135,11 @@ class FamaFrenchUSPortfolioReturnsFetcher( **kwargs: Any, ) -> AnnotatedResult[list[FamaFrenchUSPortfolioReturnsData]]: """Transform the extracted data.""" + from openbb_famafrench.utils.missing_values import ( + is_missing_value, + replace_missing_values, + ) + dfs, meta = data if not dfs: @@ -143,19 +150,11 @@ class FamaFrenchUSPortfolioReturnsFetcher( returns_data = dfs[0] if isinstance(dfs, list) else dfs - # Values of -99.99 or -999 indicate no data, - # Drop columns that have no data. for col in returns_data.columns: - if all(returns_data[col].values == "-99.99") or all( - returns_data[col].values == "-999" - ): + if all(is_missing_value(value) for value in returns_data[col].values): returns_data = returns_data.drop(columns=[col]) else: - returns_data[col] = ( - returns_data[col].astype(int) - if query.measure == "number_of_firms" - else returns_data[col].astype(float) - ) + returns_data[col] = returns_data[col].astype(float) if query.start_date: returns_data = returns_data[ @@ -167,8 +166,6 @@ class FamaFrenchUSPortfolioReturnsFetcher( returns_data.index <= query.end_date.strftime("%Y-%m-%d") ] - # Flatten the DataFrame to conform to the Data model - # This avoids having undefined fields. flattened_data = ( returns_data.reset_index() .melt( @@ -183,7 +180,12 @@ class FamaFrenchUSPortfolioReturnsFetcher( return AnnotatedResult( result=[ FamaFrenchUSPortfolioReturnsData(**d) - for d in flattened_data.to_dict(orient="records") + for d in replace_missing_values( + flattened_data.to_dict(orient="records"), + integer_fields=( + ("value",) if query.measure == "number_of_firms" else () + ), + ) ], metadata=meta[0] if isinstance(meta, list) else meta, ) diff --git a/openbb_platform/providers/famafrench/openbb_famafrench/utils/constants.py b/openbb_platform/providers/famafrench/openbb_famafrench/utils/constants.py index 5aceb9586fa..3baf3cd84d5 100644 --- a/openbb_platform/providers/famafrench/openbb_famafrench/utils/constants.py +++ b/openbb_platform/providers/famafrench/openbb_famafrench/utils/constants.py @@ -4,6 +4,8 @@ from typing import Any, Literal BASE_URL = "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/" +PERCENT_UNIT: dict[str, Any] = {"x-unit_measurement": "percent"} + DATASET_CHOICES = [ { "label": "F-F Research Data Factors", diff --git a/openbb_platform/providers/famafrench/openbb_famafrench/utils/missing_values.py b/openbb_platform/providers/famafrench/openbb_famafrench/utils/missing_values.py new file mode 100644 index 00000000000..b4e757f8b63 --- /dev/null +++ b/openbb_platform/providers/famafrench/openbb_famafrench/utils/missing_values.py @@ -0,0 +1,41 @@ +"""Fama-French missing-value handling.""" + +from math import isnan +from numbers import Real +from typing import Any + +MISSING_VALUE_MARKERS = (-99.99, -999.0) + + +def is_missing_value(value: Any) -> bool: + """Check for None, NaN, or a Fama-French missing-value marker.""" + if value is None: + return True + if isinstance(value, str): + if not value.lstrip().startswith("-9"): + return False + try: + value = float(value) + except ValueError: + return False + if isinstance(value, bool) or not isinstance(value, Real): + return False + return isnan(value) or value in MISSING_VALUE_MARKERS + + +def replace_missing_values( + records: list[dict[str, Any]], integer_fields: tuple[str, ...] = () +) -> list[dict[str, Any]]: + """Replace Fama-French missing-value markers with None.""" + cleaned: list[dict[str, Any]] = [] + for record in records: + row: dict[str, Any] = {} + for key, value in record.items(): + if is_missing_value(value): + row[key] = None + elif key in integer_fields: + row[key] = int(float(value)) + else: + row[key] = value + cleaned.append(row) + return cleaned diff --git a/openbb_platform/providers/famafrench/tests/test_missing_values.py b/openbb_platform/providers/famafrench/tests/test_missing_values.py new file mode 100644 index 00000000000..63eebe88a67 --- /dev/null +++ b/openbb_platform/providers/famafrench/tests/test_missing_values.py @@ -0,0 +1,47 @@ +from datetime import date + +import pytest + +from openbb_famafrench.utils.missing_values import ( + is_missing_value, + replace_missing_values, +) + + +@pytest.mark.parametrize( + "value", + [None, float("nan"), -99.99, -999, -999.0, "-99.99", " -99.990", "-999", "-999.00"], +) +def test_is_missing_value_true(value): + assert is_missing_value(value) is True + + +@pytest.mark.parametrize( + "value", + [ + 0.0, + -99.98, + -9.99, + 12, + "1.5", + "-9.5", + "-9x", + "Lo 30", + "2020-01-31", + True, + date(2020, 1, 31), + ], +) +def test_is_missing_value_false(value): + assert is_missing_value(value) is False + + +def test_replace_missing_values(): + records = [ + {"Date": "2020-01-31", "Mkt": "-99.99", "High": 1.5, "Firms": "12"}, + {"Date": "2020-02-29", "Mkt": 2.0, "High": -999.0, "Firms": "-999"}, + ] + assert replace_missing_values(records, integer_fields=("Firms",)) == [ + {"Date": "2020-01-31", "Mkt": None, "High": 1.5, "Firms": 12}, + {"Date": "2020-02-29", "Mkt": 2.0, "High": None, "Firms": None}, + ] diff --git a/openbb_platform/providers/famafrench/tests/test_models.py b/openbb_platform/providers/famafrench/tests/test_models.py index ab4ef4e4599..af1008357dd 100644 --- a/openbb_platform/providers/famafrench/tests/test_models.py +++ b/openbb_platform/providers/famafrench/tests/test_models.py @@ -1,5 +1,3 @@ -"""Tests for the Fama-French fetcher models.""" - import asyncio import pytest @@ -31,13 +29,8 @@ from openbb_famafrench.models.us_portfolio_returns import ( FamaFrenchUSPortfolioReturnsQueryParams, ) -# --------------------------------------------------------------------------- -# factors.py -# --------------------------------------------------------------------------- - def test_factors_query_defaults(): - """The factors query builds with default values.""" query = FamaFrenchFactorsQueryParams() assert query.region == "america" @@ -46,7 +39,6 @@ def test_factors_query_defaults(): def test_factors_query_valid_variant(): - """The factors query accepts a valid non-default combination.""" query = FamaFrenchFactorsQueryParams( region="europe", factor="5_factors", frequency="daily" ) @@ -56,19 +48,16 @@ def test_factors_query_valid_variant(): def test_factors_query_invalid_region(): - """An invalid region raises a validation error.""" with pytest.raises(ValueError, match="Invalid region"): FamaFrenchFactorsQueryParams(region="atlantis") def test_factors_query_invalid_factor_for_region(): - """A factor unavailable for the region raises a validation error.""" with pytest.raises(ValueError, match="Invalid factor"): FamaFrenchFactorsQueryParams(region="europe", factor="st_reversal") def test_factors_query_invalid_frequency(): - """A frequency unavailable for the factor raises a validation error.""" with pytest.raises(ValueError, match="Invalid frequency"): FamaFrenchFactorsQueryParams( region="europe", factor="momentum", frequency="weekly" @@ -76,7 +65,6 @@ def test_factors_query_invalid_frequency(): def test_factors_transform_query(): - """transform_query returns a FamaFrenchFactorsQueryParams instance.""" query = FamaFrenchFactorsFetcher.transform_query({"region": "japan"}) assert isinstance(query, FamaFrenchFactorsQueryParams) @@ -84,7 +72,6 @@ def test_factors_transform_query(): def test_factors_transform_data_with_date_filters(): - """transform_data filters by start and end date.""" frame = DataFrame( { "Mkt-RF": [1.0, 2.0, 3.0], @@ -104,7 +91,6 @@ def test_factors_transform_data_with_date_filters(): def test_factors_aextract_data_invalid_dataset(): - """An empty mapped dataset raises an OpenBBError.""" class _Query: region = "emerging" @@ -116,7 +102,6 @@ def test_factors_aextract_data_invalid_dataset(): def test_factors_aextract_data_helper_error(monkeypatch): - """A failure inside get_portfolio_data is wrapped in an OpenBBError.""" def _boom(*args, **kwargs): raise RuntimeError("network down") @@ -128,20 +113,13 @@ def test_factors_aextract_data_helper_error(monkeypatch): asyncio.run(FamaFrenchFactorsFetcher.aextract_data(query, None)) -# --------------------------------------------------------------------------- -# breakpoints.py -# --------------------------------------------------------------------------- - - def test_breakpoints_query_defaults(): - """The breakpoints query builds with default values.""" query = FamaFrenchBreakpointQueryParams() assert query.breakpoint_type == "me" def test_breakpoints_transform_query(): - """transform_query returns a FamaFrenchBreakpointQueryParams instance.""" query = FamaFrenchBreakpointFetcher.transform_query({"breakpoint_type": "op"}) assert isinstance(query, FamaFrenchBreakpointQueryParams) @@ -149,7 +127,6 @@ def test_breakpoints_transform_query(): def test_breakpoints_transform_data_with_date_filters(): - """transform_data filters by start and end date.""" frame = DataFrame( { "date": ["2020-01-31", "2020-02-29", "2020-03-31"], @@ -169,7 +146,6 @@ def test_breakpoints_transform_data_with_date_filters(): def test_breakpoints_transform_data_empty(): - """transform_data raises an OpenBBError when no frames are returned.""" query = FamaFrenchBreakpointQueryParams() with pytest.raises(OpenBBError, match="unexpectedly empty"): @@ -177,7 +153,6 @@ def test_breakpoints_transform_data_empty(): def test_breakpoints_aextract_data_helper_error(monkeypatch): - """A failure inside get_breakpoint_data is wrapped in an OpenBBError.""" def _boom(*args, **kwargs): raise RuntimeError("network down") @@ -189,13 +164,7 @@ def test_breakpoints_aextract_data_helper_error(monkeypatch): asyncio.run(FamaFrenchBreakpointFetcher.aextract_data(query, None)) -# --------------------------------------------------------------------------- -# us_portfolio_returns.py / regional_portfolio_returns.py -# --------------------------------------------------------------------------- - - def _portfolio_frame(): - """A returns frame with a string column that is entirely missing data.""" frame = DataFrame( { "Lo 30": ["1.0", "2.0", "3.0"], @@ -218,7 +187,6 @@ def _portfolio_frame(): ], ) def test_portfolio_transform_query(fetcher, query_cls): - """transform_query returns the expected QueryParams type.""" query = fetcher.transform_query({}) assert isinstance(query, query_cls) @@ -235,14 +203,12 @@ def test_portfolio_transform_query(fetcher, query_cls): ], ) def test_portfolio_transform_data(fetcher, query_cls): - """transform_data drops empty columns, casts, and filters by date.""" query = query_cls(measure="value", start_date="2020-02-01", end_date="2020-02-29") data = ([_portfolio_frame()], [{"description": "Portfolio"}]) result = fetcher.transform_data(query, data) assert result.result - # The fully-missing column was dropped. assert all(r.portfolio == "Lo 30" for r in result.result) assert all(r.measure == "value" for r in result.result) @@ -258,7 +224,6 @@ def test_portfolio_transform_data(fetcher, query_cls): ], ) def test_portfolio_transform_data_number_of_firms(fetcher, query_cls): - """The number_of_firms measure casts values to integers.""" frame = DataFrame( {"Lo 30": ["10", "20"]}, index=["2020-01-31", "2020-02-29"], @@ -283,7 +248,6 @@ def test_portfolio_transform_data_number_of_firms(fetcher, query_cls): ], ) def test_portfolio_transform_data_empty(fetcher, query_cls): - """transform_data raises an OpenBBError when no frames are returned.""" with pytest.raises(OpenBBError, match="returned empty"): fetcher.transform_data(query_cls(), ([], [])) @@ -299,7 +263,6 @@ def test_portfolio_transform_data_empty(fetcher, query_cls): ], ) def test_portfolio_aextract_data_helper_error(monkeypatch, fetcher, query_cls): - """A failure inside get_portfolio_data is wrapped in an OpenBBError.""" def _boom(*args, **kwargs): raise RuntimeError("network down") @@ -311,7 +274,6 @@ def test_portfolio_aextract_data_helper_error(monkeypatch, fetcher, query_cls): def test_us_portfolio_aextract_data_daily(monkeypatch): - """A daily portfolio passes a None frequency to get_portfolio_data.""" captured = {} def _capture(dataset, measure=None, frequency=None): @@ -329,7 +291,6 @@ def test_us_portfolio_aextract_data_daily(monkeypatch): def test_regional_portfolio_aextract_data_daily(monkeypatch): - """A daily regional portfolio passes a None frequency to get_portfolio_data.""" captured = {} def _capture(dataset, measure=None, frequency=None): @@ -346,13 +307,7 @@ def test_regional_portfolio_aextract_data_daily(monkeypatch): assert captured["frequency"] is None -# --------------------------------------------------------------------------- -# country_portfolio_returns.py / international_index_returns.py -# --------------------------------------------------------------------------- - - def _international_frame(multiindex: bool = False): - """A returns frame with a fully-missing column and an optional MultiIndex.""" frame = DataFrame( { "Mkt": ["1.0", "2.0", "3.0"], @@ -383,7 +338,6 @@ def _international_frame(multiindex: bool = False): ], ) def test_international_transform_query(fetcher, query_cls): - """transform_query returns the expected QueryParams type.""" query = fetcher.transform_query({}) assert isinstance(query, query_cls) @@ -403,7 +357,6 @@ def test_international_transform_query(fetcher, query_cls): ], ) def test_international_transform_data(fetcher, query_cls): - """transform_data drops empty columns and filters by date.""" query = query_cls(measure="usd", start_date="2020-02-01", end_date="2020-02-29") data = ([_international_frame()], [{"description": "Index"}]) @@ -427,7 +380,6 @@ def test_international_transform_data(fetcher, query_cls): ], ) def test_international_transform_data_multiindex(fetcher, query_cls): - """transform_data flattens MultiIndex columns.""" query = query_cls(measure="usd") data = ([_international_frame(multiindex=True)], [{"description": "Index"}]) @@ -450,7 +402,6 @@ def test_international_transform_data_multiindex(fetcher, query_cls): ], ) def test_international_transform_data_ratios_firms(fetcher, query_cls): - """The ratios measure casts the firms column to integers.""" frame = DataFrame( { "firms": ["100", "200"], @@ -481,7 +432,6 @@ def test_international_transform_data_ratios_firms(fetcher, query_cls): ], ) def test_international_transform_data_empty(fetcher, query_cls): - """transform_data raises an OpenBBError when no frames are returned.""" with pytest.raises(OpenBBError, match="returned empty"): fetcher.transform_data(query_cls(), ([], [])) @@ -500,7 +450,6 @@ def test_international_transform_data_empty(fetcher, query_cls): ], ) def test_international_aextract_data_helper_error(monkeypatch, fetcher, query_cls): - """A failure inside get_international_portfolio is wrapped in an OpenBBError.""" def _boom(*args, **kwargs): raise RuntimeError("network down") @@ -529,7 +478,6 @@ def test_international_aextract_data_helper_error(monkeypatch, fetcher, query_cl def test_international_aextract_data_ratios_monthly_warns( monkeypatch, fetcher, query_cls ): - """The ratios measure with monthly frequency warns and forces annual.""" captured = {} def _capture(*args, **kwargs): @@ -545,3 +493,99 @@ def test_international_aextract_data_ratios_monthly_warns( asyncio.run(fetcher.aextract_data(query, None)) assert captured["frequency"] == "annual" + + +DATES = ["2020-01-31", "2020-02-29", "2020-03-31"] +PORTFOLIO_FETCHERS = [ + (FamaFrenchUSPortfolioReturnsFetcher, FamaFrenchUSPortfolioReturnsQueryParams), + ( + FamaFrenchRegionalPortfolioReturnsFetcher, + FamaFrenchRegionalPortfolioReturnsQueryParams, + ), +] +INTERNATIONAL_FETCHERS = [ + ( + FamaFrenchCountryPortfolioReturnsFetcher, + FamaFrenchCountryPortfolioReturnsQueryParams, + ), + ( + FamaFrenchInternationalIndexReturnsFetcher, + FamaFrenchInternationalIndexReturnsQueryParams, + ), +] + + +def test_factors_transform_data_missing_values(): + frame = DataFrame( + {"Mkt-RF": ["1.0", "-99.99"], "SMB": ["0.1", "0.2"], "RF": ["0.01", "-999"]}, + index=DATES[:2], + ) + frame.index.name = "Date" + + result = FamaFrenchFactorsFetcher.transform_data( + FamaFrenchFactorsQueryParams(), ([frame], [{"description": "Factors"}]) + ).result + + assert [r.mkt_rf for r in result] == [1.0, None] + assert [r.smb for r in result] == [0.1, 0.2] + assert [r.rf for r in result] == [0.01, None] + + +@pytest.mark.parametrize(("fetcher", "query_cls"), PORTFOLIO_FETCHERS) +def test_portfolio_transform_data_missing_values(fetcher, query_cls): + frame = DataFrame( + { + "Lo 30": ["1.0", "-99.99", "3.0"], + "Hi 30": ["-999", "2.0", "-99.990"], + "Med 40": ["-99.99", "-999", "-999.00"], + }, + index=DATES, + ) + frame.index.name = "Date" + + result = fetcher.transform_data( + query_cls(measure="value"), ([frame], [{"description": "Portfolio"}]) + ).result + + assert {(str(r.date), r.portfolio): r.value for r in result} == { + ("2020-01-31", "Lo 30"): 1.0, + ("2020-02-29", "Lo 30"): None, + ("2020-03-31", "Lo 30"): 3.0, + ("2020-01-31", "Hi 30"): None, + ("2020-02-29", "Hi 30"): 2.0, + ("2020-03-31", "Hi 30"): None, + } + + +@pytest.mark.parametrize(("fetcher", "query_cls"), PORTFOLIO_FETCHERS) +def test_portfolio_transform_data_number_of_firms_missing_values(fetcher, query_cls): + frame = DataFrame({"Lo 30": ["10", "-999", "-99.99"]}, index=DATES) + frame.index.name = "Date" + + result = fetcher.transform_data( + query_cls(measure="number_of_firms"), ([frame], [{"description": "Portfolio"}]) + ).result + + assert [r.value for r in result] == [10, None, None] + assert isinstance(result[0].value, int) + + +@pytest.mark.parametrize(("fetcher", "query_cls"), INTERNATIONAL_FETCHERS) +def test_international_transform_data_missing_values(fetcher, query_cls): + frame = DataFrame( + { + "Date": DATES, + "Mkt": ["1.0", "-99.99", "3.0"], + "High": ["0.5", "-99.99", "-999"], + "Low": ["-99.99", "0.2", "0.3"], + } + ).set_index(["Date", "Mkt"]) + frame.columns = MultiIndex.from_arrays([["BE/ME", "BE/ME"], ["High", "Low"]]) + + result = fetcher.transform_data( + query_cls(measure="usd"), ([frame], [{"description": "Index"}]) + ).result + + assert [r.mkt for r in result] == [1.0, None, 3.0] + assert [r.be_me_high for r in result] == [0.5, None, None] + assert [r.be_me_low for r in result] == [None, 0.2, 0.3]