refactor econometrics for v5 (#7505)

Co-authored-by: deeleeramone <>
This commit is contained in:
Danglewood
2026-06-01 09:12:06 -07:00
committed by GitHub
parent 9061473d9e
commit 35aacde541
27 changed files with 6954 additions and 4428 deletions

View File

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

View File

@@ -0,0 +1,122 @@
name: 🧪 test openbb-econometrics
# PR-test for the Econometrics extension. Triggers when a PR touches any file
# under ``openbb_platform/extensions/econometrics/`` or this workflow itself.
#
# Build-once, test-many: a single ``build`` job produces the wheel + sdist;
# the ``lint`` and ``test`` jobs install the extension editable from source
# (``[tool.uv.sources]`` pins openbb-core to the sibling tree, since
# openbb-core>=2.0.0 is not yet on PyPI) and run across every supported
# (OS, Python) combination. The command suite is pure compute — no network.
on:
pull_request:
branches: [develop, main, v5, feature/v5-platform-api]
paths:
- "openbb_platform/extensions/econometrics/**"
- ".github/workflows/test-unit-openbb-econometrics.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: test-openbb-econometrics-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build sdist + wheel
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: openbb_platform/extensions/econometrics
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- run: python -m pip install --upgrade pip uv
- name: Build sdist + wheel
run: uv build --no-sources
- uses: actions/upload-artifact@v4
with:
name: openbb-econometrics-dist
path: openbb_platform/extensions/econometrics/dist/
if-no-files-found: error
retention-days: 7
lint:
name: Lint (ruff + ty)
needs: build
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: openbb_platform/extensions/econometrics
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.11"
- run: python -m pip install --upgrade pip uv
- name: Install openbb-econometrics in editable mode (+ dev group)
run: |
uv pip install --system -e .
uv pip install --system --group dev
- name: ruff check
run: ruff check openbb_econometrics tests integration
- name: ruff format --check
run: ruff format --check openbb_econometrics tests integration
- name: ty check
run: ty check openbb_econometrics
test:
name: Test (${{ matrix.os }} / Python ${{ matrix.python_version }})
needs: build
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, python_version: "3.10" }
- { os: ubuntu-latest, python_version: "3.11" }
- { os: ubuntu-latest, python_version: "3.12" }
- { os: ubuntu-latest, python_version: "3.13" }
- { os: ubuntu-latest, python_version: "3.14" }
- { os: macos-latest, python_version: "3.10" }
- { os: macos-latest, python_version: "3.14" }
- { os: windows-latest, python_version: "3.10" }
- { os: windows-latest, python_version: "3.14" }
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
working-directory: openbb_platform/extensions/econometrics
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python_version }}
allow-prereleases: true
- run: python -m pip install --upgrade pip uv
- name: Install openbb-econometrics from source (+ dev group)
run: |
uv pip install --system -e .
uv pip install --system --group dev
- name: Run unit tests
run: pytest tests --cov=openbb_econometrics --cov-report=term-missing --cov-fail-under=100

View File

@@ -1,13 +1,90 @@
# Econometrics extension for OpenBB Platform
# OpenBB Econometrics Extension
This extension provides a set of econometrics tools.
This package adds the `openbb-econometrics` extension to the Open Data Platform by OpenBB.
It provides a toolkit of econometrics commands — regression, regression diagnostics,
stationarity and cointegration tests, Granger causality, GARCH volatility modelling, and
panel-data estimators — that operate on any tabular dataset passed in as `data`.
## Installation
To install the extension, run the following command in this folder:
Install from PyPI with:
```bash
```sh
pip install openbb-econometrics
```
Documentation available [here](https://docs.openbb.co/platform/developer_guide/contributing).
Then build the Python static assets by running:
```sh
openbb-build
```
## Quick Start
Every command is a `POST` endpoint that takes a `data` payload (a list of records, e.g.
the `.results` of another OpenBB command) plus typed parameters, and returns an `OBBject`.
```python
from openbb import obb
prices = obb.equity.price.historical(
symbol="AAPL", start_date="2023-01-01", provider="yfinance"
).results
# Coefficient table of an OLS regression.
obb.econometrics.ols_regression(
data=prices, y_column="close", x_columns=["open", "high", "low"]
)
# Augmented Dickey-Fuller stationarity test.
obb.econometrics.unit_root(data=prices, column="close")
# GARCH(1, 1) conditional volatility.
obb.econometrics.garch(data=prices, column="close")
```
To use the extension over HTTP, start the API server with `openbb-api` and POST to
`/api/v1/econometrics/<command>`.
## Coverage
All commands are available under `obb.econometrics.*`.
### Tools
- `correlation_matrix` — correlation matrix of a dataset
- `summary_statistics` — per-column descriptive statistics
### Regression
- `ols_regression` — Ordinary Least Squares coefficient table
- `ols_regression_summary` — OLS goodness-of-fit summary
### Diagnostics
- `autocorrelation` — Durbin-Watson autocorrelation test
- `residual_autocorrelation` — Breusch-Godfrey residual autocorrelation test
- `heteroskedasticity` — Breusch-Pagan and White heteroskedasticity tests
- `normality` — Jarque-Bera normality test on residuals
- `variance_inflation_factor` — multicollinearity (VIF) test
### Time Series
- `unit_root` — Augmented Dickey-Fuller unit root test
- `kpss` — KPSS stationarity test
- `cointegration` — Engle-Granger pairwise cointegration test
- `cointegration_johansen` — Johansen multivariate cointegration test
- `causality` — Granger causality test
- `garch` — GARCH(p, q) conditional volatility model
### Panel
- `panel_random_effects` — one-way Random Effects model
- `panel_between` — Between estimator
- `panel_pooled` — Pooled OLS estimator
- `panel_fixed` — Fixed Effects estimator
- `panel_first_difference` — First-Difference estimator
- `panel_fmac` — Fama-MacBeth estimator
See the full docs [here](https://docs.openbb.co/odp/python/extensions/data-processing/econometrics)

View File

@@ -0,0 +1,82 @@
"""Integration-test fixtures for openbb-econometrics."""
import threading
import time
import numpy as np
import pandas as pd
import pytest
from openbb_core.app.utils import df_to_basemodel
from openbb_econometrics.utils import mock_multi_index_data
_SEED = 20240517
_N_ROWS = 120
@pytest.fixture(scope="session", autouse=True)
def api_server(pytestconfig):
"""Run the OpenBB REST API on 0.0.0.0:8000 for the integration session."""
if pytestconfig.getoption("markexpr") == "not integration":
yield None
return
import uvicorn
from openbb_core.api.rest_api import app
config = uvicorn.Config(app, host="0.0.0.0", port=8000, log_level="warning") # noqa: S104
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.time() + 60
while time.time() < deadline:
if server.started:
break
time.sleep(0.25)
else: # pragma: no cover
raise RuntimeError("The OpenBB REST API did not start within 60s.")
yield server
server.should_exit = True
thread.join(timeout=15)
def _timeseries_records() -> list[dict]:
"""Build a deterministic OHLCV time series as JSON-ready records."""
rng = np.random.default_rng(_SEED)
t = np.arange(_N_ROWS)
base = 100 + 0.5 * t + rng.normal(0, 2, _N_ROWS).cumsum() * 0.3
close = base + rng.normal(0, 1, _N_ROWS)
open_ = close + rng.normal(0, 0.5, _N_ROWS)
high = np.maximum(open_, close) + np.abs(rng.normal(1, 0.5, _N_ROWS))
low = np.minimum(open_, close) - np.abs(rng.normal(1, 0.5, _N_ROWS))
volume = 1_000_000 + rng.normal(0, 100_000, _N_ROWS) + 5_000 * t
dates = pd.date_range("2021-01-04", periods=_N_ROWS, freq="B")
return [
{
"date": day.strftime("%Y-%m-%d"),
"open": round(float(o), 4),
"high": round(float(h), 4),
"low": round(float(lo), 4),
"close": round(float(c), 4),
"volume": round(float(v), 2),
}
for day, o, h, lo, c, v in zip(dates, open_, high, low, close, volume)
]
@pytest.fixture(scope="session")
def timeseries_data() -> list[dict]:
"""Deterministic OHLCV records for the time-series command tests."""
return _timeseries_records()
@pytest.fixture(scope="session")
def panel_data() -> list[dict]:
"""Deterministic entity/time panel records as JSON-safe dicts."""
np.random.seed(_SEED)
return [
row.model_dump(mode="json") for row in df_to_basemodel(mock_multi_index_data())
]

View File

@@ -1,335 +1,238 @@
"""Test econometrics extension."""
"""Integration tests for the openbb-econometrics HTTP API interface."""
import base64
import json
import random
from typing import Literal
import pytest
import requests
from openbb_core.env import Env
from openbb_core.provider.utils.helpers import get_querystring
data: dict = {}
_BASE_URL = "http://127.0.0.1:8000/api/v1/econometrics"
_headers: dict = {}
def get_headers():
"""Get the headers for the API request."""
if "headers" in data:
return data["headers"]
"""Build the Basic-auth headers for the local API server."""
if _headers:
return _headers
userpass = f"{Env().API_USERNAME}:{Env().API_PASSWORD}"
userpass_bytes = userpass.encode("ascii")
base64_bytes = base64.b64encode(userpass_bytes)
data["headers"] = {"Authorization": f"Basic {base64_bytes.decode('ascii')}"}
return data["headers"]
base64_bytes = base64.b64encode(userpass.encode("ascii"))
_headers["Authorization"] = f"Basic {base64_bytes.decode('ascii')}"
return _headers
def request_data(menu: str, symbol: str, provider: str):
"""Randomly pick a symbol and a provider and get data from the selected menu."""
url = f"http://0.0.0.0:8000/api/v1/{menu}/price/historical?symbol={symbol}&provider={provider}"
result = requests.get(url, headers=get_headers(), timeout=10)
return result.json()["results"]
def get_equity_data():
"""Get equity data."""
if "equity_data" in data:
return data["equity_data"]
symbol = random.choice(["AAPL", "NVDA", "MSFT", "TSLA", "AMZN", "V"]) # noqa: S311
provider = random.choice(["fmp", "yfinance"]) # noqa: S311
data["equity_data"] = request_data("equity", symbol=symbol, provider=provider)
return data["equity_data"]
def get_crypto_data():
"""Get crypto data."""
if "crypto_data" in data:
return data["crypto_data"]
# TODO : add more crypto providers and symbols
symbol = random.choice(["BTCUSD"]) # noqa: S311
provider = random.choice(["fmp"]) # noqa: S311
data["crypto_data"] = request_data(
menu="crypto",
symbol=symbol,
provider=provider,
def _post(endpoint: str, payload: dict) -> requests.Response:
"""POST a QueryParams payload to an econometrics endpoint."""
return requests.post(
f"{_BASE_URL}/{endpoint}", headers=get_headers(), timeout=30, json=payload
)
return data["crypto_data"]
def get_data(menu: Literal["equity", "crypto"]):
"""Get data based on the selected menu."""
funcs = {"equity": get_equity_data, "crypto": get_crypto_data}
return funcs[menu]()
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "method": "pearson"}, "equity"),
({"data": "", "method": "pearson"}, "crypto"),
],
)
@pytest.mark.integration
def test_econometrics_correlation_matrix(params, data_type):
"""Test the correlation matrix endpoint."""
params = {p: v for p, v in params.items() if v}
body = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/econometrics/correlation_matrix?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=body)
assert isinstance(result, requests.Response)
def test_econometrics_correlation_matrix(timeseries_data):
result = _post("correlation_matrix", {"data": timeseries_data, "method": "pearson"})
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
(
{"data": "", "y_column": "close", "x_columns": ["high"]},
"equity",
),
(
{"data": "", "y_column": "close", "x_columns": ["high"]},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_ols_regression_summary(params, data_type):
"""Test the OLS regression summary endpoint."""
params = {p: v for p, v in params.items() if v}
def test_econometrics_summary_statistics(timeseries_data):
result = _post("summary_statistics", {"data": timeseries_data})
assert result.status_code == 200
assert result.json()["results"]
body = json.dumps(
@pytest.mark.integration
def test_econometrics_ols_regression(timeseries_data):
result = _post(
"ols_regression",
{"data": timeseries_data, "y_column": "close", "x_columns": ["high", "low"]},
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_ols_regression_summary(timeseries_data):
result = _post(
"ols_regression_summary",
{"data": timeseries_data, "y_column": "close", "x_columns": ["high", "low"]},
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_autocorrelation(timeseries_data):
result = _post(
"autocorrelation",
{"data": timeseries_data, "y_column": "close", "x_columns": ["open"]},
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_residual_autocorrelation(timeseries_data):
result = _post(
"residual_autocorrelation",
{
"data": get_data(data_type),
"x_columns": params.pop("x_columns"),
}
"data": timeseries_data,
"y_column": "close",
"x_columns": ["open"],
"lags": 4,
},
)
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/econometrics/ols_regression_summary?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=20, data=body)
assert isinstance(result, requests.Response)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
(
{"data": "", "y_column": "volume", "x_columns": ["close"]},
"equity",
),
(
{"data": "", "y_column": "volume", "x_columns": ["close"]},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_autocorrelation(params, data_type):
"""Test the autocorrelation endpoint."""
params = {p: v for p, v in params.items() if v}
def test_econometrics_heteroskedasticity(timeseries_data):
result = _post(
"heteroskedasticity",
{"data": timeseries_data, "y_column": "close", "x_columns": ["open"]},
)
assert result.status_code == 200
assert result.json()["results"]
body = json.dumps(
@pytest.mark.integration
def test_econometrics_normality(timeseries_data):
result = _post(
"normality",
{"data": timeseries_data, "y_column": "close", "x_columns": ["open"]},
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_variance_inflation_factor(timeseries_data):
result = _post(
"variance_inflation_factor",
{"data": timeseries_data, "columns": ["open", "high", "low"]},
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_unit_root(timeseries_data):
result = _post(
"unit_root", {"data": timeseries_data, "column": "close", "regression": "c"}
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_kpss(timeseries_data):
result = _post(
"kpss", {"data": timeseries_data, "column": "close", "regression": "c"}
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_cointegration(timeseries_data):
result = _post(
"cointegration", {"data": timeseries_data, "columns": ["open", "close"]}
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_cointegration_johansen(timeseries_data):
result = _post(
"cointegration_johansen",
{"data": timeseries_data, "columns": ["open", "high", "close"]},
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_causality(timeseries_data):
result = _post(
"causality",
{
"data": get_data(data_type),
"x_columns": params.pop("x_columns"),
}
"data": timeseries_data,
"y_column": "close",
"x_column": "volume",
"lag": 3,
},
)
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/econometrics/autocorrelation?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=body)
assert isinstance(result, requests.Response)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"y_column": "volume",
"x_columns": ["close"],
"lags": "",
},
"equity",
),
(
{
"data": "",
"y_column": "volume",
"x_columns": ["close"],
"lags": "2",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_residual_autocorrelation(params, data_type):
"""Test the residual autocorrelation endpoint."""
params = {p: v for p, v in params.items() if v}
body = json.dumps(
{
"data": get_data(data_type),
"x_columns": params.pop("x_columns"),
}
def test_econometrics_garch(timeseries_data):
result = _post(
"garch", {"data": timeseries_data, "column": "close", "p": 1, "q": 1}
)
assert result.status_code == 200
assert result.json()["results"]
query_str = get_querystring(params, [])
url = (
f"http://0.0.0.0:8000/api/v1/econometrics/residual_autocorrelation?{query_str}"
@pytest.mark.integration
def test_econometrics_panel_random_effects(panel_data):
result = _post(
"panel_random_effects",
{"data": panel_data, "y_column": "income", "x_columns": ["age", "education"]},
)
result = requests.post(url, headers=get_headers(), timeout=10, data=body)
assert isinstance(result, requests.Response)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"columns": ["close", "volume"],
},
"equity",
),
(
{
"data": "",
"columns": ["close", "volume"],
},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_cointegration(params, data_type):
"""Test the cointegration endpoint."""
params = {p: v for p, v in params.items() if v}
body = json.dumps(
{
"data": get_data(data_type),
"columns": params.pop("columns"),
}
def test_econometrics_panel_between(panel_data):
result = _post(
"panel_between",
{"data": panel_data, "y_column": "income", "x_columns": ["age", "education"]},
)
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/econometrics/cointegration?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=body)
assert isinstance(result, requests.Response)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
(
{"data": "", "y_column": "volume", "x_column": "close", "lag": ""},
"equity",
),
(
{"data": "", "y_column": "volume", "x_column": "close", "lag": "2"},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_causality(params, data_type):
"""Test the causality endpoint."""
params = {p: v for p, v in params.items() if v}
body = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/econometrics/causality?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=body)
assert isinstance(result, requests.Response)
assert result.status_code == 200
@pytest.mark.parametrize(
"params, data_type",
[({"data": "", "column": "high", "regression": "c"}, "equity")],
)
@pytest.mark.integration
def test_econometrics_unit_root(params, data_type):
"""Test the unit root endpoint."""
params = {p: v for p, v in params.items() if v}
body = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/econometrics/unit_root?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=body)
assert isinstance(result, requests.Response)
assert result.status_code == 200
@pytest.mark.parametrize(
"params, data_type",
[
(
{"data": "", "y_column": "close", "x_columns": ["high"]},
"equity",
),
(
{"data": "", "y_column": "close", "x_columns": ["high"]},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_ols_regression(params, data_type):
"""Test the OLS regression function in econometrics extension."""
params = {p: v for p, v in params.items() if v}
body = json.dumps(
{
"data": get_data(data_type),
"x_columns": params.pop("x_columns"),
}
def test_econometrics_panel_pooled(panel_data):
result = _post(
"panel_pooled",
{"data": panel_data, "y_column": "income", "x_columns": ["age", "education"]},
)
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/econometrics/ols_regression?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=body)
assert isinstance(result, requests.Response)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "columns": ["high", "low"]}, "equity"),
({"data": "", "columns": ["high", "low"]}, "crypto"),
],
)
@pytest.mark.integration
def test_econometrics_variance_inflation_factor(params, data_type):
"""Test the variance inflation factor endpoint."""
params = {p: v for p, v in params.items() if v}
body = json.dumps(
{
"data": get_data(data_type),
"columns": params.pop("columns"),
}
def test_econometrics_panel_fixed(panel_data):
result = _post(
"panel_fixed",
{"data": panel_data, "y_column": "income", "x_columns": ["age", "education"]},
)
url = "http://0.0.0.0:8000/api/v1/econometrics/variance_inflation_factor"
result = requests.post(url, headers=get_headers(), timeout=10, data=body)
assert isinstance(result, requests.Response)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_panel_first_difference(panel_data):
result = _post(
"panel_first_difference",
{"data": panel_data, "y_column": "income", "x_columns": ["age"]},
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_econometrics_panel_fmac(panel_data):
result = _post(
"panel_fmac",
{"data": panel_data, "y_column": "income", "x_columns": ["age", "education"]},
)
assert result.status_code == 200
assert result.json()["results"]

View File

@@ -1,423 +1,195 @@
"""Test econometrics extension."""
import random
from typing import Literal
"""Integration tests for the openbb-econometrics Python interface."""
import pytest
from openbb_core.app.model.obbject import OBBject
from openbb_econometrics.utils import mock_multi_index_data
# pylint: disable=inconsistent-return-statements
@pytest.fixture(scope="session")
def obb(pytestconfig):
"""Fixture to setup obb."""
"""Return the obb application object for the integration session."""
if pytestconfig.getoption("markexpr") != "not integration":
import openbb # pylint: disable=import-outside-toplevel
import openbb
return openbb.obb
return None
# pylint: disable=redefined-outer-name
data: dict = {}
def get_stocks_data():
"""Get stocks data."""
import openbb # pylint:disable=import-outside-toplevel
if "stocks_data" in data:
return data["stocks_data"]
symbol = random.choice(["AAPL", "NVDA", "MSFT", "TSLA", "AMZN", "V"]) # noqa: S311
provider = random.choice(["fmp", "yfinance"]) # noqa: S311
data["stocks_data"] = openbb.obb.equity.price.historical(
symbol=symbol, provider=provider
).results
return data["stocks_data"]
def get_crypto_data():
"""Get crypto data."""
import openbb # pylint:disable=import-outside-toplevel
if "crypto_data" in data:
return data["crypto_data"]
# TODO : add more crypto providers and symbols
symbol = random.choice(["BTCUSD"]) # noqa: S311
provider = random.choice(["fmp"]) # noqa: S311
data["crypto_data"] = openbb.obb.crypto.price.historical(
symbol=symbol, provider=provider
).results
return data["crypto_data"]
def get_data(menu: Literal["equity", "crypto"]):
"""Get data."""
funcs = {"equity": get_stocks_data, "crypto": get_crypto_data}
return funcs[menu]()
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "method": "pearson"}, "equity"),
({"data": "", "method": "pearson"}, "crypto"),
],
)
@pytest.mark.integration
def test_econometrics_correlation_matrix(params, data_type, obb):
"""Test the econometrics correlation matrix."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.econometrics.correlation_matrix(**params)
assert result
def test_econometrics_correlation_matrix(obb, timeseries_data):
result = obb.econometrics.correlation_matrix(data=timeseries_data, method="pearson")
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{"data": "", "y_column": "close", "x_columns": ["high"]},
"equity",
),
(
{"data": "", "y_column": "close", "x_columns": ["high"]},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_ols_regression(params, data_type, obb):
"""Test the econometrics OLS regression."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.econometrics.ols_regression(**params)
assert result
def test_econometrics_summary_statistics(obb, timeseries_data):
result = obb.econometrics.summary_statistics(data=timeseries_data)
assert isinstance(result, OBBject)
assert result.results is not None
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{"data": "", "y_column": "close", "x_columns": ["high"]},
"equity",
),
(
{"data": "", "y_column": "close", "x_columns": ["high"]},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_ols_regression_summary(params, data_type, obb):
"""Test the econometrics OLS regression summary."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.econometrics.ols_regression_summary(**params)
assert result
def test_econometrics_ols_regression(obb, timeseries_data):
result = obb.econometrics.ols_regression(
data=timeseries_data, y_column="close", x_columns=["high", "low"]
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{"data": "", "y_column": "volume", "x_columns": ["close"]},
"equity",
),
(
{"data": "", "y_column": "volume", "x_columns": ["close"]},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_autocorrelation(params, data_type, obb):
"""Test the econometrics autocorrelation."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.econometrics.autocorrelation(**params)
assert result
def test_econometrics_ols_regression_summary(obb, timeseries_data):
result = obb.econometrics.ols_regression_summary(
data=timeseries_data, y_column="close", x_columns=["high", "low"]
)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"y_column": "volume",
"x_columns": ["close"],
"lags": "",
},
"equity",
),
(
{
"data": "",
"y_column": "volume",
"x_columns": ["close"],
"lags": "2",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_residual_autocorrelation(params, data_type, obb):
"""Test the econometrics residual autocorrelation."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.econometrics.residual_autocorrelation(**params)
assert result
def test_econometrics_autocorrelation(obb, timeseries_data):
result = obb.econometrics.autocorrelation(
data=timeseries_data, y_column="close", x_columns=["open"]
)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"columns": ["close", "volume"],
},
"equity",
),
(
{
"data": "",
"columns": ["close", "volume"],
},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_cointegration(params, data_type, obb):
"""Test the econometrics cointegration."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.econometrics.cointegration(**params)
assert result
def test_econometrics_residual_autocorrelation(obb, timeseries_data):
result = obb.econometrics.residual_autocorrelation(
data=timeseries_data, y_column="close", x_columns=["open"], lags=4
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"y_column": "volume",
"x_column": "close",
"lag": "",
},
"equity",
),
(
{
"data": "",
"y_column": "volume",
"x_column": "close",
"lag": "2",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_causality(params, data_type, obb):
"""Test the econometrics causality."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.econometrics.causality(**params)
assert result
def test_econometrics_heteroskedasticity(obb, timeseries_data):
result = obb.econometrics.heteroskedasticity(
data=timeseries_data, y_column="close", x_columns=["open"]
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "column": "close", "regression": "c"}, "equity"),
(
{"data": "", "column": "volume", "regression": "ctt"},
"crypto",
),
],
)
@pytest.mark.integration
def test_econometrics_unit_root(params, data_type, obb):
"""Test the econometrics unit root."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.econometrics.unit_root(**params)
assert result
def test_econometrics_normality(obb, timeseries_data):
result = obb.econometrics.normality(
data=timeseries_data, y_column="close", x_columns=["open"]
)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.parametrize(
"params",
[
{"data": "", "y_column": "income", "x_columns": ["age"]},
{"data": "", "y_column": "income", "x_columns": ["age"]},
],
)
@pytest.mark.integration
def test_econometrics_panel_random_effects(params, obb):
"""Test the econometrics panel random effects."""
params = {p: v for p, v in params.items() if v}
params["data"] = mock_multi_index_data()
result = obb.econometrics.panel_random_effects(**params)
assert result
def test_econometrics_variance_inflation_factor(obb, timeseries_data):
result = obb.econometrics.variance_inflation_factor(
data=timeseries_data, columns=["open", "high", "low"]
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params",
[
{"data": "", "y_column": "income", "x_columns": ["age"]},
{"data": "", "y_column": "income", "x_columns": ["age"]},
],
)
@pytest.mark.integration
def test_econometrics_panel_between(params, obb):
"""Test the econometrics panel between."""
params = {p: v for p, v in params.items() if v}
params["data"] = mock_multi_index_data()
result = obb.econometrics.panel_between(**params)
assert result
def test_econometrics_unit_root(obb, timeseries_data):
result = obb.econometrics.unit_root(
data=timeseries_data, column="close", regression="c"
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params",
[
{"data": "", "y_column": "income", "x_columns": ["age"]},
{"data": "", "y_column": "income", "x_columns": ["age"]},
],
)
@pytest.mark.integration
def test_econometrics_panel_pooled(params, obb):
"""Test the econometrics panel pooled."""
params = {p: v for p, v in params.items() if v}
params["data"] = mock_multi_index_data()
result = obb.econometrics.panel_pooled(**params)
assert result
def test_econometrics_kpss(obb, timeseries_data):
result = obb.econometrics.kpss(data=timeseries_data, column="close", regression="c")
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params",
[
{"data": "", "y_column": "income", "x_columns": ["age"]},
{"data": "", "y_column": "income", "x_columns": ["age"]},
],
)
@pytest.mark.integration
def test_econometrics_panel_fixed(params, obb):
"""Test the econometrics panel fixed."""
params = {p: v for p, v in params.items() if v}
params["data"] = mock_multi_index_data()
result = obb.econometrics.panel_fixed(**params)
assert result
def test_econometrics_cointegration(obb, timeseries_data):
result = obb.econometrics.cointegration(
data=timeseries_data, columns=["open", "close"]
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params",
[
{"data": "", "y_column": "income", "x_columns": ["age"]},
{"data": "", "y_column": "income", "x_columns": ["age"]},
],
)
@pytest.mark.integration
def test_econometrics_panel_first_difference(params, obb):
"""Test the econometrics panel first difference."""
params = {p: v for p, v in params.items() if v}
params["data"] = mock_multi_index_data()
result = obb.econometrics.panel_first_difference(**params)
assert result
def test_econometrics_cointegration_johansen(obb, timeseries_data):
result = obb.econometrics.cointegration_johansen(
data=timeseries_data, columns=["open", "high", "close"]
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params",
[
{"data": "", "y_column": "income", "x_columns": ["age"]},
{"data": "", "y_column": "income", "x_columns": ["age"]},
],
)
@pytest.mark.integration
def test_econometrics_panel_fmac(params, obb):
"""Test the econometrics panel fmac."""
params = {p: v for p, v in params.items() if v}
params["data"] = mock_multi_index_data()
result = obb.econometrics.panel_fmac(**params)
"""Test the econometrics panel fmac."""
assert result
def test_econometrics_causality(obb, timeseries_data):
result = obb.econometrics.causality(
data=timeseries_data, y_column="close", x_column="volume", lag=3
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params",
[
({"data": "", "columns": ["income", "age"]}),
({"data": "", "columns": ["education"]}),
],
)
@pytest.mark.integration
def test_econometrics_variance_inflation_factor(params, obb):
params = {p: v for p, v in params.items() if v}
params["data"] = mock_multi_index_data()
result = obb.econometrics.variance_inflation_factor(**params)
assert result
def test_econometrics_garch(obb, timeseries_data):
result = obb.econometrics.garch(data=timeseries_data, column="close", p=1, q=1)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.integration
def test_econometrics_panel_random_effects(obb, panel_data):
result = obb.econometrics.panel_random_effects(
data=panel_data, y_column="income", x_columns=["age", "education"]
)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.integration
def test_econometrics_panel_between(obb, panel_data):
result = obb.econometrics.panel_between(
data=panel_data, y_column="income", x_columns=["age", "education"]
)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.integration
def test_econometrics_panel_pooled(obb, panel_data):
result = obb.econometrics.panel_pooled(
data=panel_data, y_column="income", x_columns=["age", "education"]
)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.integration
def test_econometrics_panel_fixed(obb, panel_data):
result = obb.econometrics.panel_fixed(
data=panel_data, y_column="income", x_columns=["age", "education"]
)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.integration
def test_econometrics_panel_first_difference(obb, panel_data):
result = obb.econometrics.panel_first_difference(
data=panel_data, y_column="income", x_columns=["age"]
)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.integration
def test_econometrics_panel_fmac(obb, panel_data):
result = obb.econometrics.panel_fmac(
data=panel_data, y_column="income", x_columns=["age", "education"]
)
assert isinstance(result, OBBject)
assert result.results

View File

@@ -0,0 +1,353 @@
"""Econometrics regression-diagnostic commands."""
from openbb_core.app.model.example import APIEx, PythonEx
from openbb_core.app.model.obbject import OBBject
from openbb_core.app.router import Router
from openbb_core.provider.abstract.data import Data
from openbb_core.provider.abstract.query_params import QueryParams
from pydantic import Field, PositiveInt
router = Router(prefix="", description="Econometrics regression-diagnostic commands.")
def _ols_fit(data, y_column, x_columns):
"""Fit an OLS model and return the statsmodels results object."""
import statsmodels.api as sm
from openbb_core.app.utils import (
basemodel_to_df,
get_target_column,
get_target_columns,
)
df = basemodel_to_df(data)
exog = sm.add_constant(get_target_columns(df, x_columns))
endog = get_target_column(df, y_column)
return sm.OLS(endog, exog).fit()
class AutocorrelationQueryParams(QueryParams):
"""Query parameters for the Durbin-Watson autocorrelation endpoint."""
__category__ = "diagnostic"
__output_columns__ = ("durbin_watson",)
data: list[Data] = Field(description="Input dataset.")
y_column: str = Field(description="Name of the dependent (target) column.")
x_columns: list[str] = Field(
description="Names of the independent (exogenous) columns."
)
class AutocorrelationData(Data):
"""Durbin-Watson autocorrelation test result."""
durbin_watson: float = Field(
description="Durbin-Watson statistic (near 2 = no autocorrelation)."
)
class ResidualAutocorrelationQueryParams(QueryParams):
"""Query parameters for the Breusch-Godfrey residual autocorrelation endpoint."""
__category__ = "diagnostic"
__output_columns__ = (
"lm_statistic",
"lm_p_value",
"f_statistic",
"f_p_value",
)
data: list[Data] = Field(description="Input dataset.")
y_column: str = Field(description="Name of the dependent (target) column.")
x_columns: list[str] = Field(
description="Names of the independent (exogenous) columns."
)
lags: PositiveInt = Field(
default=1, description="Number of lags to include in the test."
)
class ResidualAutocorrelationData(Data):
"""Breusch-Godfrey residual autocorrelation test result."""
lm_statistic: float = Field(description="Lagrange Multiplier test statistic.")
lm_p_value: float = Field(description="p-value of the LM statistic.")
f_statistic: float = Field(description="F-statistic of the auxiliary regression.")
f_p_value: float = Field(description="p-value of the F-statistic.")
class HeteroskedasticityQueryParams(QueryParams):
"""Query parameters for the heteroskedasticity endpoint."""
__category__ = "diagnostic"
__output_columns__ = (
"breusch_pagan_lm_statistic",
"breusch_pagan_lm_p_value",
"breusch_pagan_f_statistic",
"breusch_pagan_f_p_value",
"white_lm_statistic",
"white_lm_p_value",
"white_f_statistic",
"white_f_p_value",
)
data: list[Data] = Field(description="Input dataset.")
y_column: str = Field(description="Name of the dependent (target) column.")
x_columns: list[str] = Field(
description="Names of the independent (exogenous) columns."
)
robust: bool = Field(
default=True,
description="Use the robust (studentized Koenker) Breusch-Pagan variant,"
+ " which does not assume normally distributed residuals.",
)
class HeteroskedasticityData(Data):
"""Breusch-Pagan and White heteroskedasticity test results."""
breusch_pagan_lm_statistic: float = Field(description="Breusch-Pagan LM statistic.")
breusch_pagan_lm_p_value: float = Field(
description="p-value of the Breusch-Pagan LM statistic."
)
breusch_pagan_f_statistic: float = Field(description="Breusch-Pagan F statistic.")
breusch_pagan_f_p_value: float = Field(
description="p-value of the Breusch-Pagan F statistic."
)
white_lm_statistic: float = Field(description="White LM statistic.")
white_lm_p_value: float = Field(description="p-value of the White LM statistic.")
white_f_statistic: float = Field(description="White F statistic.")
white_f_p_value: float = Field(description="p-value of the White F statistic.")
class NormalityQueryParams(QueryParams):
"""Query parameters for the Jarque-Bera normality endpoint."""
__category__ = "diagnostic"
__output_columns__ = ("jarque_bera", "p_value", "skew", "kurtosis")
data: list[Data] = Field(description="Input dataset.")
y_column: str = Field(description="Name of the dependent (target) column.")
x_columns: list[str] = Field(
description="Names of the independent (exogenous) columns."
)
class NormalityData(Data):
"""Jarque-Bera normality test result for regression residuals."""
jarque_bera: float = Field(description="Jarque-Bera test statistic.")
p_value: float = Field(description="p-value of the test statistic.")
skew: float = Field(description="Skewness of the residuals.")
kurtosis: float = Field(description="Kurtosis of the residuals.")
class VarianceInflationFactorQueryParams(QueryParams):
"""Query parameters for the variance inflation factor endpoint."""
__category__ = "diagnostic"
__output_columns__ = ("variable", "vif")
data: list[Data] = Field(description="Input dataset.")
columns: list[str] | None = Field(
default=None,
description="Columns to test for collinearity. If omitted, all numeric columns.",
)
class VarianceInflationFactorData(Data):
"""Variance inflation factor for a single variable."""
variable: str = Field(description="Name of the variable.")
vif: float = Field(
description="Variance inflation factor (above 5 indicates high collinearity)."
)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"y_column": "close",
"x_columns": ["open", "high", "low"],
"data": APIEx.mock_data("timeseries"),
}
),
],
)
def autocorrelation(
params: AutocorrelationQueryParams,
) -> OBBject[AutocorrelationData]:
"""Perform the Durbin-Watson test for autocorrelation in regression residuals.
The Durbin-Watson statistic ranges from 0 to 4. A value near 2 suggests no
autocorrelation; values toward 0 indicate positive autocorrelation and values
toward 4 indicate negative autocorrelation.
"""
from statsmodels.stats.stattools import durbin_watson
results = _ols_fit(params.data, params.y_column, params.x_columns)
return OBBject(
results=AutocorrelationData(durbin_watson=float(durbin_watson(results.resid)))
)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"y_column": "close",
"x_columns": ["open", "high", "low"],
"data": APIEx.mock_data("timeseries"),
}
),
],
)
def residual_autocorrelation(
params: ResidualAutocorrelationQueryParams,
) -> OBBject[ResidualAutocorrelationData]:
"""Perform the Breusch-Godfrey LM test for residual autocorrelation.
The Breusch-Godfrey test detects autocorrelation in regression residuals up to a
chosen number of lags. Significant statistics indicate the model has not captured
some of the data's serial dependence.
"""
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
)
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),
)
)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"y_column": "close",
"x_columns": ["open", "high", "low"],
"data": APIEx.mock_data("timeseries"),
}
),
],
)
def heteroskedasticity(
params: HeteroskedasticityQueryParams,
) -> OBBject[HeteroskedasticityData]:
"""Perform the Breusch-Pagan and White tests for heteroskedasticity.
Heteroskedasticity - non-constant variance of the regression residuals - violates a
key OLS assumption and makes standard errors unreliable. The Breusch-Pagan test
detects linear forms; the White test additionally captures non-linear forms.
"""
from statsmodels.stats.diagnostic import het_breuschpagan, het_white
results = _ols_fit(params.data, params.y_column, params.x_columns)
breusch_pagan = het_breuschpagan(
results.resid, results.model.exog, robust=params.robust
)
white = het_white(results.resid, results.model.exog)
return OBBject(
results=HeteroskedasticityData(
breusch_pagan_lm_statistic=float(breusch_pagan[0]),
breusch_pagan_lm_p_value=float(breusch_pagan[1]),
breusch_pagan_f_statistic=float(breusch_pagan[2]),
breusch_pagan_f_p_value=float(breusch_pagan[3]),
white_lm_statistic=float(white[0]),
white_lm_p_value=float(white[1]),
white_f_statistic=float(white[2]),
white_f_p_value=float(white[3]),
)
)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"y_column": "close",
"x_columns": ["open", "high", "low"],
"data": APIEx.mock_data("timeseries"),
}
),
],
)
def normality(params: NormalityQueryParams) -> OBBject[NormalityData]:
"""Perform the Jarque-Bera normality test on regression residuals.
The Jarque-Bera test checks whether OLS residuals are normally distributed by
comparing their skewness and kurtosis to those of a normal distribution.
"""
from statsmodels.stats.stattools import jarque_bera
results = _ols_fit(params.data, params.y_column, params.x_columns)
jb_stat, jb_p_value, skew, kurtosis = jarque_bera(results.resid)
return OBBject(
results=NormalityData(
jarque_bera=float(jb_stat),
p_value=float(jb_p_value),
skew=float(skew),
kurtosis=float(kurtosis),
)
)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Calculate the variance inflation factor.",
code=[
"stock_data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').to_df()", # noqa: E501
'obb.econometrics.variance_inflation_factor(data=stock_data, columns=["open", "high", "low", "close"])', # noqa: E501
],
),
APIEx(
parameters={
"columns": ["open", "high", "low"],
"data": APIEx.mock_data("timeseries"),
}
),
],
)
def variance_inflation_factor(
params: VarianceInflationFactorQueryParams,
) -> OBBject[list[VarianceInflationFactorData]]:
"""Calculate the variance inflation factor (VIF) to detect multicollinearity.
VIF quantifies how much the variance of an estimated coefficient is inflated by
collinearity with the other regressors. A VIF between 1 and 5 is generally
acceptable; values above 5 indicate high collinearity.
"""
from openbb_core.app.utils import basemodel_to_df
from statsmodels.stats.outliers_influence import (
variance_inflation_factor as _vif,
)
from statsmodels.tools.tools import add_constant
dataset = basemodel_to_df(params.data)
dataset = dataset if params.columns is None else dataset[params.columns]
df = add_constant(dataset).select_dtypes(
exclude=["object", "datetime", "timedelta"]
)
out = [
VarianceInflationFactorData(
variable=str(df.columns[i]), vif=float(_vif(df.values, i))
)
for i in range(1, len(df.columns))
]
return OBBject(results=out)

View File

@@ -33,7 +33,6 @@ class EconometricsViews:
layout_kwargs : Dict[str, Any]
Additional keyword arguments to apply with figure.update_layout(), by default None.
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.charts.correlation_matrix import correlation_matrix
return correlation_matrix(**kwargs) # type: ignore
return correlation_matrix(**kwargs)

View File

@@ -0,0 +1,211 @@
"""Econometrics panel-data regression commands."""
from typing import Literal
from openbb_core.app.model.example import APIEx
from openbb_core.app.model.obbject import OBBject
from openbb_core.app.router import Router
from openbb_core.provider.abstract.data import Data
from openbb_core.provider.abstract.query_params import QueryParams
from pydantic import Field
router = Router(prefix="", description="Econometrics panel-data regression commands.")
class PanelRegressionQueryParams(QueryParams):
"""Query parameters shared by every panel-data regression endpoint."""
__category__ = "panel"
__output_columns__ = (
"variable",
"coefficient",
"standard_error",
"t_statistic",
"p_value",
"conf_int_lower",
"conf_int_upper",
)
data: list[Data] = Field(
description="Input panel dataset, indexed by entity and time."
)
y_column: str = Field(description="Name of the dependent (target) column.")
x_columns: list[str] = Field(
description="Names of the independent (exogenous) columns."
)
cov_type: Literal["unadjusted", "robust"] = Field(
default="unadjusted",
description="Covariance estimator: 'unadjusted' (homoskedastic) or 'robust'"
+ " (heteroskedasticity-robust standard errors).",
)
class PanelFixedQueryParams(PanelRegressionQueryParams):
"""Query parameters for the fixed-effects panel regression endpoint."""
entity_effects: bool = Field(
default=True, description="Include entity-specific fixed effects."
)
time_effects: bool = Field(
default=False, description="Include time-specific fixed effects."
)
class PanelRegressionData(Data):
"""One estimated coefficient of a panel-data regression."""
variable: str = Field(description="Name of the regressor.")
coefficient: float = Field(description="Estimated coefficient.")
standard_error: float = Field(description="Standard error of the coefficient.")
t_statistic: float = Field(
description="t-statistic for the null hypothesis that the coefficient is zero."
)
p_value: float = Field(description="p-value of the t-statistic.")
conf_int_lower: float = Field(
description="Lower bound of the 95% confidence interval."
)
conf_int_upper: float = Field(
description="Upper bound of the 95% confidence interval."
)
def _fit_panel(estimator, params, *, add_constant=True, min_observations=0):
"""Fit a linearmodels panel estimator and return its results object."""
import statsmodels.api as sm
from openbb_core.app.utils import (
basemodel_to_df,
get_target_column,
get_target_columns,
)
df = basemodel_to_df(params.data)
exog = get_target_columns(df, params.x_columns)
if min_observations and len(exog) < min_observations:
raise ValueError(
f"This analysis requires at least {min_observations} items in the dataset."
)
endog = get_target_column(df, params.y_column)
if add_constant:
exog = sm.add_constant(exog)
model_kwargs: dict = {}
if hasattr(params, "entity_effects"):
model_kwargs["entity_effects"] = params.entity_effects
model_kwargs["time_effects"] = params.time_effects
return estimator(endog, exog, **model_kwargs).fit(cov_type=params.cov_type)
def _to_rows(results) -> list[PanelRegressionData]:
"""Convert a linearmodels results object into PanelRegressionData rows."""
conf_int = results.conf_int()
return [
PanelRegressionData(
variable=str(variable),
coefficient=float(results.params[variable]),
standard_error=float(results.std_errors[variable]),
t_statistic=float(results.tstats[variable]),
p_value=float(results.pvalues[variable]),
conf_int_lower=float(conf_int.loc[variable, "lower"]),
conf_int_upper=float(conf_int.loc[variable, "upper"]),
)
for variable in results.params.index
]
_PANEL_EXAMPLE = APIEx(
parameters={
"y_column": "portfolio_value",
"x_columns": ["risk_free_rate"],
"data": APIEx.mock_data("panel"),
}
)
@router.command(methods=["POST"], examples=[_PANEL_EXAMPLE])
def panel_random_effects(
params: PanelRegressionQueryParams,
) -> OBBject[list[PanelRegressionData]]:
"""Estimate a one-way Random Effects model for panel data.
The Random Effects model treats entity-specific deviations as random draws,
capturing variation across entities while pooling information for efficient
estimation. Requires at least three observations.
"""
from linearmodels.panel import RandomEffects
results = _fit_panel(RandomEffects, params, min_observations=3)
return OBBject(results=_to_rows(results))
@router.command(methods=["POST"], examples=[_PANEL_EXAMPLE])
def panel_between(
params: PanelRegressionQueryParams,
) -> OBBject[list[PanelRegressionData]]:
"""Estimate a Between regression on panel data.
The Between estimator regresses the entity-level means, summarising the long-run
cross-sectional relationship between the regressors and the target.
"""
from linearmodels.panel import BetweenOLS
results = _fit_panel(BetweenOLS, params)
return OBBject(results=_to_rows(results))
@router.command(methods=["POST"], examples=[_PANEL_EXAMPLE])
def panel_pooled(
params: PanelRegressionQueryParams,
) -> OBBject[list[PanelRegressionData]]:
"""Estimate a Pooled OLS regression on panel data.
The Pooled estimator treats the panel as one large cross-section, assuming the
regressors have a uniform effect across all entities and time periods.
"""
from linearmodels.panel import PooledOLS
results = _fit_panel(PooledOLS, params)
return OBBject(results=_to_rows(results))
@router.command(methods=["POST"], examples=[_PANEL_EXAMPLE])
def panel_fixed(
params: PanelFixedQueryParams,
) -> OBBject[list[PanelRegressionData]]:
"""Estimate a Fixed Effects regression on panel data.
The Fixed Effects estimator controls for entity- and/or time-specific effects,
isolating the effect of the regressors from unobserved heterogeneity.
"""
from linearmodels.panel import PanelOLS
results = _fit_panel(PanelOLS, params, add_constant=False)
return OBBject(results=_to_rows(results))
@router.command(methods=["POST"], examples=[_PANEL_EXAMPLE])
def panel_first_difference(
params: PanelRegressionQueryParams,
) -> OBBject[list[PanelRegressionData]]:
"""Estimate a First-Difference regression on panel data.
Differencing consecutive observations removes time-invariant entity effects, so the
estimator measures how changes in the regressors relate to changes in the target.
"""
from linearmodels.panel import FirstDifferenceOLS
results = _fit_panel(FirstDifferenceOLS, params, add_constant=False)
return OBBject(results=_to_rows(results))
@router.command(methods=["POST"], examples=[_PANEL_EXAMPLE])
def panel_fmac(
params: PanelRegressionQueryParams,
) -> OBBject[list[PanelRegressionData]]:
"""Estimate a Fama-MacBeth regression on panel data.
The Fama-MacBeth procedure runs a cross-sectional regression each period and
averages the coefficients over time - widely used to estimate risk premia.
"""
from linearmodels.panel import FamaMacBeth
results = _fit_panel(FamaMacBeth, params)
return OBBject(results=_to_rows(results))

View File

@@ -0,0 +1,227 @@
"""Econometrics regression commands - Ordinary Least Squares."""
from typing import Literal
from openbb_core.app.model.example import APIEx, PythonEx
from openbb_core.app.model.obbject import OBBject
from openbb_core.app.router import Router
from openbb_core.provider.abstract.data import Data
from openbb_core.provider.abstract.query_params import QueryParams
from pydantic import Field
router = Router(prefix="", description="Econometrics regression commands.")
class OlsRegressionQueryParams(QueryParams):
"""Query parameters for the OLS regression endpoint."""
__category__ = "regression"
__output_columns__ = (
"variable",
"coefficient",
"standard_error",
"t_statistic",
"p_value",
"conf_int_lower",
"conf_int_upper",
)
data: list[Data] = Field(description="Input dataset.")
y_column: str = Field(description="Name of the dependent (target) column.")
x_columns: list[str] = Field(
description="Names of the independent (exogenous) columns."
)
cov_type: Literal["nonrobust", "HC0", "HC1", "HC2", "HC3"] = Field(
default="nonrobust",
description="Covariance estimator for the standard errors: 'nonrobust', or"
+ " heteroskedasticity-robust 'HC0', 'HC1', 'HC2', or 'HC3'.",
)
class OlsRegressionData(Data):
"""One estimated coefficient of an OLS regression."""
variable: str = Field(
description="Name of the regressor ('const' is the intercept)."
)
coefficient: float = Field(description="Estimated coefficient.")
standard_error: float = Field(description="Standard error of the coefficient.")
t_statistic: float = Field(
description="t-statistic for the null hypothesis that the coefficient is zero."
)
p_value: float = Field(description="Two-sided p-value of the t-statistic.")
conf_int_lower: float = Field(
description="Lower bound of the 95% confidence interval."
)
conf_int_upper: float = Field(
description="Upper bound of the 95% confidence interval."
)
class OlsRegressionSummaryQueryParams(QueryParams):
"""Query parameters for the OLS regression summary endpoint."""
__category__ = "regression"
__output_columns__ = (
"r_squared",
"adjusted_r_squared",
"f_statistic",
"f_p_value",
"aic",
"bic",
"log_likelihood",
"nobs",
"df_model",
"df_residuals",
)
data: list[Data] = Field(description="Input dataset.")
y_column: str = Field(description="Name of the dependent (target) column.")
x_columns: list[str] = Field(
description="Names of the independent (exogenous) columns."
)
cov_type: Literal["nonrobust", "HC0", "HC1", "HC2", "HC3"] = Field(
default="nonrobust",
description="Covariance estimator for the standard errors: 'nonrobust', or"
+ " heteroskedasticity-robust 'HC0', 'HC1', 'HC2', or 'HC3'.",
)
class OlsRegressionSummaryData(Data):
"""Goodness-of-fit summary of an OLS regression."""
r_squared: float = Field(description="Coefficient of determination.")
adjusted_r_squared: float = Field(
description="R-squared adjusted for the number of regressors."
)
f_statistic: float | None = Field(
default=None, description="F-statistic of the overall regression."
)
f_p_value: float | None = Field(
default=None, description="p-value of the F-statistic."
)
aic: float = Field(description="Akaike Information Criterion.")
bic: float = Field(description="Bayesian Information Criterion.")
log_likelihood: float = Field(description="Log-likelihood of the fitted model.")
nobs: int = Field(description="Number of observations.")
df_model: float = Field(description="Model degrees of freedom.")
df_residuals: float = Field(description="Residual degrees of freedom.")
summary: str = Field(description="Full statsmodels text summary.")
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Perform Ordinary Least Squares (OLS) regression.",
code=[
"stock_data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').to_df()", # noqa: E501
'obb.econometrics.ols_regression(data=stock_data, y_column="close", x_columns=["open", "high", "low"])', # noqa: E501
],
),
APIEx(
parameters={
"y_column": "close",
"x_columns": ["open", "high", "low"],
"data": APIEx.mock_data("timeseries"),
}
),
],
)
def ols_regression(
params: OlsRegressionQueryParams,
) -> OBBject[list[OlsRegressionData]]:
"""Perform Ordinary Least Squares (OLS) regression.
OLS fits the best linear relationship between a dependent variable and one or more
independent variables. Returns the estimated coefficient table - one row per
regressor, with standard errors, t-statistics, p-values, and confidence intervals.
"""
import statsmodels.api as sm
from openbb_core.app.utils import (
basemodel_to_df,
get_target_column,
get_target_columns,
)
df = basemodel_to_df(params.data)
exog = sm.add_constant(get_target_columns(df, params.x_columns))
endog = get_target_column(df, params.y_column)
results = sm.OLS(endog, exog).fit(cov_type=params.cov_type)
conf_int = results.conf_int()
out = []
for variable in results.params.index:
bounds = conf_int.loc[variable]
out.append(
OlsRegressionData(
variable=str(variable),
coefficient=float(results.params[variable]),
standard_error=float(results.bse[variable]),
t_statistic=float(results.tvalues[variable]),
p_value=float(results.pvalues[variable]),
conf_int_lower=float(bounds.iloc[0]),
conf_int_upper=float(bounds.iloc[1]),
)
)
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"y_column": "close",
"x_columns": ["open", "high", "low"],
"data": APIEx.mock_data("timeseries"),
}
),
],
)
def ols_regression_summary(
params: OlsRegressionSummaryQueryParams,
) -> OBBject[OlsRegressionSummaryData]:
"""Perform Ordinary Least Squares (OLS) regression and return the fit summary.
Returns the model-level goodness-of-fit statistics - R-squared, F-statistic,
information criteria, log-likelihood, and degrees of freedom - along with the full
statsmodels text summary.
"""
import statsmodels.api as sm
from openbb_core.app.utils import (
basemodel_to_df,
get_target_column,
get_target_columns,
)
df = basemodel_to_df(params.data)
exog = sm.add_constant(get_target_columns(df, params.x_columns))
endog = get_target_column(df, params.y_column)
try:
exog = exog.astype(float)
endog = endog.astype(float)
except ValueError as exc:
raise ValueError("All columns must be numeric.") from exc
results = sm.OLS(endog, exog).fit(cov_type=params.cov_type)
return OBBject(
results=OlsRegressionSummaryData(
r_squared=float(results.rsquared),
adjusted_r_squared=float(results.rsquared_adj),
f_statistic=(float(results.fvalue) if results.fvalue is not None else None),
f_p_value=(
float(results.f_pvalue) if results.f_pvalue is not None else None
),
aic=float(results.aic),
bic=float(results.bic),
log_likelihood=float(results.llf),
nobs=int(results.nobs),
df_model=float(results.df_model),
df_residuals=float(results.df_resid),
summary=str(results.summary()),
)
)

View File

@@ -0,0 +1,18 @@
"""Top-level router for the openbb-econometrics extension."""
from openbb_core.app.router import Router
from openbb_econometrics.diagnostics import router as _diagnostics_router
from openbb_econometrics.panel import router as _panel_router
from openbb_econometrics.regression import router as _regression_router
from openbb_econometrics.time_series import router as _time_series_router
from openbb_econometrics.tools import router as _tools_router
router = Router(prefix="", description="Econometrics analysis tools.")
router.include_router(_tools_router)
router.include_router(_regression_router)
router.include_router(_diagnostics_router)
router.include_router(_time_series_router)
router.include_router(_panel_router)
__all__ = ["router"]

View File

@@ -0,0 +1,537 @@
"""Econometrics time-series commands - stationarity, cointegration, causality, GARCH."""
from datetime import (
date as dateType,
datetime,
)
from itertools import combinations
from typing import Literal
from openbb_core.app.model.example import APIEx
from openbb_core.app.model.obbject import OBBject
from openbb_core.app.router import Router
from openbb_core.provider.abstract.data import Data
from openbb_core.provider.abstract.query_params import QueryParams
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt
router = Router(prefix="", description="Econometrics time-series commands.")
class UnitRootQueryParams(QueryParams):
"""Query parameters for the Augmented Dickey-Fuller unit root endpoint."""
__category__ = "time_series"
__output_columns__ = ("adf_stat", "p_value", "used_lag", "nobs", "ic_best")
data: list[Data] = Field(description="Input dataset.")
column: str = Field(description="Column to test for a unit root.")
regression: Literal["c", "ct", "ctt"] = Field(
default="c",
description="Deterministic terms: 'c' constant, 'ct' constant and trend,"
+ " 'ctt' constant, trend, and quadratic trend.",
)
maxlag: NonNegativeInt | None = Field(
default=None,
description="Maximum lag to include. If None, a data-dependent default is used.",
)
autolag: Literal["AIC", "BIC", "t-stat"] | None = Field(
default="AIC",
description="Lag-selection method: 'AIC', 'BIC', or 't-stat'; None uses"
+ " maxlag directly without selection.",
)
class UnitRootData(Data):
"""Augmented Dickey-Fuller unit root test result."""
adf_stat: float = Field(
description="Test statistic (more negative favours stationarity)."
)
p_value: float = Field(description="MacKinnon p-value.")
used_lag: int = Field(description="Number of lags used.")
nobs: int = Field(description="Number of observations used.")
ic_best: float | None = Field(
default=None,
description="Maximised information criterion (None when autolag is disabled).",
)
critical_values: dict = Field(
description="Critical values at the 1%, 5%, and 10% levels."
)
class KpssQueryParams(QueryParams):
"""Query parameters for the KPSS stationarity endpoint."""
__category__ = "time_series"
__output_columns__ = ("kpss_stat", "p_value", "p_value_interpolated", "lags")
data: list[Data] = Field(description="Input dataset.")
column: str = Field(description="Column to test.")
regression: Literal["c", "ct"] = Field(
default="c",
description="Null hypothesis: 'c' level-stationary, 'ct' trend-stationary.",
)
nlags: Literal["auto", "legacy"] | NonNegativeInt = Field(
default="auto",
description="Lags for the variance estimator: 'auto', 'legacy', or an"
+ " explicit non-negative integer.",
)
class KpssData(Data):
"""KPSS stationarity test result."""
kpss_stat: float = Field(description="Test statistic.")
p_value: float = Field(description="p-value of the test statistic.")
p_value_interpolated: bool = Field(
description="False when p_value is a clamped table boundary"
+ " (the true p-value is more extreme than reported)."
)
lags: int = Field(description="Number of lags used in the test.")
critical_values: dict = Field(
description="Critical values at the 10%, 5%, 2.5%, and 1% levels."
)
class CointegrationQueryParams(QueryParams):
"""Query parameters for the Engle-Granger cointegration endpoint."""
__category__ = "time_series"
__output_columns__ = ("pair", "c", "gamma", "alpha", "adf_stat", "p_value")
data: list[Data] = Field(description="Input dataset.")
columns: list[str] = Field(
description="Columns to test pairwise for cointegration."
)
class CointegrationData(Data):
"""Engle-Granger two-step cointegration result for one column pair."""
pair: str = Field(description="The tested column pair, formatted as 'x/y'.")
c: float = Field(description="Constant of the long-run relationship.")
gamma: float = Field(description="Slope of the long-run relationship.")
alpha: float = Field(description="Error-correction speed of adjustment.")
adf_stat: float = Field(description="Dickey-Fuller statistic on the residuals.")
p_value: float = Field(description="p-value of the Dickey-Fuller statistic.")
class CointegrationJohansenQueryParams(QueryParams):
"""Query parameters for the Johansen cointegration endpoint."""
__category__ = "time_series"
__output_columns__ = (
"rank",
"eigenvalue",
"trace_statistic",
"max_eig_statistic",
)
data: list[Data] = Field(description="Input dataset.")
columns: list[str] = Field(
description="Columns (time series) to test jointly for cointegration."
)
deterministic_order: Literal[-1, 0, 1] = Field(
default=0,
description="Deterministic trend: -1 none, 0 constant, 1 constant and trend.",
)
k_ar_diff: PositiveInt = Field(
default=1, description="Number of lagged differences in the model."
)
class CointegrationJohansenData(Data):
"""Johansen cointegration result for one cointegration rank hypothesis."""
rank: int = Field(description="Cointegration rank hypothesis (r <= rank).")
eigenvalue: float = Field(description="Eigenvalue associated with the rank.")
trace_statistic: float = Field(description="Trace test statistic.")
trace_crit_90: float = Field(description="Trace critical value at the 90% level.")
trace_crit_95: float = Field(description="Trace critical value at the 95% level.")
trace_crit_99: float = Field(description="Trace critical value at the 99% level.")
max_eig_statistic: float = Field(description="Maximum-eigenvalue test statistic.")
max_eig_crit_90: float = Field(
description="Maximum-eigenvalue critical value at the 90% level."
)
max_eig_crit_95: float = Field(
description="Maximum-eigenvalue critical value at the 95% level."
)
max_eig_crit_99: float = Field(
description="Maximum-eigenvalue critical value at the 99% level."
)
class CausalityQueryParams(QueryParams):
"""Query parameters for the Granger causality endpoint."""
__category__ = "time_series"
__output_columns__ = ("test", "statistic", "p_value", "lag")
data: list[Data] = Field(description="Input dataset.")
y_column: str = Field(description="Target column.")
x_column: str = Field(description="Candidate causal column.")
lag: PositiveInt = Field(default=3, description="Number of lags to test.")
class CausalityData(Data):
"""One sub-test of the Granger causality result."""
test: str = Field(description="Name of the Granger sub-test.")
statistic: float = Field(description="Test statistic.")
p_value: float = Field(description="p-value of the test statistic.")
lag: int = Field(description="Number of lags tested.")
class GarchQueryParams(QueryParams):
"""Query parameters for the GARCH volatility endpoint."""
__category__ = "time_series"
__output_columns__ = ("date", "conditional_volatility")
data: list[Data] = Field(description="Input dataset.")
column: str = Field(description="Column to model. Best applied to a return series.")
x_columns: list[str] | None = Field(
default=None,
description="Exogenous regressor columns for the mean model"
+ " (used by the 'LS', 'ARX', and 'HARX' mean models).",
)
mean: Literal["Constant", "Zero", "LS", "AR", "ARX", "HAR", "HARX"] = Field(
default="Constant",
description="Mean model: 'Constant' (constant mean), 'Zero' (zero mean),"
+ " 'LS' (least squares), 'AR'/'ARX' (autoregressive, 'ARX' adds exogenous"
+ " regressors), 'HAR'/'HARX' (heterogeneous autoregressive).",
)
lags: NonNegativeInt = Field(
default=0,
description="Lag order of the 'AR', 'ARX', 'HAR', and 'HARX' mean models.",
)
vol: Literal["GARCH", "ARCH", "EGARCH", "FIGARCH", "APARCH", "HARCH"] = Field(
default="GARCH",
description="Volatility process: 'GARCH', 'ARCH', 'EGARCH' (exponential GARCH,"
+ " captures leverage), 'FIGARCH' (fractionally integrated, long memory),"
+ " 'APARCH' (asymmetric power ARCH), 'HARCH' (heterogeneous ARCH).",
)
p: PositiveInt = Field(
default=1, description="Order of the symmetric ARCH (innovation) term."
)
o: NonNegativeInt = Field(
default=0, description="Order of the asymmetric (leverage) term."
)
q: NonNegativeInt = Field(
default=1, description="Order of the lagged volatility (GARCH) term."
)
power: PositiveFloat = Field(
default=2.0,
description="Power applied to the conditional volatility (2.0 is standard"
+ " GARCH; other values give power-GARCH and APARCH variants).",
)
distribution: Literal["normal", "t", "skewt", "ged"] = Field(
default="normal",
description="Distribution of the model innovations: 'normal' (Gaussian),"
+ " 't' (Student's t, fat tails), 'skewt' (skewed Student's t),"
+ " or 'ged' (generalized error distribution).",
)
class GarchData(Data):
"""One observation of the GARCH conditional volatility series."""
date: datetime | dateType | str | int = Field(
description="Observation date (integer position when the input has no date)."
)
conditional_volatility: float = Field(
description="Estimated conditional volatility at the observation."
)
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"column": "close", "data": APIEx.mock_data("timeseries")})
],
)
def unit_root(params: UnitRootQueryParams) -> OBBject[UnitRootData]:
"""Perform the Augmented Dickey-Fuller (ADF) unit root test.
The ADF test checks whether a series has a unit root - a sign of non-stationarity.
Rejecting the null hypothesis indicates the series is stationary.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from statsmodels.tsa.stattools import adfuller
series = get_target_column(basemodel_to_df(params.data), params.column)
result = adfuller(
series,
maxlag=params.maxlag,
regression=params.regression,
autolag=params.autolag,
)
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),
)
)
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"column": "close", "data": APIEx.mock_data("timeseries")})
],
)
def kpss(params: KpssQueryParams) -> OBBject[KpssData]:
"""Perform the KPSS test for stationarity.
The KPSS null hypothesis is that the series IS stationary - the opposite of the ADF
test - so running both tests gives a more robust read on stationarity.
"""
import warnings
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
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
)
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),
p_value_interpolated=p_value_interpolated,
lags=int(lags),
critical_values=dict(critical_values),
)
)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"columns": ["open", "close"],
"data": APIEx.mock_data("timeseries"),
}
)
],
)
def cointegration(params: CointegrationQueryParams) -> OBBject[list[CointegrationData]]:
"""Test pairs of time series for cointegration via the Engle-Granger two-step test.
Cointegration means two non-stationary series share a long-run equilibrium. The
Engle-Granger test regresses one series on the other and checks the residuals for
stationarity.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_columns
from openbb_econometrics.utils import (
get_engle_granger_two_step_cointegration_test,
)
dataset = get_target_columns(basemodel_to_df(params.data), params.columns)
out = []
for x_col, y_col in combinations(params.columns, 2):
c, gamma, alpha, _, adf_stat, p_value = (
get_engle_granger_two_step_cointegration_test(
dataset[x_col], dataset[y_col]
)
)
out.append(
CointegrationData(
pair=f"{x_col}/{y_col}",
c=float(c),
gamma=float(gamma),
alpha=float(alpha),
adf_stat=float(adf_stat),
p_value=float(p_value),
)
)
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"columns": ["open", "close"],
"data": APIEx.mock_data("timeseries"),
}
)
],
)
def cointegration_johansen(
params: CointegrationJohansenQueryParams,
) -> OBBject[list[CointegrationJohansenData]]:
"""Perform the Johansen cointegration test on multiple time series.
The Johansen test detects cointegrating relationships among two or more series
simultaneously, reporting the number of cointegrating vectors via the trace and
maximum-eigenvalue statistics.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_columns
from statsmodels.tsa.vector_ar.vecm import coint_johansen
dataset = get_target_columns(basemodel_to_df(params.data), params.columns)
result = coint_johansen(
dataset, det_order=params.deterministic_order, k_ar_diff=params.k_ar_diff
)
out = [
CointegrationJohansenData(
rank=rank,
eigenvalue=float(result.eig[rank]),
trace_statistic=float(result.lr1[rank]),
trace_crit_90=float(result.cvt[rank][0]),
trace_crit_95=float(result.cvt[rank][1]),
trace_crit_99=float(result.cvt[rank][2]),
max_eig_statistic=float(result.lr2[rank]),
max_eig_crit_90=float(result.cvm[rank][0]),
max_eig_crit_95=float(result.cvm[rank][1]),
max_eig_crit_99=float(result.cvm[rank][2]),
)
for rank in range(len(result.lr1))
]
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(
description="Granger causality test with mock data.",
parameters={
"y_column": "close",
"x_column": "open",
"lag": 1,
"data": APIEx.mock_data("timeseries"),
},
)
],
)
def causality(params: CausalityQueryParams) -> OBBject[list[CausalityData]]:
"""Perform the Granger causality test to assess whether X helps predict y.
Granger causality tests whether past values of one series improve the forecast of
another. It is a predictive, not a structural, notion of causality.
"""
from contextlib import redirect_stdout
from io import StringIO
from openbb_core.app.utils import basemodel_to_df, get_target_column
from pandas import concat
from statsmodels.tsa.stattools import grangercausalitytests
df = basemodel_to_df(params.data)
x_series = get_target_column(df, params.x_column)
y_series = get_target_column(df, params.y_column)
with redirect_stdout(StringIO()):
granger = grangercausalitytests(
concat([y_series, x_series], axis=1), [params.lag]
)
out = [
CausalityData(
test=str(test),
statistic=float(values[0]),
p_value=float(values[1]),
lag=params.lag,
)
for test, values in granger[params.lag][0].items()
]
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"column": "close", "data": APIEx.mock_data("timeseries")})
],
)
def garch(params: GarchQueryParams) -> OBBject[list[GarchData]]:
"""Fit a configurable volatility model from the GARCH family to a series.
The mean model, volatility process, lag orders, power, and innovation distribution
are all configurable. The estimated conditional volatility series is returned; the
fitted parameters and information criteria are attached to ``OBBject.extra``. Best
applied to a return series.
"""
from arch import arch_model
from openbb_core.app.utils import (
basemodel_to_df,
get_target_column,
get_target_columns,
)
from pandas import DataFrame, Series
df = basemodel_to_df(params.data, index="date")
series = get_target_column(df, params.column).astype(float)
exog = None
if params.x_columns:
combined = (
get_target_columns(df, params.x_columns)
.astype(float)
.join(series.rename("_endog_"))
.dropna()
)
series = combined["_endog_"]
exog = combined.drop(columns="_endog_")
else:
series = series.dropna()
results = arch_model(
series,
x=exog,
mean=params.mean,
lags=params.lags,
vol=params.vol,
p=params.p,
o=params.o,
q=params.q,
power=params.power,
dist=params.distribution,
).fit(disp="off")
volatility = Series(results.conditional_volatility).dropna()
frame = DataFrame(
{
"date": volatility.index,
"conditional_volatility": volatility.to_numpy(),
}
)
out = [
GarchData(
date=record["date"],
conditional_volatility=float(record["conditional_volatility"]),
)
for record in frame.to_dict(orient="records")
]
return OBBject(
results=out,
extra={
"results_metadata": {
"params": results.params.to_dict(),
"aic": float(results.aic),
"bic": float(results.bic),
"log_likelihood": float(results.loglikelihood),
}
},
)

View File

@@ -0,0 +1,157 @@
"""Econometrics data tools - correlation matrix and descriptive statistics."""
from typing import Literal
from openbb_core.app.model.example import APIEx, PythonEx
from openbb_core.app.model.obbject import OBBject
from openbb_core.app.router import Router
from openbb_core.provider.abstract.data import Data
from openbb_core.provider.abstract.query_params import QueryParams
from pydantic import Field, PositiveInt
router = Router(
prefix="", description="Econometrics correlation and descriptive tools."
)
class CorrelationMatrixQueryParams(QueryParams):
"""Query parameters for the correlation matrix endpoint."""
__category__ = "tools"
__output_columns__ = ("comp_to",)
data: list[Data] = Field(description="Input dataset.")
method: Literal["pearson", "kendall", "spearman"] = Field(
default="pearson",
description="Correlation method: 'pearson' (standard), 'kendall' (Kendall Tau),"
+ " or 'spearman' (rank correlation).",
)
min_periods: PositiveInt = Field(
default=1,
description="Minimum number of observations required per pair of columns"
+ " to compute a correlation.",
)
class CorrelationMatrixData(Data):
"""One row of the correlation matrix."""
comp_to: str = Field(
description="The variable each correlation in the row is measured against."
)
class SummaryStatisticsQueryParams(QueryParams):
"""Query parameters for the summary statistics endpoint."""
__category__ = "tools"
__output_columns__ = (
"column",
"count",
"mean",
"std",
"min",
"p25",
"median",
"p75",
"max",
"skew",
"kurtosis",
)
data: list[Data] = Field(description="Input dataset.")
class SummaryStatisticsData(Data):
"""Descriptive statistics for a single numeric column."""
column: str = Field(description="Name of the column.")
count: int = Field(description="Number of non-null observations.")
mean: float = Field(description="Arithmetic mean.")
std: float = Field(description="Sample standard deviation.")
min: float = Field(description="Minimum value.")
p25: float = Field(description="25th percentile.")
median: float = Field(description="Median (50th percentile).")
p75: float = Field(description="75th percentile.")
max: float = Field(description="Maximum value.")
skew: float = Field(description="Skewness of the distribution.")
kurtosis: float = Field(description="Excess kurtosis of the distribution.")
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get the correlation matrix of a dataset.",
code=[
"stock_data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').to_df()", # noqa: E501
"obb.econometrics.correlation_matrix(data=stock_data)",
],
),
APIEx(parameters={"data": APIEx.mock_data("timeseries")}),
],
)
def correlation_matrix(
params: CorrelationMatrixQueryParams,
) -> OBBject[list[CorrelationMatrixData]]:
"""Get the correlation matrix of an input dataset.
The correlation matrix shows how strongly the variables in the dataset move in
relation to one another. Scores range from -1 (perfect negative correlation) through
0 (no correlation) to 1 (perfect positive correlation).
"""
import numpy as np
from openbb_core.app.utils import basemodel_to_df
df = basemodel_to_df(params.data)
if "symbol" in df.columns and len(df.symbol.unique()) > 1 and "close" in df.columns:
df = df.pivot(columns="symbol", values="close")
corr = df.corr(
method=params.method, min_periods=params.min_periods, numeric_only=True
).replace(np.nan, None)
results = []
for key, series in corr.items():
row = series.to_dict()
row["comp_to"] = str(key)
results.append(CorrelationMatrixData.model_validate(row))
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[APIEx(parameters={"data": APIEx.mock_data("timeseries")})],
)
def summary_statistics(
params: SummaryStatisticsQueryParams,
) -> OBBject[list[SummaryStatisticsData]]:
"""Compute descriptive summary statistics for each numeric column.
Returns per-column count, mean, standard deviation, minimum, quartiles, maximum,
skewness, and excess kurtosis - a quick overview of each variable's distribution.
"""
from openbb_core.app.utils import basemodel_to_df
df = basemodel_to_df(params.data).select_dtypes(include="number")
results = [
SummaryStatisticsData(
column=str(column),
count=int(df[column].count()),
mean=float(df[column].mean()),
std=float(df[column].std()),
min=float(df[column].min()),
p25=float(df[column].quantile(0.25)),
median=float(df[column].median()),
p75=float(df[column].quantile(0.75)),
max=float(df[column].max()),
skew=float(df[column].skew()),
kurtosis=float(df[column].kurtosis()),
)
for column in df.columns
]
return OBBject(results=results)

View File

@@ -10,60 +10,7 @@ if TYPE_CHECKING:
def get_engle_granger_two_step_cointegration_test(
dependent_series: "Series", independent_series: "Series"
) -> tuple[float, float, float, "Series", float, float]:
"""Estimate long-run and short-run cointegration relationship for series y and x.
Then apply the two-step Engle & Granger test for cointegration.
Uses a 2-step process to first estimate coefficients for the long-run relationship
y_t = c + gamma * x_t + z_t
and then the short-term relationship,
y_t - y_(t-1) = alpha * z_(t-1) + epsilon_t,
with z the found residuals of the first equation.
Then tests cointegration by Dickey-Fuller phi=1 vs phi < 1 in
z_t = phi * z_(t-1) + eta_t
If this implies phi < 1, the z series is stationary is concluded to be
stationary, and thus the series y and x are concluded to be cointegrated.
Parameters
----------
dependent_series : pd.Series
The first time series of the pair to analyse.
independent_series : pd.Series
The second time series of the pair to analyse.
Returns
-------
Tuple[float, float, float, pd.Series, float, float]
c : float
The constant term in the long-run relationship y_t = c + gamma * x_t + z_t. This
describes the static shift of y with respect to gamma * x.
gamma : float
The gamma term in the long-run relationship y_t = c + gamma * x_t + z_t. This
describes the ratio between the const-shifted y and x.
alpha : float
The alpha term in the short-run relationship y_t - y_(t-1) = alpha * z_(t-1) + epsilon. This
gives an indication of the strength of the error correction toward the long-run mean.
z : pd.Series
Series of residuals z_t from the long-run relationship y_t = c + gamma * x_t + z_t, representing
the value of the error correction term.
dfstat : float
The Dickey Fuller test-statistic for phi = 1 vs phi < 1 in the second equation. A more
negative value implies the existence of stronger cointegration.
pvalue : float
The p-value corresponding to the Dickey Fuller test-statistic. A lower value implies
stronger rejection of no-cointegration, thus stronger evidence of cointegration.
"""
# pylint: disable=import-outside-toplevel
"""Run the Engle-Granger two-step cointegration test on a series pair."""
import statsmodels.api as sm
from statsmodels.tsa.stattools import adfuller
@@ -81,11 +28,7 @@ def get_engle_granger_two_step_cointegration_test(
alpha = short_run_ols_fit.params.iloc[0]
# NOTE: The p-value returned by the adfuller function assumes we do not estimate z
# first, but test stationarity of an unestimated series directly. This assumption
# should have limited effect for high N, however. Critical values taking this into
# account more accurately are provided in e.g. McKinnon (1990) and Engle & Yoo (1987).
# adfuller's p-value assumes z is not pre-estimated; limited effect for high N.
adfstat, pvalue, _, _, _ = adfuller(z, maxlag=1, autolag=None)
return c, gamma, alpha, z, adfstat, pvalue
@@ -93,7 +36,6 @@ def get_engle_granger_two_step_cointegration_test(
def mock_multi_index_data():
"""Create a mock multi-index dataframe for testing purposes."""
# pylint: disable=import-outside-toplevel
from numpy import random
from pandas import DataFrame, MultiIndex

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,97 @@
[tool.poetry]
[project]
name = "openbb-econometrics"
version = "1.7.2"
version = "2.0.0"
description = "Econometrics Toolkit for OpenBB"
authors = ["OpenBB Team <hello@openbb.co>"]
license = "AGPL-3.0-only"
authors = [{ name = "OpenBB Team", email = "hello@openbb.co" }]
license = { text = "AGPL-3.0-only" }
readme = "README.md"
packages = [{ include = "openbb_econometrics" }]
requires-python = ">=3.10,<4"
dependencies = [
"openbb-core[pandas]>=2.0.0",
"statsmodels>=0.14",
"linearmodels>=6",
"scipy>=1.11",
"arch>=7.2",
]
[tool.poetry.dependencies]
python = ">=3.10,<4" # scipy forces python <4.0 explicitly
openbb-core = "^1.6.10"
pandas-ta-openbb = "^0.4.23"
arch = "^7.2"
linearmodels = "^6"
[project.entry-points."openbb_core_extension"]
econometrics = "openbb_econometrics.router:router"
[project.entry-points."openbb_charting_extension"]
econometrics = "openbb_econometrics.econometrics_views:EconometricsViews"
[project.urls]
Homepage = "https://openbb.co"
Repository = "https://github.com/OpenBB-finance/OpenBB"
[dependency-groups]
dev = ["openbb-devtools>=2.0.0"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.poetry.plugins."openbb_core_extension"]
econometrics = "openbb_econometrics.econometrics_router:router"
[tool.hatch.build.targets.wheel]
packages = ["openbb_econometrics"]
[tool.poetry.plugins."openbb_charting_extension"]
econometrics = "openbb_econometrics.econometrics_views:EconometricsViews"
[tool.hatch.build.targets.sdist]
include = ["openbb_econometrics", "README.md", "pyproject.toml"]
[tool.uv.sources]
openbb-core = { path = "../../core", editable = true }
openbb-devtools = { path = "../devtools", editable = true }
[tool.ruff]
line-length = 88
target-version = "py310"
fix = true
[tool.ruff.lint]
select = ["D", "E", "W", "F", "S", "UP", "I", "PLC", "PLE", "PLR", "PLW", "SIM", "T20"]
ignore = [
"PLR2004", "PLR0913", "PLR0915", "PLC0415",
"E402", "E501",
"D101", "D102", "D103", "D105", "D107",
"D202", "D203", "D205", "D206", "D210", "D212", "D213", "D214",
"D215", "D300", "D400", "D401", "D404", "D405", "D406", "D407",
"D408", "D409", "D410", "D411", "D413", "D415", "D416", "D417",
]
[tool.ruff.lint.per-file-ignores]
"openbb_econometrics/*.py" = ["PLR0911", "PLR0912", "PLR0913", "PLR0914", "PLR0915", "PLR0917", "ARG001", "ARG002", "SLF001"]
"docs/**" = ["T201"]
"tests/**" = ["S101", "S104", "S105", "S106", "S108", "D", "F841", "PLR0911", "PLW0108", "SIM105", "SIM117", "E741"]
"integration/**" = ["S101", "S104", "S105", "S106", "S108", "D", "PLC0415", "PLR0911", "PLR0913", "PLR0914", "PLR0915", "PLR0917"]
"**/__init__.py" = ["F401"]
[tool.ruff.lint.isort]
combine-as-imports = true
force-wrap-aliases = true
[tool.ruff.lint.pydocstyle]
convention = "numpy"
[tool.ty]
[tool.ty.environment]
python-version = "3.10"
[tool.ty.src]
include = ["openbb_econometrics"]
exclude = ["tests", "integration"]
[tool.ty.rules]
unresolved-import = "warn"
[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@overload"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
addopts = "-q --strict-markers"
testpaths = ["tests"]
markers = [
"integration: end-to-end tests that hit live services (excluded from the coverage gate)",
]
filterwarnings = [
"ignore:Inheritance class ClientSession from ClientSession is discouraged:DeprecationWarning",
]

View File

@@ -0,0 +1,94 @@
"""Shared fixtures for the econometrics test suite."""
import numpy as np
import pandas as pd
import pytest
from openbb_core.app.utils import df_to_basemodel
from openbb_econometrics.utils import mock_multi_index_data
SEED = 42
N_ROWS = 120
@pytest.fixture(scope="session")
def timeseries_df() -> pd.DataFrame:
"""Build a smooth, slightly-noisy OHLCV timeseries DataFrame."""
rng = np.random.default_rng(SEED)
t = np.arange(N_ROWS)
base = 100 + 0.5 * t + rng.normal(0, 2, N_ROWS).cumsum() * 0.3
close = base + rng.normal(0, 1, N_ROWS)
return pd.DataFrame(
{
"open": close + rng.normal(0, 0.5, N_ROWS),
"high": close + np.abs(rng.normal(1, 0.5, N_ROWS)),
"low": close - np.abs(rng.normal(1, 0.5, N_ROWS)),
"close": close,
"volume": (1e6 + rng.normal(0, 1e5, N_ROWS) + 5000 * t).astype(float),
}
)
@pytest.fixture(scope="session")
def timeseries_data(timeseries_df) -> list:
"""Expose the timeseries DataFrame as a ``list[Data]``."""
return df_to_basemodel(timeseries_df)
@pytest.fixture(scope="session")
def symbol_df() -> pd.DataFrame:
"""Build a long-format DataFrame with a ``symbol`` column and >1 symbol."""
rng = np.random.default_rng(SEED)
rows = 30
frames = []
for sym in ("AAA", "BBB", "CCC"):
close = 100 + rng.normal(0, 1, rows).cumsum()
frames.append(pd.DataFrame({"symbol": sym, "close": close}))
return pd.concat(frames, ignore_index=True)
@pytest.fixture(scope="session")
def symbol_data(symbol_df) -> list:
"""Expose the multi-symbol DataFrame as a ``list[Data]``."""
return df_to_basemodel(symbol_df)
@pytest.fixture(scope="session")
def panel_df() -> pd.DataFrame:
"""Build a panel (entity/time MultiIndex) DataFrame."""
np.random.seed(SEED)
return mock_multi_index_data()
@pytest.fixture(scope="session")
def panel_data(panel_df) -> list:
"""Expose the panel DataFrame as a ``list[Data]`` (MultiIndex preserved)."""
return df_to_basemodel(panel_df, index=True)
@pytest.fixture(scope="session")
def non_numeric_data() -> list:
"""A dataset whose ``open`` column is non-numeric."""
rng = np.random.default_rng(SEED)
rows = 20
df = pd.DataFrame(
{
"open": [f"label_{i}" for i in range(rows)],
"high": rng.normal(10, 1, rows),
"close": rng.normal(10, 1, rows),
}
)
return df_to_basemodel(df)
@pytest.fixture(scope="session")
def small_panel_data() -> list:
"""A panel dataset with only 2 rows."""
df = pd.DataFrame(
{"portfolio_value": [10.0, 12.0], "risk_free_rate": [0.01, 0.02]},
index=pd.MultiIndex.from_tuples(
[("entity_1", 1), ("entity_1", 2)], names=("entity", "time")
),
)
return df_to_basemodel(df, index=True)

View File

@@ -0,0 +1,116 @@
"""Tests for ``openbb_econometrics.diagnostics`` - regression diagnostics."""
from openbb_econometrics import diagnostics
X_COLUMNS = ["open", "high", "low"]
def test_autocorrelation(timeseries_data):
"""The Durbin-Watson statistic lies in the [0, 4] range."""
out = diagnostics.autocorrelation(
diagnostics.AutocorrelationQueryParams(
data=timeseries_data, y_column="close", x_columns=X_COLUMNS
)
)
result = out.results
dumped = result.model_dump()
assert 0.0 <= dumped["durbin_watson"] <= 4.0
def test_residual_autocorrelation(timeseries_data):
"""The Breusch-Godfrey test returns LM and F statistics with p-values."""
out = diagnostics.residual_autocorrelation(
diagnostics.ResidualAutocorrelationQueryParams(
data=timeseries_data,
y_column="close",
x_columns=X_COLUMNS,
lags=2,
)
)
dumped = out.results.model_dump()
assert 0.0 <= dumped["lm_p_value"] <= 1.0
assert 0.0 <= dumped["f_p_value"] <= 1.0
assert isinstance(dumped["lm_statistic"], float)
assert isinstance(dumped["f_statistic"], float)
def test_residual_autocorrelation_default_lags(timeseries_data):
"""The ``lags`` parameter defaults to 1."""
params = diagnostics.ResidualAutocorrelationQueryParams(
data=timeseries_data, y_column="close", x_columns=X_COLUMNS
)
assert params.lags == 1
out = diagnostics.residual_autocorrelation(params)
assert out.results.lm_statistic is not None
def test_heteroskedasticity(timeseries_data):
"""The heteroskedasticity command returns Breusch-Pagan and White results."""
params = diagnostics.HeteroskedasticityQueryParams(
data=timeseries_data, y_column="close", x_columns=X_COLUMNS
)
assert params.robust is True
out = diagnostics.heteroskedasticity(params)
dumped = out.results.model_dump()
for prefix in ("breusch_pagan", "white"):
assert 0.0 <= dumped[f"{prefix}_lm_p_value"] <= 1.0
assert 0.0 <= dumped[f"{prefix}_f_p_value"] <= 1.0
assert isinstance(dumped[f"{prefix}_lm_statistic"], float)
assert isinstance(dumped[f"{prefix}_f_statistic"], float)
def test_heteroskedasticity_non_robust(timeseries_data):
"""The heteroskedasticity command accepts the non-robust Breusch-Pagan variant."""
out = diagnostics.heteroskedasticity(
diagnostics.HeteroskedasticityQueryParams(
data=timeseries_data,
y_column="close",
x_columns=X_COLUMNS,
robust=False,
)
)
dumped = out.results.model_dump()
assert 0.0 <= dumped["breusch_pagan_lm_p_value"] <= 1.0
def test_normality(timeseries_data):
"""The Jarque-Bera normality test returns a statistic and p-value."""
out = diagnostics.normality(
diagnostics.NormalityQueryParams(
data=timeseries_data, y_column="close", x_columns=X_COLUMNS
)
)
dumped = out.results.model_dump()
assert dumped["jarque_bera"] >= 0.0
assert 0.0 <= dumped["p_value"] <= 1.0
assert isinstance(dumped["skew"], float)
assert isinstance(dumped["kurtosis"], float)
def test_variance_inflation_factor_with_columns(timeseries_data):
"""VIF with an explicit ``columns`` list returns one row per requested column."""
out = diagnostics.variance_inflation_factor(
diagnostics.VarianceInflationFactorQueryParams(
data=timeseries_data, columns=X_COLUMNS
)
)
results = out.results
assert {row.variable for row in results} == set(X_COLUMNS)
for row in results:
assert row.model_dump()["vif"] > 0.0
def test_variance_inflation_factor_without_columns(timeseries_data):
"""Without ``columns`` VIF uses every numeric column in the dataset."""
params = diagnostics.VarianceInflationFactorQueryParams(data=timeseries_data)
assert params.columns is None
out = diagnostics.variance_inflation_factor(params)
# open/high/low/close/volume.
assert len(out.results) == 5
assert {row.variable for row in out.results} == {
"open",
"high",
"low",
"close",
"volume",
}

View File

@@ -2,6 +2,7 @@
import numpy as np
import pandas as pd
from openbb_econometrics.utils import (
get_engle_granger_two_step_cointegration_test,
mock_multi_index_data,

View File

@@ -0,0 +1,55 @@
"""Tests for ``openbb_econometrics.econometrics_views`` - the charting views.
``openbb_charting`` is an optional dependency that is not installed in the test
environment. ``EconometricsViews`` imports ``openbb_charting.charts`` lazily, so
a fake package is injected into ``sys.modules`` to exercise the wrapper.
"""
import sys
import types
import pytest
from openbb_econometrics.econometrics_views import EconometricsViews
@pytest.fixture
def fake_charting(monkeypatch):
"""Inject a fake ``openbb_charting.charts.correlation_matrix`` package tree.
The fake ``correlation_matrix`` records the keyword arguments it received and
returns a ``(figure, content)`` tuple, mirroring the real charting API.
"""
calls: dict = {}
def _correlation_matrix(**kwargs):
calls["kwargs"] = kwargs
return ("FAKE_FIGURE", {"chart": "correlation_matrix"})
charting = types.ModuleType("openbb_charting")
charts = types.ModuleType("openbb_charting.charts")
corr_mod = types.ModuleType("openbb_charting.charts.correlation_matrix")
corr_mod.correlation_matrix = _correlation_matrix
charts.correlation_matrix = corr_mod
charting.charts = charts
monkeypatch.setitem(sys.modules, "openbb_charting", charting)
monkeypatch.setitem(sys.modules, "openbb_charting.charts", charts)
monkeypatch.setitem(
sys.modules, "openbb_charting.charts.correlation_matrix", corr_mod
)
return calls
def test_econometrics_correlation_matrix(fake_charting):
"""The view delegates to ``openbb_charting`` and returns its tuple."""
figure, content = EconometricsViews.econometrics_correlation_matrix(
data=[{"a": 1.0, "b": 2.0}], method="pearson"
)
assert figure == "FAKE_FIGURE"
assert content == {"chart": "correlation_matrix"}
# The kwargs are forwarded unchanged.
assert fake_charting["kwargs"] == {
"data": [{"a": 1.0, "b": 2.0}],
"method": "pearson",
}

View File

@@ -0,0 +1,103 @@
"""Tests for ``openbb_econometrics.panel`` - panel-data regression commands."""
import pytest
from openbb_econometrics import panel
PANEL_COMMANDS = [
"panel_random_effects",
"panel_between",
"panel_pooled",
"panel_first_difference",
"panel_fmac",
]
@pytest.mark.parametrize("command_name", PANEL_COMMANDS)
def test_panel_regressions(panel_data, command_name):
"""Each panel estimator returns finite coefficient rows."""
command = getattr(panel, command_name)
out = command(
panel.PanelRegressionQueryParams(
data=panel_data,
y_column="income",
x_columns=["age", "education"],
)
)
results = out.results
assert isinstance(results, list)
assert len(results) > 0
for row in results:
dumped = row.model_dump()
assert isinstance(dumped["variable"], str)
assert isinstance(dumped["coefficient"], float)
assert isinstance(dumped["standard_error"], float)
assert isinstance(dumped["t_statistic"], float)
assert isinstance(dumped["p_value"], float)
assert dumped["conf_int_lower"] <= dumped["conf_int_upper"]
def test_panel_first_difference_no_constant(panel_data):
"""First-difference regression drops the constant term."""
out = panel.panel_first_difference(
panel.PanelRegressionQueryParams(
data=panel_data,
y_column="income",
x_columns=["age", "education"],
)
)
variables = {row.variable for row in out.results}
assert "const" not in variables
def test_panel_random_effects_too_few_items_raises(small_panel_data):
"""A panel with fewer than three observations triggers a ValueError."""
params = panel.PanelRegressionQueryParams(
data=small_panel_data,
y_column="portfolio_value",
x_columns=["risk_free_rate"],
)
with pytest.raises(ValueError, match="at least 3 items"):
panel.panel_random_effects(params)
def test_panel_fixed(panel_data):
"""Fixed-effects regression includes entity effects and omits the constant."""
out = panel.panel_fixed(
panel.PanelFixedQueryParams(
data=panel_data, y_column="income", x_columns=["age", "education"]
)
)
results = out.results
assert len(results) > 0
assert "const" not in {row.variable for row in results}
for row in results:
dumped = row.model_dump()
assert isinstance(dumped["coefficient"], float)
assert dumped["conf_int_lower"] <= dumped["conf_int_upper"]
def test_panel_fixed_time_effects(panel_data):
"""Fixed-effects regression accepts entity and time effects together."""
out = panel.panel_fixed(
panel.PanelFixedQueryParams(
data=panel_data,
y_column="income",
x_columns=["age", "education"],
time_effects=True,
)
)
assert out.results
def test_panel_robust_covariance(panel_data):
"""Panel estimators accept the robust covariance estimator."""
out = panel.panel_random_effects(
panel.PanelRegressionQueryParams(
data=panel_data,
y_column="income",
x_columns=["age", "education"],
cov_type="robust",
)
)
assert out.results

View File

@@ -0,0 +1,86 @@
"""Tests for ``openbb_econometrics.regression`` - OLS regression commands."""
import pytest
from openbb_econometrics import regression
def test_ols_regression(timeseries_data):
"""OLS regression returns one coefficient row per regressor plus the constant."""
params = regression.OlsRegressionQueryParams(
data=timeseries_data,
y_column="close",
x_columns=["open", "high", "low"],
)
assert params.cov_type == "nonrobust"
out = regression.ols_regression(params)
results = out.results
assert isinstance(results, list)
# const + open + high + low.
assert len(results) == 4
variables = {row.variable for row in results}
assert variables == {"const", "open", "high", "low"}
for row in results:
dumped = row.model_dump()
assert dumped["conf_int_lower"] <= dumped["conf_int_upper"]
assert isinstance(dumped["coefficient"], float)
assert isinstance(dumped["t_statistic"], float)
assert isinstance(dumped["p_value"], float)
def test_ols_regression_summary(timeseries_data):
"""The OLS summary reports finite goodness-of-fit statistics."""
out = regression.ols_regression_summary(
regression.OlsRegressionSummaryQueryParams(
data=timeseries_data,
y_column="close",
x_columns=["open", "high", "low"],
)
)
result = out.results
dumped = result.model_dump()
assert 0.0 <= dumped["r_squared"] <= 1.0
assert dumped["nobs"] == 120
assert dumped["f_statistic"] is not None
assert dumped["f_p_value"] is not None
assert isinstance(dumped["aic"], float)
assert isinstance(dumped["bic"], float)
assert "OLS Regression Results" in dumped["summary"]
def test_ols_regression_summary_non_numeric_raises(non_numeric_data):
"""A non-numeric regressor column triggers the numeric-coercion ValueError."""
params = regression.OlsRegressionSummaryQueryParams(
data=non_numeric_data,
y_column="close",
x_columns=["open", "high"],
)
with pytest.raises(ValueError, match="All columns must be numeric."):
regression.ols_regression_summary(params)
@pytest.mark.parametrize("cov_type", ["nonrobust", "HC0", "HC1", "HC2", "HC3"])
def test_ols_regression_cov_type(timeseries_data, cov_type):
"""OLS regression accepts the nonrobust and heteroskedasticity-robust estimators."""
out = regression.ols_regression(
regression.OlsRegressionQueryParams(
data=timeseries_data,
y_column="close",
x_columns=["open", "high", "low"],
cov_type=cov_type,
)
)
assert len(out.results) == 4
def test_ols_regression_summary_cov_type(timeseries_data):
"""The OLS summary accepts a heteroskedasticity-robust covariance estimator."""
out = regression.ols_regression_summary(
regression.OlsRegressionSummaryQueryParams(
data=timeseries_data,
y_column="close",
x_columns=["open", "high", "low"],
cov_type="HC3",
)
)
assert 0.0 <= out.results.r_squared <= 1.0

View File

@@ -0,0 +1,11 @@
"""Tests for ``openbb_econometrics.router`` - the aggregating router."""
def test_router_imports_and_aggregates():
"""Importing the router wires together every sub-router."""
from openbb_core.app.router import Router
from openbb_econometrics.router import router
assert isinstance(router, Router)
assert "router" in dir()

View File

@@ -0,0 +1,248 @@
"""Tests for ``openbb_econometrics.time_series`` - stationarity and volatility."""
import pytest
from openbb_econometrics import time_series
@pytest.mark.parametrize("regression", ["c", "ct", "ctt"])
def test_unit_root(timeseries_data, regression):
"""The ADF unit-root test returns a statistic and critical values."""
out = time_series.unit_root(
time_series.UnitRootQueryParams(
data=timeseries_data, column="close", regression=regression
)
)
dumped = out.results.model_dump()
assert isinstance(dumped["adf_stat"], float)
assert 0.0 <= dumped["p_value"] <= 1.0
assert dumped["used_lag"] >= 0
assert dumped["nobs"] > 0
assert isinstance(dumped["ic_best"], float)
assert set(dumped["critical_values"]) == {"1%", "5%", "10%"}
def test_unit_root_defaults(timeseries_data):
"""The ADF parameters default to a constant regression with AIC lag selection."""
params = time_series.UnitRootQueryParams(data=timeseries_data, column="close")
assert params.regression == "c"
assert params.maxlag is None
assert params.autolag == "AIC"
def test_unit_root_no_autolag(timeseries_data):
"""With ``autolag`` disabled the ADF test uses ``maxlag`` directly."""
out = time_series.unit_root(
time_series.UnitRootQueryParams(
data=timeseries_data, column="close", maxlag=4, autolag=None
)
)
dumped = out.results.model_dump()
assert dumped["used_lag"] == 4
assert dumped["ic_best"] is None
@pytest.mark.parametrize("regression", ["c", "ct"])
def test_kpss(timeseries_data, regression):
"""The KPSS stationarity test returns a statistic and critical values."""
out = time_series.kpss(
time_series.KpssQueryParams(
data=timeseries_data, column="close", regression=regression
)
)
dumped = out.results.model_dump()
assert isinstance(dumped["kpss_stat"], float)
assert 0.0 <= dumped["p_value"] <= 1.0
assert dumped["lags"] >= 0
assert dumped["critical_values"]
assert isinstance(dumped["p_value_interpolated"], bool)
assert dumped["p_value_interpolated"] == (0.01 < dumped["p_value"] < 0.1)
def test_kpss_defaults(timeseries_data):
"""The KPSS parameters default to a constant regression and automatic lags."""
params = time_series.KpssQueryParams(data=timeseries_data, column="close")
assert params.regression == "c"
assert params.nlags == "auto"
@pytest.mark.parametrize("nlags", ["auto", "legacy", 5])
def test_kpss_nlags(timeseries_data, nlags):
"""The KPSS test accepts automatic, legacy, and explicit integer lag settings."""
out = time_series.kpss(
time_series.KpssQueryParams(data=timeseries_data, column="close", nlags=nlags)
)
dumped = out.results.model_dump()
assert dumped["kpss_stat"] >= 0.0
assert dumped["lags"] >= 0
def test_cointegration(timeseries_data):
"""Engle-Granger cointegration returns one row per column pair."""
out = time_series.cointegration(
time_series.CointegrationQueryParams(
data=timeseries_data, columns=["open", "high", "close"]
)
)
results = out.results
# 3 columns -> 3 pairwise combinations.
assert len(results) == 3
pairs = {row.pair for row in results}
assert pairs == {"open/high", "open/close", "high/close"}
for row in results:
dumped = row.model_dump()
assert isinstance(dumped["c"], float)
assert isinstance(dumped["gamma"], float)
assert isinstance(dumped["alpha"], float)
assert isinstance(dumped["adf_stat"], float)
@pytest.mark.parametrize("deterministic_order", [-1, 0, 1])
def test_cointegration_johansen(timeseries_data, deterministic_order):
"""The Johansen test returns one row per cointegration rank hypothesis."""
out = time_series.cointegration_johansen(
time_series.CointegrationJohansenQueryParams(
data=timeseries_data,
columns=["open", "close"],
deterministic_order=deterministic_order,
)
)
results = out.results
# Two series -> two rank hypotheses.
assert len(results) == 2
for rank, row in enumerate(results):
dumped = row.model_dump()
assert dumped["rank"] == rank
assert isinstance(dumped["eigenvalue"], float)
assert isinstance(dumped["trace_statistic"], float)
assert isinstance(dumped["max_eig_statistic"], float)
assert dumped["trace_crit_90"] <= dumped["trace_crit_99"]
def test_cointegration_johansen_defaults(timeseries_data):
"""The Johansen ``deterministic_order``/``k_ar_diff`` defaults are 0 and 1."""
params = time_series.CointegrationJohansenQueryParams(
data=timeseries_data, columns=["open", "close"]
)
assert params.deterministic_order == 0
assert params.k_ar_diff == 1
def test_causality(timeseries_data):
"""The Granger causality test returns one row per Granger sub-test."""
out = time_series.causality(
time_series.CausalityQueryParams(
data=timeseries_data, y_column="close", x_column="open", lag=2
)
)
results = out.results
assert len(results) > 0
for row in results:
dumped = row.model_dump()
assert dumped["lag"] == 2
assert isinstance(dumped["test"], str)
assert isinstance(dumped["statistic"], float)
assert 0.0 <= dumped["p_value"] <= 1.0
def test_causality_default_lag(timeseries_data):
"""The Granger causality ``lag`` parameter defaults to 3."""
params = time_series.CausalityQueryParams(
data=timeseries_data, y_column="close", x_column="open"
)
assert params.lag == 3
@pytest.mark.parametrize(
("p", "q", "distribution"),
[
(1, 1, "normal"),
(2, 1, "t"),
(1, 2, "skewt"),
(2, 2, "ged"),
],
)
def test_garch(timeseries_data, p, q, distribution):
"""GARCH returns a conditional-volatility series and fit metadata in ``extra``."""
out = time_series.garch(
time_series.GarchQueryParams(
data=timeseries_data,
column="close",
p=p,
q=q,
distribution=distribution,
)
)
results = out.results
assert len(results) == 120
for index, row in enumerate(results):
dumped = row.model_dump()
assert dumped["date"] == index
assert dumped["conditional_volatility"] > 0.0
metadata = out.extra["results_metadata"]
assert set(metadata) == {"params", "aic", "bic", "log_likelihood"}
assert isinstance(metadata["params"], dict)
assert isinstance(metadata["aic"], float)
assert isinstance(metadata["bic"], float)
assert isinstance(metadata["log_likelihood"], float)
def test_garch_defaults(timeseries_data):
"""The GARCH model-configuration parameters default to a plain GARCH(1, 1)."""
params = time_series.GarchQueryParams(data=timeseries_data, column="close")
assert params.mean == "Constant"
assert params.lags == 0
assert params.vol == "GARCH"
assert params.p == 1
assert params.o == 0
assert params.q == 1
assert params.power == 2.0
assert params.distribution == "normal"
assert params.x_columns is None
def test_garch_configurable_volatility_model(timeseries_data):
"""garch fits non-default mean and volatility processes."""
out = time_series.garch(
time_series.GarchQueryParams(
data=timeseries_data,
column="close",
mean="AR",
lags=2,
vol="EGARCH",
o=1,
)
)
assert out.results
assert all(row.conditional_volatility > 0.0 for row in out.results)
def test_garch_with_exogenous_regressor(timeseries_data):
"""garch accepts exogenous mean regressors through x_columns."""
out = time_series.garch(
time_series.GarchQueryParams(
data=timeseries_data,
column="close",
x_columns=["open"],
mean="LS",
)
)
assert out.results
assert all(row.conditional_volatility > 0.0 for row in out.results)
def test_garch_preserves_input_dates():
"""garch reports each observation's input date, not a 0-based position."""
rows = [
{
"date": f"2022-{(i // 28) + 1:02d}-{(i % 28) + 1:02d}",
"close": 100.0 + (i % 7) - (i % 3) * 1.5 + i * 0.2,
}
for i in range(60)
]
out = time_series.garch(time_series.GarchQueryParams(data=rows, column="close"))
dates = [str(row.model_dump()["date"]) for row in out.results]
assert len(dates) == 60
assert dates[0] == "2022-01-01"
assert dates[-1] == "2022-03-04"

View File

@@ -0,0 +1,68 @@
"""Tests for ``openbb_econometrics.tools`` - correlation matrix and statistics."""
import pytest
from openbb_econometrics import tools
@pytest.mark.parametrize("method", ["pearson", "kendall", "spearman"])
def test_correlation_matrix_methods(timeseries_data, method):
"""The correlation matrix returns one row per numeric column for each method."""
out = tools.correlation_matrix(
tools.CorrelationMatrixQueryParams(data=timeseries_data, method=method)
)
results = out.results
assert isinstance(results, list)
# open/high/low/close/volume -> 5 numeric columns.
assert len(results) == 5
comp_to = {row.comp_to for row in results}
assert comp_to == {"open", "high", "low", "close", "volume"}
for row in results:
dumped = row.model_dump()
assert dumped["comp_to"] in comp_to
# The self-correlation is 1.0.
assert dumped[row.comp_to] == pytest.approx(1.0)
def test_correlation_matrix_defaults(timeseries_data):
"""The correlation matrix defaults to the pearson method with min_periods of 1."""
params = tools.CorrelationMatrixQueryParams(data=timeseries_data)
assert params.method == "pearson"
assert params.min_periods == 1
out = tools.correlation_matrix(params)
assert len(out.results) == 5
def test_correlation_matrix_min_periods(timeseries_data):
"""The correlation matrix honours an explicit min_periods threshold."""
out = tools.correlation_matrix(
tools.CorrelationMatrixQueryParams(data=timeseries_data, min_periods=10)
)
assert len(out.results) == 5
def test_correlation_matrix_symbol_pivot(symbol_data):
"""A dataset with a ``symbol`` column and >1 symbol pivots on ``close``."""
out = tools.correlation_matrix(tools.CorrelationMatrixQueryParams(data=symbol_data))
results = out.results
# Pivoted on the three symbols.
assert len(results) == 3
assert {row.comp_to for row in results} == {"AAA", "BBB", "CCC"}
def test_summary_statistics(timeseries_data):
"""Summary statistics returns one fully-populated row per numeric column."""
out = tools.summary_statistics(
tools.SummaryStatisticsQueryParams(data=timeseries_data)
)
results = out.results
assert len(results) == 5
columns = {row.column for row in results}
assert columns == {"open", "high", "low", "close", "volume"}
for row in results:
dumped = row.model_dump()
assert dumped["count"] > 0
assert dumped["min"] <= dumped["median"] <= dumped["max"]
assert dumped["p25"] <= dumped["p75"]
assert isinstance(dumped["skew"], float)
assert isinstance(dumped["kurtosis"], float)

3821
openbb_platform/extensions/econometrics/uv.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff