mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 04:31:13 +00:00
163
.github/workflows/publish-openbb-econometrics.yml
vendored
Normal file
163
.github/workflows/publish-openbb-econometrics.yml
vendored
Normal 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
|
||||
122
.github/workflows/test-unit-openbb-econometrics.yml
vendored
Normal file
122
.github/workflows/test-unit-openbb-econometrics.yml
vendored
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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()),
|
||||
)
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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),
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
2662
openbb_platform/extensions/econometrics/poetry.lock
generated
vendored
2662
openbb_platform/extensions/econometrics/poetry.lock
generated
vendored
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
|
||||
94
openbb_platform/extensions/econometrics/tests/conftest.py
Normal file
94
openbb_platform/extensions/econometrics/tests/conftest.py
Normal 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)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
103
openbb_platform/extensions/econometrics/tests/test_panel.py
Normal file
103
openbb_platform/extensions/econometrics/tests/test_panel.py
Normal 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
|
||||
@@ -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
|
||||
11
openbb_platform/extensions/econometrics/tests/test_router.py
Normal file
11
openbb_platform/extensions/econometrics/tests/test_router.py
Normal 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()
|
||||
@@ -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"
|
||||
68
openbb_platform/extensions/econometrics/tests/test_tools.py
Normal file
68
openbb_platform/extensions/econometrics/tests/test_tools.py
Normal 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
3821
openbb_platform/extensions/econometrics/uv.lock
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user