[V5] openbb-quantitative: Refactor for V5 (#7506)

* refactor quantitative for v5

* add factors endpoint and add a few charts

---------

Co-authored-by: deeleeramone <>
This commit is contained in:
Danglewood
2026-05-29 00:46:25 -07:00
committed by GitHub
parent 43b14e1746
commit cd7d602b8f
46 changed files with 8394 additions and 4794 deletions

View File

@@ -0,0 +1,163 @@
name: 📦 publish openbb-quantitative
# Triggers exclusively on the merge of a ``release/openbb-quantitative-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-quantitative-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/quantitative
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-quantitative/${VERSION}/json")
case "$STATUS" in
200)
echo "::error::openbb-quantitative ${VERSION} is already published on PyPI."
echo "Bump openbb_platform/extensions/quantitative/pyproject.toml [project].version and re-merge a fresh release branch."
exit 1
;;
404)
echo "openbb-quantitative ${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-quantitative-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/quantitative
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-quantitative-dist
path: openbb_platform/extensions/quantitative/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/quantitative
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-quantitative 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_quantitative --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-quantitative-dist
path: dist
- uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist

View File

@@ -0,0 +1,122 @@
name: 🧪 test openbb-quantitative
# PR-test for the Quantitative extension. Triggers when a PR touches any file
# under ``openbb_platform/extensions/quantitative/`` 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]
paths:
- "openbb_platform/extensions/quantitative/**"
- ".github/workflows/test-unit-openbb-quantitative.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: test-openbb-quantitative-${{ 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/quantitative
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-quantitative-dist
path: openbb_platform/extensions/quantitative/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/quantitative
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-quantitative in editable mode (+ dev group)
run: |
uv pip install --system -e .
uv pip install --system --group dev
- name: ruff check
run: ruff check openbb_quantitative tests integration
- name: ruff format --check
run: ruff format --check openbb_quantitative tests integration
- name: ty check
run: ty check openbb_quantitative
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/quantitative
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-quantitative 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_quantitative --cov-report=term-missing --cov-fail-under=100

View File

@@ -1,17 +1,136 @@
# OpenBB QA Extension
# OpenBB Quantitative Extension
This extension provides Quantitative Analysis (QA) tools for the OpenBB Platform.
This package adds the `openbb-quantitative` extension to the Open Data Platform by OpenBB.
Features of the QA extension include various statistical tools and models.
This extension works nicely with a companion `openbb-charting` extension
It provides a quantitative analysis toolkit — normality and unit root tests, CAPM risk
measures, descriptive statistics, rolling-window statistics, risk-adjusted performance
ratios, and multi-factor regression / attribution / risk-decomposition — 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-quantitative
```
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
# Descriptive summary statistics of a series.
obb.quantitative.summary(data=prices, target="close")
# Augmented Dickey-Fuller and KPSS unit root tests.
obb.quantitative.unitroot_test(data=prices, target="close")
# Rolling standard deviation over a moving window.
obb.quantitative.rolling.stdev(data=prices, target="close", window=21)
# Rolling Sharpe ratio.
obb.quantitative.performance.sharpe_ratio(data=prices, target="close")
```
### Factor analysis
The factor endpoints take two payloads — a target series and a factor matrix —
plus an optional risk-free column name. Pair with any factor source; for
Fama-French data the [`openbb-famafrench`](../../providers/famafrench) provider
exposes the canonical research datasets.
```python
target = obb.equity.price.historical("SPY", provider="yfinance").results
factors = obb.famafrench.factors(provider="famafrench").results
# Multi-period regression: betas, p-values, CIs, R-squared per named window.
obb.quantitative.factors(
data=target, factors_data=factors, target="close", risk_free_column="rf"
)
# Share of Var(target) attributable to each factor (residual sums to 1 - R^2).
obb.quantitative.risk_decomposition(
data=target, factors_data=factors, target="close", risk_free_column="rf"
)
# Decompose the period's total return into factor contributions + alpha + residual.
obb.quantitative.attribution(
data=target, factors_data=factors, target="close", risk_free_column="rf"
)
# Refit OLS on a sliding window to track time-varying factor exposures.
obb.quantitative.rolling.factors(
data=target, factors_data=factors, target="close",
window=252, step=21, risk_free_column="rf",
)
```
To use the extension over HTTP, start the API server with `openbb-api` and POST to
`/api/v1/quantitative/<command>`.
## Coverage
All commands are available under `obb.quantitative.*`.
### Metrics
- `normality` — kurtosis, skewness, Jarque-Bera, Shapiro-Wilk, and Kolmogorov-Smirnov normality tests
- `capm` — Capital Asset Pricing Model risk measures
- `unitroot_test` — Augmented Dickey-Fuller and KPSS unit root tests
- `summary` — descriptive summary statistics of a series
### Factor analysis
- `factors` — multi-period OLS regression of a target series on a factor matrix; returns coefficient, p-value, 95% CI, and R-squared per (period, factor)
- `risk_decomposition` — share of Var(target) attributable to each factor plus residual; per-period factor shares sum to R-squared and the residual share to 1 - R-squared
- `attribution` — additive decomposition of the period's total target return into factor contributions, alpha, and residual
### Rolling
- `rolling.skew` — rolling skew over a moving window
- `rolling.variance` — rolling variance over a moving window
- `rolling.stdev` — rolling standard deviation over a moving window
- `rolling.kurtosis` — rolling kurtosis over a moving window
- `rolling.mean` — rolling mean over a moving window
- `rolling.quantile` — rolling quantile over a moving window
- `rolling.factors` — rolling-window factor regression; emits per-factor betas and t-statistics at each window end
### Stats
- `stats.skew` — skewness of a series
- `stats.variance` — variance of a series
- `stats.stdev` — standard deviation of a series
- `stats.kurtosis` — kurtosis of a series
- `stats.mean` — arithmetic mean of a series
- `stats.quantile` — quantile of a series
### Performance
- `performance.omega_ratio` — Omega ratio across a range of return thresholds
- `performance.sharpe_ratio` — rolling Sharpe ratio
- `performance.sortino_ratio` — rolling Sortino ratio
### Charts
The extension also ships chart views, auto-discovered by `openbb-charting`:
- `factors` — coefficient heatmap colored by p-value
- `risk_decomposition` — stacked horizontal bars of variance shares per period
- `attribution` — stacked horizontal bars of return contributions per period (signs preserved)
- `rolling.factors` — stacked area chart of rolling factor exposure over time.
See the full docs [here](https://docs.openbb.co/odp/python/extensions/data-processing/quantitative)

View File

@@ -0,0 +1,70 @@
"""Integration-test fixtures for openbb-quantitative."""
import threading
import time
import numpy as np
import pandas as pd
import pytest
_SEED = 20240517
_N_ROWS = 500
@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.1 * t + rng.normal(0, 1, _N_ROWS).cumsum() * 0.3
close = np.abs(base + rng.normal(0, 1, _N_ROWS)) + 1.0
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 prices_data() -> list[dict]:
"""Deterministic OHLCV records for the quantitative command tests."""
return _timeseries_records()

View File

@@ -1,564 +1,183 @@
"""Integration tests for the quantitative extension."""
"""Integration tests for the openbb-quantitative 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
# pylint:disable=redefined-outer-name
data: dict = {}
_BASE_URL = "http://127.0.0.1:8000/api/v1/quantitative"
_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, start_date: str = "", end_date: 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}&start_date={start_date}&end_date={end_date}" # pylint: disable=line-too-long # noqa: E501
result = requests.get(url, headers=get_headers(), timeout=10)
return result.json()["results"]
def get_stocks_data():
"""Get stocks data."""
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"] = request_data(
menu="equity",
symbol=symbol,
provider=provider,
start_date="2023-01-01",
end_date="2023-12-31",
def _post(endpoint: str, payload: dict) -> requests.Response:
"""POST a QueryParams payload to a quantitative endpoint."""
return requests.post(
f"{_BASE_URL}/{endpoint}", headers=get_headers(), timeout=30, json=payload
)
return data["stocks_data"]
def get_crypto_data():
"""Get crypto data."""
if "crypto_data" in data:
return data["crypto_data"]
@pytest.mark.integration
def test_quantitative_normality(prices_data):
result = _post("normality", {"data": prices_data, "target": "close"})
assert result.status_code == 200
assert result.json()["results"]
# TODO : add more crypto providers and symbols
symbol = random.choice(["BTCUSD"]) # noqa: S311
provider = "fmp"
data["crypto_data"] = request_data(
menu="crypto",
symbol=symbol,
provider=provider,
start_date="2023-01-01",
end_date="2023-12-31",
@pytest.mark.integration
def test_quantitative_capm(prices_data):
result = _post("capm", {"data": prices_data, "target": "close"})
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_quantitative_unitroot_test(prices_data):
result = _post("unitroot_test", {"data": prices_data, "target": "close"})
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_quantitative_summary(prices_data):
result = _post("summary", {"data": prices_data, "target": "close"})
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.integration
def test_quantitative_rolling_skew(prices_data):
result = _post(
"rolling/skew", {"data": prices_data, "target": "close", "window": 20}
)
return data["crypto_data"]
def get_data(menu: Literal["equity", "crypto"]):
"""Get data based on the selected menu."""
funcs = {"equity": get_stocks_data, "crypto": get_crypto_data}
return funcs[menu]()
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close"}, "equity"),
({"data": "", "target": "high"}, "crypto"),
],
)
@pytest.mark.integration
def test_quantitative_normality(params, data_type):
"""Test the normality endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/normality?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=data)
assert isinstance(result, requests.Response)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "high"}, "equity"),
({"data": "", "target": "high"}, "crypto"),
],
)
@pytest.mark.integration
def test_quantitative_capm(params, data_type):
"""Test the CAPM endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/capm?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=data)
assert isinstance(result, requests.Response)
assert result.status_code == 200
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"threshold_start": "",
"threshold_end": "",
},
"equity",
),
(
{
"data": "",
"target": "high",
"threshold_start": "0.1",
"threshold_end": "1.6",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_performance_omega_ratio(params, data_type):
"""Test the Omega Ratio endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/performance/omega_ratio?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=data)
assert isinstance(result, requests.Response)
assert result.status_code == 200
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "window": "5", "index": "date"}, "equity"),
({"data": "", "target": "high", "window": "10", "index": "date"}, "crypto"),
],
)
@pytest.mark.integration
def test_quantitative_rolling_kurtosis(params, data_type):
"""Test the rolling kurtosis endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/rolling/kurtosis?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=data)
assert isinstance(result, requests.Response)
assert result.status_code == 200
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"fuller_reg": "c",
"kpss_reg": "ct",
},
"equity",
),
(
{
"data": "",
"target": "high",
"fuller_reg": "ct",
"kpss_reg": "c",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_unitroot_test(params, data_type):
"""Test the unit root test endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/unitroot_test?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=data)
assert isinstance(result, requests.Response)
assert result.status_code == 200
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"rfr": "",
"window": "100",
"index": "date",
},
"equity",
),
(
{
"data": "",
"target": "high",
"rfr": "0.5",
"window": "150",
"index": "date",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_performance_sharpe_ratio(params, data_type):
"""Test the Sharpe Ratio endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = (
f"http://0.0.0.0:8000/api/v1/quantitative/performance/sharpe_ratio?{query_str}"
def test_quantitative_rolling_variance(prices_data):
result = _post(
"rolling/variance", {"data": prices_data, "target": "close", "window": 20}
)
result = requests.post(url, headers=get_headers(), timeout=10, data=data)
assert isinstance(result, requests.Response)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"target_return": "",
"window": "100",
"adjusted": "",
"index": "date",
},
"equity",
),
(
{
"data": "",
"target": "close",
"target_return": "0.5",
"window": "150",
"adjusted": "true",
"index": "date",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_performance_sortino_ratio(params, data_type):
"""Test the Sortino Ratio endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = (
f"http://0.0.0.0:8000/api/v1/quantitative/performance/sortino_ratio?{query_str}"
def test_quantitative_rolling_stdev(prices_data):
result = _post(
"rolling/stdev", {"data": prices_data, "target": "close", "window": 20}
)
result = requests.post(url, headers=get_headers(), timeout=10, data=data)
assert isinstance(result, requests.Response)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "window": "220", "index": "date"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_rolling_skew(params, data_type):
"""Test the rolling skew endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/rolling/skew?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=60, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_rolling_kurtosis(prices_data):
result = _post(
"rolling/kurtosis", {"data": prices_data, "target": "close", "window": 20}
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "window": "220", "index": "date"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_rolling_variance(params, data_type):
"""Test the rolling variance endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/rolling/variance?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=60, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_rolling_mean(prices_data):
result = _post(
"rolling/mean", {"data": prices_data, "target": "close", "window": 20}
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "window": "220", "index": "date"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_rolling_stdev(params, data_type):
"""Test the rolling standard deviation endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/rolling/stdev?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=60, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_rolling_quantile(prices_data):
result = _post(
"rolling/quantile",
{"data": prices_data, "target": "close", "window": 20, "quantile_pct": 0.75},
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "window": "220", "index": "date"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_rolling_mean(params, data_type):
"""Test the rolling mean endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/rolling/mean?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=60, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_stats_skew(prices_data):
result = _post("stats/skew", {"data": prices_data, "target": "close"})
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"window": "10",
"quantile_pct": "",
"index": "date",
},
"equity",
),
(
{
"data": "",
"target": "high",
"window": "50",
"quantile_pct": "0.6",
"index": "date",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_rolling_quantile(params, data_type):
"""Test the rolling quantile endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/rolling/quantile?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_stats_variance(prices_data):
result = _post("stats/variance", {"data": prices_data, "target": "close"})
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close"}, "equity"),
({"data": "", "target": "high"}, "crypto"),
],
)
@pytest.mark.integration
def test_quantitative_summary(params, data_type):
"""Test the summary endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/summary?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_stats_stdev(prices_data):
result = _post("stats/stdev", {"data": prices_data, "target": "close"})
assert result.status_code == 200
assert result.json()["results"]
############
# quantitative/stats
############
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "index": "date"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_stats_skew(params, data_type):
"""Test the skew endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/stats/skew?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=60, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_stats_kurtosis(prices_data):
result = _post("stats/kurtosis", {"data": prices_data, "target": "close"})
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "index": "date"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_stats_kurtosis(params, data_type):
"""Test the kurtosis endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/stats/kurtosis?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=60, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_stats_mean(prices_data):
result = _post("stats/mean", {"data": prices_data, "target": "close"})
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "index": "date"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_stats_mean(params, data_type):
"""Test the mean endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/stats/mean?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=60, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_stats_quantile(prices_data):
result = _post(
"stats/quantile",
{"data": prices_data, "target": "close", "quantile_pct": 0.75},
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "index": "date"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_stats_stdev(params, data_type):
"""Test the standard deviation endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/stats/stdev?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=60, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_performance_omega_ratio(prices_data):
result = _post("performance/omega_ratio", {"data": prices_data, "target": "close"})
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "index": "date"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_stats_variance(params, data_type):
"""Test the variance endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/stats/variance?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=60, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_performance_sharpe_ratio(prices_data):
result = _post(
"performance/sharpe_ratio",
{"data": prices_data, "target": "close", "window": 20},
)
assert result.status_code == 200
assert result.json()["results"]
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"quantile_pct": "",
"index": "date",
},
"equity",
),
(
{
"data": "",
"target": "high",
"quantile_pct": "0.6",
"index": "date",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_stats_quantile(params, data_type):
"""Test the quantile endpoint."""
params = {p: v for p, v in params.items() if v}
data = json.dumps(get_data(data_type))
query_str = get_querystring(params, [])
url = f"http://0.0.0.0:8000/api/v1/quantitative/stats/quantile?{query_str}"
result = requests.post(url, headers=get_headers(), timeout=10, data=data)
assert isinstance(result, requests.Response)
def test_quantitative_performance_sortino_ratio(prices_data):
result = _post(
"performance/sortino_ratio",
{"data": prices_data, "target": "close", "window": 20},
)
assert result.status_code == 200
assert result.json()["results"]

View File

@@ -1,559 +1,159 @@
"""Test qa extension."""
import random
from typing import Literal
"""Integration tests for the openbb-quantitative Python interface."""
import pytest
from openbb_core.app.model.obbject import OBBject
# 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( # type: ignore
symbol=symbol, provider=provider # type: ignore
).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": "", "target": "close"}, "equity"),
({"data": "", "target": "high"}, "crypto"),
],
)
@pytest.mark.integration
def test_quantitative_normality(params, data_type, obb):
"""Test normality."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.normality(**params)
assert result
def test_quantitative_normality(obb, prices_data):
result = obb.quantitative.normality(data=prices_data, target="close")
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close"}, "equity"),
({"data": "", "target": "high"}, "crypto"),
],
)
@pytest.mark.integration
def test_quantitative_capm(params, data_type, obb):
"""Test capm."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.capm(**params)
assert result
def test_quantitative_capm(obb, prices_data):
result = obb.quantitative.capm(data=prices_data, target="close")
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"threshold_start": "",
"threshold_end": "",
},
"equity",
),
(
{
"data": "",
"target": "high",
"threshold_start": "0.1",
"threshold_end": "1.6",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_performance_omega_ratio(params, data_type, obb):
"""Test omega ratio."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.performance.omega_ratio(**params)
assert result
def test_quantitative_unitroot_test(obb, prices_data):
result = obb.quantitative.unitroot_test(data=prices_data, target="close")
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "window": "5", "index": "date"}, "equity"),
({"data": "", "target": "high", "window": "10", "index": "date"}, "crypto"),
],
)
@pytest.mark.integration
def test_quantitative_rolling_kurtosis(params, data_type, obb):
"""Test rolling kurtosis."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.rolling.kurtosis(**params)
assert result
def test_quantitative_summary(obb, prices_data):
result = obb.quantitative.summary(data=prices_data, target="close")
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"fuller_reg": "c",
"kpss_reg": "ct",
},
"equity",
),
(
{
"data": "",
"target": "high",
"fuller_reg": "ct",
"kpss_reg": "c",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_unitroot_test(params, data_type, obb):
"""Test unitroot test."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.unitroot_test(**params)
assert result
def test_quantitative_rolling_skew(obb, prices_data):
result = obb.quantitative.rolling.skew(data=prices_data, target="close", window=20)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"rfr": "",
"window": "100",
"index": "date",
},
"equity",
),
(
{
"data": "",
"target": "high",
"rfr": "0.5",
"window": "100",
"index": "date",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_performance_sharpe_ratio(params, data_type, obb):
"""Test sharpe ratio."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.performance.sharpe_ratio(**params)
assert result
def test_quantitative_rolling_variance(obb, prices_data):
result = obb.quantitative.rolling.variance(
data=prices_data, target="close", window=20
)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"target_return": "",
"window": "100",
"adjusted": "",
"index": "date",
},
"equity",
),
(
{
"data": "",
"target": "close",
"target_return": "",
"window": "100",
"adjusted": "true",
"index": "date",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_performance_sortino_ratio(params, data_type, obb):
"""Test sortino ratio."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.performance.sortino_ratio(**params)
assert result
def test_quantitative_rolling_stdev(obb, prices_data):
result = obb.quantitative.rolling.stdev(data=prices_data, target="close", window=20)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close", "window": "220", "index": "date"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_rolling_skew(params, data_type, obb):
"""Test rolling skew."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.rolling.skew(**params)
assert result
def test_quantitative_rolling_kurtosis(obb, prices_data):
result = obb.quantitative.rolling.kurtosis(
data=prices_data, target="close", window=20
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"window": "10",
"quantile_pct": "",
"index": "date",
},
"equity",
),
(
{
"data": "",
"target": "high",
"window": "50",
"quantile_pct": "0.6",
"index": "date",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_rolling_quantile(params, data_type, obb):
"""Test rolling quantile."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.rolling.quantile(**params)
assert result
def test_quantitative_rolling_mean(obb, prices_data):
result = obb.quantitative.rolling.mean(data=prices_data, target="close", window=20)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close"}, "equity"),
({"data": "", "target": "high"}, "crypto"),
],
)
@pytest.mark.integration
def test_quantitative_summary(params, data_type, obb):
"""Test summary."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.summary(**params)
assert result
def test_quantitative_rolling_quantile(obb, prices_data):
result = obb.quantitative.rolling.quantile(
data=prices_data, target="close", window=20, quantile_pct=0.75
)
assert isinstance(result, OBBject)
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"window": "10",
"index": "date",
},
"equity",
),
(
{
"data": "",
"target": "high",
"window": "50",
"index": "date",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_rolling_stdev(params, data_type, obb):
"""Test rolling stdev."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.rolling.stdev(**params)
assert result
def test_quantitative_stats_skew(obb, prices_data):
result = obb.quantitative.stats.skew(data=prices_data, target="close")
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"window": "10",
"index": "date",
},
"equity",
),
(
{
"data": "",
"target": "high",
"window": "50",
"index": "date",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_rolling_mean(params, data_type, obb):
"""Test rolling mean."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.rolling.mean(**params)
assert result
def test_quantitative_stats_variance(obb, prices_data):
result = obb.quantitative.stats.variance(data=prices_data, target="close")
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"window": "10",
"index": "date",
},
"equity",
),
(
{
"data": "",
"target": "high",
"window": "50",
"index": "date",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_rolling_variance(params, data_type, obb):
"""Test rolling variance."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.rolling.variance(**params)
assert result
def test_quantitative_stats_stdev(obb, prices_data):
result = obb.quantitative.stats.stdev(data=prices_data, target="close")
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_stats_skew(params, data_type, obb):
"""Test skew."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.stats.skew(**params)
assert result
def test_quantitative_stats_kurtosis(obb, prices_data):
result = obb.quantitative.stats.kurtosis(data=prices_data, target="close")
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_stats_kurtosis(params, data_type, obb):
"""Test kurtosis."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.stats.kurtosis(**params)
assert result
def test_quantitative_stats_mean(obb, prices_data):
result = obb.quantitative.stats.mean(data=prices_data, target="close")
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_stats_variance(params, data_type, obb):
"""Test variance."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.stats.variance(**params)
assert result
def test_quantitative_stats_quantile(obb, prices_data):
result = obb.quantitative.stats.quantile(
data=prices_data, target="close", quantile_pct=0.75
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_stats_stdev(params, data_type, obb):
"""Test stdev."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.stats.stdev(**params)
assert result
def test_quantitative_performance_omega_ratio(obb, prices_data):
result = obb.quantitative.performance.omega_ratio(data=prices_data, target="close")
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
({"data": "", "target": "close"}, "equity"),
],
)
@pytest.mark.integration
def test_quantitative_stats_mean(params, data_type, obb):
"""Test mean."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.stats.mean(**params)
assert result
def test_quantitative_performance_sharpe_ratio(obb, prices_data):
result = obb.quantitative.performance.sharpe_ratio(
data=prices_data, target="close", window=20
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results
@pytest.mark.parametrize(
"params, data_type",
[
(
{
"data": "",
"target": "close",
"quantile_pct": "",
},
"equity",
),
(
{
"data": "",
"target": "close",
"quantile_pct": "0.6",
},
"crypto",
),
],
)
@pytest.mark.integration
def test_quantitative_stats_quantile(params, data_type, obb):
"""Test quantile."""
params = {p: v for p, v in params.items() if v}
params["data"] = get_data(data_type)
result = obb.quantitative.stats.quantile(**params)
assert result
def test_quantitative_performance_sortino_ratio(obb, prices_data):
result = obb.quantitative.performance.sortino_ratio(
data=prices_data, target="close", window=20
)
assert isinstance(result, OBBject)
assert len(result.results) > 0
assert result.results

View File

@@ -1 +1 @@
"""Quantitative analysis extension for OpenBB Platform."""
"""OpenBB Quantitative Analysis Extension."""

View File

@@ -0,0 +1,105 @@
"""Internal helpers shared by the factor-regression endpoints."""
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pandas import DataFrame, Series, Timestamp
_DEFAULT_PERIODS: tuple[str, ...] = (
"1 Month",
"3 Month",
"YTD",
"1 Year",
"3 Year",
"Max",
)
def period_start(max_date: "Timestamp", period: str):
"""Return the inclusive start date for a named look-back period."""
from pandas import DateOffset, Timestamp
if period == "1 Month":
return max_date - DateOffset(months=1)
if period == "3 Month":
return max_date - DateOffset(months=3)
if period == "YTD":
return Timestamp(f"{max_date.year}-01-01")
if period == "1 Year":
return max_date - DateOffset(years=1)
if period == "3 Year":
return max_date - DateOffset(years=3)
if period == "Max":
return None
raise ValueError(
f"Unsupported period '{period}'. Expected one of: {list(_DEFAULT_PERIODS)}."
)
def align_inputs(
data,
factors_data,
target: str,
index: str,
risk_free_column: str | None,
) -> tuple["DataFrame", "Series", list[str]]:
"""Align the target series and factor matrix on their shared index.
Returns a tuple of `(factor_matrix, target_series, factor_cols)`:
- `factor_matrix` is indexed by the common dates, holds only the factor columns
(risk-free column excluded), and is float-typed.
- `target_series` is the (optionally excess) target, aligned to `factor_matrix`.
- `factor_cols` is the ordered list of factor column names.
Raises `ValueError` if the inputs cannot produce a non-empty aligned frame.
"""
from openbb_core.app.utils import basemodel_to_df
from pandas import to_datetime
target_df = basemodel_to_df(data, index=index)
factor_df = basemodel_to_df(factors_data, index=index)
if target not in target_df.columns:
raise ValueError(
f"Target column '{target}' not found in `data`. Available columns:"
f" {list(target_df.columns)}."
)
target_df.index = to_datetime(target_df.index)
factor_df.index = to_datetime(factor_df.index)
target_df = target_df.sort_index()
factor_df = factor_df.sort_index().astype(float, errors="ignore")
common_index = target_df.index.intersection(factor_df.index)
if len(common_index) == 0:
raise ValueError(
"No overlapping dates between `data` and `factors_data` after alignment."
)
target_series = target_df.loc[common_index, target].astype(float)
factor_matrix = factor_df.loc[common_index].copy()
if risk_free_column is not None:
if risk_free_column not in factor_matrix.columns:
raise ValueError(
f"`risk_free_column='{risk_free_column}'` not found in"
f" `factors_data`. Available columns: {list(factor_matrix.columns)}."
)
target_series = target_series - factor_matrix[risk_free_column].astype(float)
factor_matrix = factor_matrix.drop(columns=[risk_free_column])
factor_cols = [c for c in factor_matrix.columns if c != target]
if not factor_cols:
raise ValueError(
"No factor columns remain after dropping the target and risk-free columns."
)
aligned = factor_matrix[factor_cols].astype(float)
aligned = aligned.assign(**{target: target_series}).dropna()
if aligned.empty:
raise ValueError(
"No observations remain after dropping NaNs across the aligned"
" target and factor matrix."
)
return aligned[factor_cols], aligned[target], factor_cols

View File

@@ -0,0 +1,35 @@
"""Statistical compute helpers for the quantitative extension."""
from numpy import (
mean as mean_np,
ndarray,
std as std_np,
var as var_np,
)
from pandas import DataFrame, Series
from scipy import stats
def kurtosis_(data: DataFrame | Series | ndarray) -> float:
"""Compute kurtosis - the tailedness of a distribution."""
return float(stats.kurtosis(data))
def skew_(data: DataFrame | Series | ndarray) -> float:
"""Compute skewness - the asymmetry of a distribution about its mean."""
return float(stats.skew(data))
def mean_(data: DataFrame | Series | ndarray) -> float:
"""Compute the arithmetic mean."""
return float(mean_np(data))
def std_dev_(data: DataFrame | Series | ndarray) -> float:
"""Compute the standard deviation - the dispersion of a set of values."""
return float(std_np(data))
def var_(data: DataFrame | Series | ndarray) -> float:
"""Compute the variance - the squared dispersion of a set of values."""
return float(var_np(data))

View File

@@ -0,0 +1,146 @@
"""Quantitative return-attribution commands."""
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
from openbb_quantitative._factor_helpers import (
_DEFAULT_PERIODS,
align_inputs,
period_start,
)
router = Router(prefix="", description="Return-attribution commands.")
_ALPHA_LABEL = "Alpha"
_RESIDUAL_LABEL = "Residual"
class ReturnAttributionQueryParams(QueryParams):
"""Query parameters for the attribution endpoint."""
__category__ = "quantitative"
__output_columns__ = ("period", "factor", "contribution", "share")
data: list[Data] = Field(
description="Target time series (index column plus the target column)."
)
factors_data: list[Data] = Field(
description="Factor matrix (index column plus one column per factor)."
)
target: str = Field(
default="close",
description="Name of the column in `data` to attribute across the factors.",
)
index: str = Field(
default="date",
description="Name of the index column shared by `data` and `factors_data`.",
)
risk_free_column: str | None = Field(
default=None,
description="Optional name of the risk-free rate column in `factors_data`."
" When provided, the target is converted to excess returns before"
" attribution and the column is dropped from the factor set.",
)
periods: list[str] = Field(
default_factory=lambda: list(_DEFAULT_PERIODS),
description="Named look-back periods to attribute over, anchored to the"
" latest factor observation.",
)
class ReturnAttributionData(Data):
"""One return-contribution observation for a given look-back period."""
period: str = Field(description="Look-back period the attribution was run over.")
factor: str = Field(
description="Factor name, 'Alpha' for the intercept contribution, or"
" 'Residual' for the unexplained residual."
)
contribution: float = Field(
description="Component's contribution to the period's total target return:"
" for a factor, beta_i * sum_t factor_i; for 'Alpha', alpha * n; for"
" 'Residual', sum of regression residuals. The three sum to the period's"
" total target return."
)
share: float = Field(
description="Component's contribution divided by the total target return"
" over the period. Shares sum to 1.0 unless the total return is zero."
)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data("timeseries", 800),
"factors_data": APIEx.mock_data("timeseries", 800),
}
)
],
)
def attribution(
params: ReturnAttributionQueryParams,
) -> OBBject[list[ReturnAttributionData]]:
"""Decompose the target's total return into per-factor contributions plus alpha
and residual.
For each look-back period, runs OLS of the target on the factor matrix and
splits the period's cumulative target return via the identity
`sum_t y_t = alpha * n + sum_i beta_i * sum_t f_{i,t} + sum_t residual_t`.
The three components are returned as rows: one per factor, one `Alpha`, and one
`Residual`. Use with arithmetic (additive) returns; for geometric compounding,
convert returns to log-returns before passing in.
"""
import statsmodels.api as sm
factor_matrix, target_series, factor_cols = align_inputs(
params.data,
params.factors_data,
target=params.target,
index=params.index,
risk_free_column=params.risk_free_column,
)
aligned = factor_matrix.assign(**{params.target: target_series})
max_date = aligned.index.max()
out: list[ReturnAttributionData] = []
for period in params.periods:
start = period_start(max_date, period)
window = aligned if start is None else aligned[aligned.index >= start]
if len(window) <= len(factor_cols) + 1:
continue
y = window[params.target]
total_return = float(y.sum())
x = sm.add_constant(window[factor_cols])
model = sm.OLS(y, x).fit()
n = len(window)
alpha_contribution = float(model.params["const"]) * n
residual_contribution = float(model.resid.sum())
components: list[tuple[str, float]] = []
for name in factor_cols:
beta = float(model.params[name])
factor_sum = float(window[name].sum())
components.append((name, beta * factor_sum))
components.append((_ALPHA_LABEL, alpha_contribution))
components.append((_RESIDUAL_LABEL, residual_contribution))
denom = total_return if total_return != 0.0 else float("nan")
for name, contribution in components:
out.append(
ReturnAttributionData(
period=period,
factor=name,
contribution=contribution,
share=contribution / denom,
)
)
return OBBject(results=out)

View File

@@ -0,0 +1,140 @@
"""Quantitative factor-regression commands."""
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
from openbb_quantitative._factor_helpers import (
_DEFAULT_PERIODS,
align_inputs,
period_start,
)
router = Router(prefix="", description="Quantitative factor-regression commands.")
class FactorRegressionQueryParams(QueryParams):
"""Query parameters for the factors endpoint."""
__category__ = "quantitative"
__output_columns__ = (
"period",
"factor",
"coefficient",
"p_value",
"lower_ci",
"upper_ci",
"r_squared",
)
data: list[Data] = Field(
description="Target time series (index column plus the target column)."
)
factors_data: list[Data] = Field(
description="Factor matrix (index column plus one column per factor)."
)
target: str = Field(
default="close",
description="Name of the column in `data` to regress on the factor matrix.",
)
index: str = Field(
default="date",
description="Name of the index column shared by `data` and `factors_data`.",
)
risk_free_column: str | None = Field(
default=None,
description="Optional name of the risk-free rate column in `factors_data`."
" When provided, the target is regressed in excess form (target minus the"
" risk-free column) and the column itself is dropped from the regressors.",
)
periods: list[str] = Field(
default_factory=lambda: list(_DEFAULT_PERIODS),
description="Named look-back periods to regress over, anchored to the latest"
" factor observation. Supported values: '1 Month', '3 Month', 'YTD',"
" '1 Year', '3 Year', 'Max'.",
)
class FactorRegressionData(Data):
"""One factor coefficient observation for a given look-back period."""
period: str = Field(description="Look-back period the regression was run over.")
factor: str = Field(
description="Factor name (or 'const' for the regression intercept)."
)
coefficient: float = Field(description="OLS coefficient estimate for the factor.")
p_value: float = Field(description="Two-sided p-value of the coefficient estimate.")
lower_ci: float = Field(description="Lower bound of the 95% confidence interval.")
upper_ci: float = Field(description="Upper bound of the 95% confidence interval.")
r_squared: float = Field(
description="R-squared of the regression for this period (repeated per row)."
)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data("timeseries", 800),
"factors_data": APIEx.mock_data("timeseries", 800),
}
)
],
)
def factors(
params: FactorRegressionQueryParams,
) -> OBBject[list[FactorRegressionData]]:
"""Regress a target return series on a factor matrix across named periods.
Aligns `data` and `factors_data` on their shared index, optionally subtracts a
risk-free column from the target, then runs an OLS regression of the target on
the factor columns (plus an intercept) for each named look-back period. Returns
one row per (`period`, `factor`) with the OLS coefficient, p-value, 95%
confidence interval, and the period's R-squared.
Designed to be paired with any factor provider; for Fama-French data, pipe the
`openbb-famafrench` provider's output into `factors_data`.
"""
import statsmodels.api as sm
factor_matrix, target_series, factor_cols = align_inputs(
params.data,
params.factors_data,
target=params.target,
index=params.index,
risk_free_column=params.risk_free_column,
)
aligned = factor_matrix.assign(**{params.target: target_series})
max_date = aligned.index.max()
out: list[FactorRegressionData] = []
for period in params.periods:
start = period_start(max_date, period)
window = aligned if start is None else aligned[aligned.index >= start]
if len(window) <= len(factor_cols) + 1:
continue
x = sm.add_constant(window[factor_cols])
model = sm.OLS(window[params.target], x).fit()
conf = model.conf_int()
r_squared = float(model.rsquared)
for name in model.params.index:
out.append(
FactorRegressionData(
period=period,
factor=str(name),
coefficient=float(model.params[name]),
p_value=float(model.pvalues[name]),
lower_ci=float(conf.loc[name, 0]),
upper_ci=float(conf.loc[name, 1]),
r_squared=r_squared,
)
)
return OBBject(results=out)

View File

@@ -1,80 +1,58 @@
"""Helper functions for Quantitative Analysis."""
"""Helper functions for the quantitative extension."""
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pandas import DataFrame, Series
_FAMA_FRENCH_FACTORS_URL = (
"https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/"
"F-F_Research_Data_Factors_CSV.zip"
)
# ruff: ignore=S310
def get_fama_raw(start_date: str, end_date: str) -> "DataFrame":
"""Get base Fama French data to calculate risk.
"""Download the Fama-French research factors for a date range.
Returns
-------
DataFrame
A data with fama french model information
Returns a DataFrame indexed by date with the excess market return ('mkt_rf'),
size ('smb'), value ('hml'), and risk-free ('rf') factors as decimal fractions.
"""
# pylint: disable=import-outside-toplevel
from io import BytesIO
from urllib.request import urlopen
from zipfile import ZipFile
from pandas import read_csv, to_datetime, to_numeric
with urlopen( # nosec # noqa: S310 SIM117
"https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Research_Data_Factors_CSV.zip"
) as url:
# Download Zipfile and create pandas DataFrame
with ZipFile(BytesIO(url.read())) as zipfile:
with zipfile.open("F-F_Research_Data_Factors.csv") as zip_open:
df = read_csv(
zip_open,
header=0,
names=["Date", "MKT-RF", "SMB", "HML", "RF"],
skiprows=3,
)
df = df[df["Date"].apply(lambda x: len(str(x).strip()) == 6)]
df["Date"] = df["Date"].astype(str) + "01"
df["Date"] = to_datetime(df["Date"], format="%Y%m%d")
df["MKT-RF"] = to_numeric(df["MKT-RF"], downcast="float")
df["SMB"] = to_numeric(df["SMB"], downcast="float")
df["HML"] = to_numeric(df["HML"], downcast="float")
df["RF"] = to_numeric(df["RF"], downcast="float")
df["MKT-RF"] = df["MKT-RF"] / 100
df["SMB"] = df["SMB"] / 100
df["HML"] = df["HML"] / 100
df["RF"] = df["RF"] / 100
df = df.set_index("Date")
dt_start_date = to_datetime(start_date, format="%Y-%m-%d")
if dt_start_date > df.index.max():
raise ValueError(
f"Start date '{dt_start_date}' is after the last date available for Fama-French '{df.index[-1]}'"
with (
urlopen(_FAMA_FRENCH_FACTORS_URL) as response, # noqa: S310
ZipFile(BytesIO(response.read())) as archive,
archive.open("F-F_Research_Data_Factors.csv") as handle,
):
df = read_csv(
handle,
header=0,
names=["date", "mkt_rf", "smb", "hml", "rf"],
skiprows=3,
)
df = df.loc[start_date:end_date] # type: ignore
df = df[df["date"].apply(lambda value: len(str(value).strip()) == 6)]
df["date"] = to_datetime(df["date"].astype(str) + "01", format="%Y%m%d")
for column in ("mkt_rf", "smb", "hml", "rf"):
df[column] = to_numeric(df[column], downcast="float") / 100
df = df.set_index("date")
return df
if to_datetime(start_date) > df.index.max():
raise ValueError(
f"Start date '{start_date}' is after the last available Fama-French"
f" observation '{df.index.max().date()}'."
)
return df.loc[start_date:end_date]
def validate_window(input_data: Union["Series", "DataFrame"], window: int) -> None:
"""Validate the window input.
Parameters
----------
input_data : Union[Series, DataFrame]
The input data to be validated.
window : int
The window to be validated.
Raises
------
ValueError
If the window is greater than the input data length.
"""
def validate_window(input_data: "Series | DataFrame", window: int) -> None:
"""Raise a ValueError when the rolling window exceeds the data length."""
if window > len(input_data):
raise ValueError(
f"Window '{window}' is greater than the input data length '{len(input_data)}'"
f"Window '{window}' is larger than the data length '{len(input_data)}'."
)

View File

@@ -0,0 +1,354 @@
"""Quantitative analysis metrics 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, NonNegativeInt
router = Router(prefix="", description="Quantitative analysis commands.")
class NormalityQueryParams(QueryParams):
"""Query parameters for the normality endpoint."""
__category__ = "quantitative"
__output_columns__ = (
"kurtosis_statistic",
"kurtosis_p_value",
"skewness_statistic",
"skewness_p_value",
"jarque_bera_statistic",
"jarque_bera_p_value",
"shapiro_wilk_statistic",
"shapiro_wilk_p_value",
"kolmogorov_smirnov_statistic",
"kolmogorov_smirnov_p_value",
)
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
class NormalityData(Data):
"""Normality test statistics for a data series."""
kurtosis_statistic: float = Field(description="Kurtosis test statistic.")
kurtosis_p_value: float = Field(description="p-value of the kurtosis test.")
skewness_statistic: float = Field(description="Skewness test statistic.")
skewness_p_value: float = Field(description="p-value of the skewness test.")
jarque_bera_statistic: float = Field(description="Jarque-Bera test statistic.")
jarque_bera_p_value: float = Field(description="p-value of the Jarque-Bera test.")
shapiro_wilk_statistic: float = Field(description="Shapiro-Wilk test statistic.")
shapiro_wilk_p_value: float = Field(description="p-value of the Shapiro-Wilk test.")
kolmogorov_smirnov_statistic: float = Field(
description="Kolmogorov-Smirnov test statistic."
)
kolmogorov_smirnov_p_value: float = Field(
description="p-value of the Kolmogorov-Smirnov test."
)
class CapmQueryParams(QueryParams):
"""Query parameters for the CAPM endpoint."""
__category__ = "quantitative"
__output_columns__ = ("market_risk", "systematic_risk", "idiosyncratic_risk")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
class CapmData(Data):
"""Capital Asset Pricing Model risk measures for an asset."""
market_risk: float = Field(
description="Beta - the asset's sensitivity to market returns."
)
systematic_risk: float = Field(
description="Share of variance explained by the market (R-squared)."
)
idiosyncratic_risk: float = Field(
description="Share of variance not explained by the market (1 - R-squared)."
)
class UnitRootTestQueryParams(QueryParams):
"""Query parameters for the unit root test endpoint."""
__category__ = "quantitative"
__output_columns__ = (
"adf_statistic",
"adf_p_value",
"adf_nlags",
"adf_nobs",
"adf_icbest",
"kpss_statistic",
"kpss_p_value",
"kpss_nlags",
"kpss_p_value_interpolated",
)
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
fuller_reg: Literal["c", "ct", "ctt", "n"] = Field(
default="c",
description="Constant/trend terms for the ADF test: 'c' constant, 'ct'"
+ " constant and trend, 'ctt' constant, linear and quadratic trend, 'n' no"
+ " constant or trend.",
)
kpss_reg: Literal["c", "ct"] = Field(
default="c",
description="Null hypothesis for the KPSS test: 'c' level-stationary, 'ct'"
+ " trend-stationary.",
)
maxlag: NonNegativeInt | None = Field(
default=None,
description="Maximum lag for the ADF test. If None, a data-dependent"
+ " default is used.",
)
autolag: Literal["AIC", "BIC", "t-stat"] | None = Field(
default="AIC",
description="Lag-selection method for the ADF test: 'AIC', 'BIC', or"
+ " 't-stat'; None uses maxlag directly without selection.",
)
nlags: Literal["auto", "legacy"] | NonNegativeInt = Field(
default="auto",
description="Lags for the KPSS variance estimator: 'auto', 'legacy', or an"
+ " explicit non-negative integer.",
)
class UnitRootData(Data):
"""Augmented Dickey-Fuller and KPSS unit root test results."""
adf_statistic: float = Field(description="Augmented Dickey-Fuller test statistic.")
adf_p_value: float = Field(description="p-value of the ADF test.")
adf_nlags: int = Field(description="Number of lags used in the ADF test.")
adf_nobs: int = Field(description="Number of observations used in the ADF test.")
adf_icbest: float | None = Field(
default=None,
description="Best information criterion value of the ADF test"
+ " (None when autolag is disabled).",
)
kpss_statistic: float = Field(description="KPSS test statistic.")
kpss_p_value: float = Field(description="p-value of the KPSS test.")
kpss_nlags: int = Field(description="Number of lags used in the KPSS test.")
kpss_p_value_interpolated: bool = Field(
description="False when the KPSS p-value is clamped to a table boundary"
+ " (the true p-value is more extreme than reported)."
)
class SummaryQueryParams(QueryParams):
"""Query parameters for the summary endpoint."""
__category__ = "quantitative"
__output_columns__ = (
"count",
"mean",
"std",
"var",
"min",
"p25",
"p50",
"p75",
"max",
)
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
class SummaryData(Data):
"""Descriptive summary statistics for a data series."""
count: int = Field(description="Number of observations.")
mean: float = Field(description="Arithmetic mean.")
std: float = Field(description="Standard deviation.")
var: float = Field(description="Variance.")
min: float = Field(description="Minimum value.")
p25: float = Field(description="25th percentile.")
p50: float = Field(description="50th percentile (median).")
p75: float = Field(description="75th percentile.")
max: float = Field(description="Maximum value.")
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries")})
],
)
def normality(params: NormalityQueryParams) -> OBBject[NormalityData]:
"""Get normality statistics for a data series.
Runs the kurtosis, skewness, Jarque-Bera, Shapiro-Wilk, and Kolmogorov-Smirnov
tests, each assessing whether the sample is drawn from a normal distribution.
Returns the test statistic and p-value for every test.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from scipy import stats
series = get_target_column(basemodel_to_df(params.data), params.target)
try:
series = series.astype(float)
except ValueError as exc:
raise ValueError("The target column must be numeric.") from exc
kurtosis_statistic, kurtosis_p_value = stats.kurtosistest(series)
skewness_statistic, skewness_p_value = stats.skewtest(series)
jarque_bera_statistic, jarque_bera_p_value = stats.jarque_bera(series)
shapiro_wilk_statistic, shapiro_wilk_p_value = stats.shapiro(series)
kolmogorov_smirnov_statistic, kolmogorov_smirnov_p_value = stats.kstest(
series, "norm"
)
return OBBject(
results=NormalityData(
kurtosis_statistic=float(kurtosis_statistic),
kurtosis_p_value=float(kurtosis_p_value),
skewness_statistic=float(skewness_statistic),
skewness_p_value=float(skewness_p_value),
jarque_bera_statistic=float(jarque_bera_statistic),
jarque_bera_p_value=float(jarque_bera_p_value),
shapiro_wilk_statistic=float(shapiro_wilk_statistic),
shapiro_wilk_p_value=float(shapiro_wilk_p_value),
kolmogorov_smirnov_statistic=float(kolmogorov_smirnov_statistic),
kolmogorov_smirnov_p_value=float(kolmogorov_smirnov_p_value),
)
)
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries")})
],
)
def capm(params: CapmQueryParams) -> OBBject[CapmData]:
"""Get Capital Asset Pricing Model (CAPM) risk measures for an asset.
Regresses the asset's excess return on the excess market return using the
Fama-French factors. Returns the market risk (beta), the systematic risk
(R-squared), and the idiosyncratic risk (1 - R-squared).
"""
import statsmodels.api as sm
from openbb_core.app.utils import basemodel_to_df, get_target_columns
from pandas import to_datetime
from openbb_quantitative.helpers import get_fama_raw
prices = get_target_columns(basemodel_to_df(params.data), ["date", params.target])
prices = prices.set_index("date")
prices.index = to_datetime(prices.index)
monthly_return = (
prices[params.target]
.resample("MS")
.last()
.pct_change(fill_method=None)
.dropna()
)
monthly_return.name = "asset_return"
start_date = monthly_return.index.min().strftime("%Y-%m-%d")
end_date = monthly_return.index.max().strftime("%Y-%m-%d")
factors = get_fama_raw(start_date, end_date)
merged = monthly_return.to_frame().merge(factors, left_index=True, right_index=True)
merged["excess_return"] = merged["asset_return"] - merged["rf"]
merged = merged.dropna()
model = sm.OLS(merged[["excess_return"]], sm.add_constant(merged["mkt_rf"])).fit()
return OBBject(
results=CapmData(
market_risk=float(model.params["mkt_rf"]),
systematic_risk=float(model.rsquared),
idiosyncratic_risk=float(1 - model.rsquared),
)
)
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries")})
],
)
def unitroot_test(params: UnitRootTestQueryParams) -> OBBject[UnitRootData]:
"""Get unit root test results for a data series.
Applies the Augmented Dickey-Fuller (ADF) test, whose null hypothesis is the
presence of a unit root, and the Kwiatkowski-Phillips-Schmidt-Shin (KPSS) test,
whose null hypothesis is stationarity. Together they characterize whether the
series is stationary.
"""
import warnings
from openbb_core.app.utils import basemodel_to_df, get_target_column
from statsmodels.tools.sm_exceptions import InterpolationWarning
from statsmodels.tsa import stattools
series = get_target_column(basemodel_to_df(params.data), params.target)
adf = stattools.adfuller(
series,
maxlag=params.maxlag,
regression=params.fuller_reg,
autolag=params.autolag,
)
adf_icbest = adf[5] if len(adf) > 5 else None
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", InterpolationWarning)
kpss_result = stattools.kpss(
series, regression=params.kpss_reg, nlags=params.nlags
)
kpss_p_value_interpolated = not any(
issubclass(entry.category, InterpolationWarning) for entry in caught
)
return OBBject(
results=UnitRootData(
adf_statistic=float(adf[0]),
adf_p_value=float(adf[1]),
adf_nlags=int(adf[2]),
adf_nobs=int(adf[3]),
adf_icbest=float(adf_icbest) if adf_icbest is not None else None,
kpss_statistic=float(kpss_result[0]),
kpss_p_value=float(kpss_result[1]),
kpss_nlags=int(kpss_result[2]),
kpss_p_value_interpolated=kpss_p_value_interpolated,
)
)
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries")})
],
)
def summary(params: SummaryQueryParams) -> OBBject[SummaryData]:
"""Get descriptive summary statistics for a data series.
Computes the count, central tendency, dispersion, and quartile distribution of
the target column. Provides a concise profile of the data for initial
exploration and reporting.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
series = get_target_column(basemodel_to_df(params.data), params.target)
return OBBject(
results=SummaryData(
count=int(series.count()),
mean=float(series.mean()),
std=float(series.std()),
var=float(series.var()),
min=float(series.min()),
p25=float(series.quantile(0.25)),
p50=float(series.quantile(0.5)),
p75=float(series.quantile(0.75)),
max=float(series.max()),
)
)

View File

@@ -1,70 +0,0 @@
"""Pydantic models for Quantitative Analysis."""
from pydantic import BaseModel
class TestModel(BaseModel):
"""Base model for QA tests."""
statistic: float
p_value: float
class NormalityModel(BaseModel):
"""Normality model."""
kurtosis: TestModel
skewness: TestModel
jarque_bera: TestModel
shapiro_wilk: TestModel
kolmogorov_smirnov: TestModel
class ADFTestModel(TestModel):
"""Augmented Dickey-Fuller test model."""
nlags: int
nobs: int
icbest: float
class KPSSTestModel(TestModel):
"""Kwiatkowski–Phillips–Schmidt–Shin test model."""
nlags: int
class UnitRootModel(BaseModel):
"""Unit root model."""
adf: ADFTestModel
kpss: KPSSTestModel
class OmegaModel(BaseModel):
"""Omega model."""
threshold: float
omega: float
class SummaryModel(BaseModel):
"""Summary model."""
count: int
mean: float
std: float
var: float
min: float
max: float
p_25: float
p_50: float
p_75: float
class CAPMModel(BaseModel):
"""CAPM model."""
market_risk: float
systematic_risk: float
idiosyncratic_risk: float

View File

@@ -0,0 +1,240 @@
"""Quantitative performance-metric commands."""
from datetime import (
date as dateType,
datetime,
)
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, PositiveInt
router = Router(
prefix="/performance",
description="Quantitative performance-metric commands.",
)
class OmegaRatioQueryParams(QueryParams):
"""Query parameters for the Omega ratio endpoint."""
__category__ = "performance"
__output_columns__ = ("threshold", "omega")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
threshold_start: float = Field(
default=0.0, description="Lower bound of the return-threshold range."
)
threshold_end: float = Field(
default=1.5, description="Upper bound of the return-threshold range."
)
bins: PositiveInt = Field(
default=50, description="Number of evenly spaced thresholds to evaluate."
)
class OmegaRatioData(Data):
"""One Omega ratio evaluated at a return threshold."""
threshold: float = Field(description="Return threshold the ratio is evaluated at.")
omega: float = Field(description="Omega ratio at the threshold.")
class SharpeRatioQueryParams(QueryParams):
"""Query parameters for the rolling Sharpe ratio endpoint."""
__category__ = "performance"
__output_columns__ = ("date", "sharpe_ratio")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
rfr: float = Field(
default=0.0, description="Risk-free rate, as a decimal fraction."
)
window: PositiveInt = Field(
default=252, description="Number of observations in each rolling window."
)
index: str = Field(default="date", description="Name of the index column.")
class SharpeRatioData(Data):
"""One rolling Sharpe ratio observation."""
date: datetime | dateType | str = Field(description="Observation date.")
sharpe_ratio: float = Field(description="Rolling Sharpe ratio.")
class SortinoRatioQueryParams(QueryParams):
"""Query parameters for the rolling Sortino ratio endpoint."""
__category__ = "performance"
__output_columns__ = ("date", "sortino_ratio")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
target_return: float = Field(
default=0.0, description="Minimum acceptable return, as a decimal fraction."
)
window: PositiveInt = Field(
default=252, description="Number of observations in each rolling window."
)
adjusted: bool = Field(
default=False,
description="When true, scale the ratio by 1/sqrt(2) for comparability"
+ " with the Sharpe ratio.",
)
index: str = Field(default="date", description="Name of the index column.")
class SortinoRatioData(Data):
"""One rolling Sortino ratio observation."""
date: datetime | dateType | str = Field(description="Observation date.")
sortino_ratio: float = Field(description="Rolling Sortino ratio.")
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data("timeseries"),
}
),
],
)
def omega_ratio(params: OmegaRatioQueryParams) -> OBBject[list[OmegaRatioData]]:
"""Calculate the Omega ratio across a range of return thresholds.
The Omega ratio measures the probability-weighted gains above a threshold relative
to the losses below it. The ratio is evaluated at 50 thresholds spanning the
requested range, giving a profile of risk and reward rather than a single number.
"""
from numpy import linspace, sqrt
from openbb_core.app.utils import basemodel_to_df, get_target_column
series = get_target_column(basemodel_to_df(params.data), params.target)
epsilon = 1e-6
def get_omega_ratio(df_target, threshold: float) -> float:
"""Get omega ratio."""
daily_threshold = (threshold + 1) ** sqrt(1 / 252) - 1
excess = df_target - daily_threshold
numerator = excess[excess > 0].sum()
denominator = -excess[excess < 0].sum() + epsilon
return numerator / denominator
thresholds = linspace(params.threshold_start, params.threshold_end, params.bins)
out = [
OmegaRatioData(threshold=float(t), omega=float(get_omega_ratio(series, t)))
for t in thresholds
]
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"window": 20,
"data": APIEx.mock_data("timeseries", 60),
}
),
],
)
def sharpe_ratio(params: SharpeRatioQueryParams) -> OBBject[list[SharpeRatioData]]:
"""Calculate the rolling Sharpe ratio of a return series.
The Sharpe ratio measures the excess return earned per unit of total volatility.
It is computed over a rolling window so the result tracks how risk-adjusted
performance evolves over time.
"""
from numpy import sqrt
from openbb_core.app.utils import basemodel_to_df, get_target_column
from pandas import DataFrame
from openbb_quantitative.helpers import validate_window
df = basemodel_to_df(params.data, index=params.index)
series = get_target_column(df, params.target)
validate_window(series, params.window)
returns = series.pct_change(fill_method=None).dropna().rolling(params.window).sum()
std = series.rolling(params.window).std() / sqrt(params.window)
ratio = ((returns - params.rfr) / std).dropna()
frame = DataFrame({"date": ratio.index, "sharpe_ratio": ratio.to_numpy()})
out = [
SharpeRatioData(date=record["date"], sharpe_ratio=float(record["sharpe_ratio"]))
for record in frame.to_dict(orient="records")
]
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"window": 20,
"data": APIEx.mock_data("timeseries", 60),
}
),
],
)
def sortino_ratio(params: SortinoRatioQueryParams) -> OBBject[list[SortinoRatioData]]:
"""Calculate the rolling Sortino ratio of a return series.
The Sortino ratio refines the Sharpe ratio by penalizing only downside volatility
measured against a minimum acceptable return. When adjusted, the ratio is scaled by
1/sqrt(2) so it can be compared directly with the Sharpe ratio.
"""
from numpy import isfinite, nan, sqrt
from openbb_core.app.utils import basemodel_to_df, get_target_column
from pandas import DataFrame
from openbb_quantitative.helpers import validate_window
df = basemodel_to_df(params.data, index=params.index)
series = get_target_column(df, params.target)
validate_window(series, params.window)
returns = (
series.pct_change(fill_method=None)
.dropna()
.rolling(params.window)
.sum()
.dropna()
)
def downside_deviation(values) -> float:
"""Annualized standard deviation of the negative returns in a window."""
negative = values[values < 0]
if negative.size < 2:
return nan
return float(negative.std() / sqrt(252) * 100)
deviation = returns.rolling(params.window).apply(downside_deviation, raw=True)
ratio = (returns - params.target_return) / deviation
ratio = ratio[isfinite(ratio)]
if params.adjusted:
ratio = ratio / sqrt(2)
frame = DataFrame({"date": ratio.index, "sortino_ratio": ratio.to_numpy()})
out = [
SortinoRatioData(
date=record["date"], sortino_ratio=float(record["sortino_ratio"])
)
for record in frame.to_dict(orient="records")
]
return OBBject(results=out)

View File

@@ -1,271 +0,0 @@
"""OpenBB Performance Extension router."""
# pylint: disable=too-many-positional-arguments
from typing import TYPE_CHECKING
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_quantitative.models import (
OmegaModel,
)
from pydantic import PositiveInt
if TYPE_CHECKING:
from pandas import Series
router = Router(prefix="/performance")
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Omega Ratio.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.performance.omega_ratio(data=returns, target="close")',
],
),
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
},
),
],
)
def omega_ratio(
data: list[Data],
target: str,
threshold_start: float = 0.0,
threshold_end: float = 1.5,
) -> OBBject[list[OmegaModel]]:
"""Calculate the Omega Ratio.
The Omega Ratio is a sophisticated metric that goes beyond traditional performance measures by considering the
probability of achieving returns above a given threshold. It offers a more nuanced view of risk and reward,
focusing on the likelihood of success rather than just average outcomes.
Parameters
----------
data : list[Data]
Time series data.
target : str
Target column name.
threshold_start : float, optional
Start threshold, by default 0.0
threshold_end : float, optional
End threshold, by default 1.5
Returns
-------
OBBject[list[OmegaModel]]
Omega ratios.
"""
# pylint: disable=import-outside-toplevel
from numpy import linspace, sqrt
from openbb_core.app.utils import (
basemodel_to_df,
get_target_column,
)
df = basemodel_to_df(data)
series_target = get_target_column(df, target)
epsilon = 1e-6 # to avoid division by zero
def get_omega_ratio(df_target: "Series", threshold: float) -> float:
"""Get omega ratio."""
daily_threshold = (threshold + 1) ** sqrt(1 / 252) - 1
excess = df_target - daily_threshold
numerator = excess[excess > 0].sum()
denominator = -excess[excess < 0].sum() + epsilon
return numerator / denominator
threshold = linspace(threshold_start, threshold_end, 50)
results = []
for i in threshold:
omega_ = get_omega_ratio(series_target, i)
results.append(OmegaModel(threshold=i, omega=omega_))
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Rolling Sharpe Ratio.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501 # pylint: disable=line-too-long
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.performance.sharpe_ratio(data=returns, target="close")',
],
),
APIEx(
parameters={
"target": "close",
"window": 2,
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
},
),
],
)
def sharpe_ratio(
data: list[Data],
target: str,
rfr: float = 0.0,
window: PositiveInt = 252,
index: str = "date",
) -> OBBject[list[Data]]:
"""Get Rolling Sharpe Ratio.
This function calculates the Sharpe Ratio, a metric used to assess the return of an investment compared to its risk.
By factoring in the risk-free rate, it helps you understand how much extra return you're getting for the extra
volatility that you endure by holding a riskier asset. The Sharpe Ratio is essential for investors looking to
compare the efficiency of different investments, providing a clear picture of potential rewards in relation to their
risks over a specified period. Ideal for gauging the effectiveness of investment strategies, it offers insights into
optimizing your portfolio for maximum return on risk.
Parameters
----------
data : list[Data]
Time series data.
target : str
Target column name.
rfr : float, optional
Risk-free rate, by default 0.0
window : PositiveInt, optional
Window size, by default 252
index : str, optional
Returns
-------
OBBject[list[Data]]
Sharpe ratio.
"""
# pylint: disable=import-outside-toplevel
from numpy import sqrt
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.helpers import validate_window
df = basemodel_to_df(data, index=index)
series_target = get_target_column(df, target)
validate_window(series_target, window)
series_target.name = f"sharpe_{window}"
returns = series_target.pct_change().dropna().rolling(window).sum()
std = series_target.rolling(window).std() / sqrt(window)
results = ((returns - rfr) / std).dropna().reset_index(drop=False)
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Rolling Sortino Ratio.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.performance.sortino_ratio(data=stock_data, target="close")',
'obb.quantitative.performance.sortino_ratio(data=stock_data, target="close", target_return=0.01, window=126, adjusted=True)', # noqa: E501 pylint: disable=line-too-long
],
),
APIEx(
parameters={
"target": "close",
"window": 2,
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
},
),
],
)
def sortino_ratio(
data: list[Data],
target: str,
target_return: float = 0.0,
window: PositiveInt = 252,
adjusted: bool = False,
index: str = "date",
) -> OBBject[list[Data]]:
"""Get rolling Sortino Ratio.
The Sortino Ratio enhances the evaluation of investment returns by distinguishing harmful volatility
from total volatility. Unlike other metrics that treat all volatility as risk, this command specifically assesses
the volatility of negative returns relative to a target or desired return.
It's particularly useful for investors who are more concerned with downside risk than with overall volatility.
By calculating the Sortino Ratio, investors can better understand the risk-adjusted return of their investments,
focusing on the likelihood and impact of negative returns.
This approach offers a more nuanced tool for portfolio optimization, especially in strategies aiming
to minimize the downside.
For method & terminology see:
http://www.redrockcapital.com/Sortino__A__Sharper__Ratio_Red_Rock_Capital.pdf
Parameters
----------
data : list[Data]
Time series data.
target : str
Target column name.
target_return : float, optional
Target return, by default 0.0
window : PositiveInt, optional
Window size, by default 252
adjusted : bool, optional
Adjust sortino ratio to compare it to sharpe ratio, by default False
index:str
Index column for input data
Returns
-------
OBBject[list[Data]]
Sortino ratio.
"""
# pylint: disable=import-outside-toplevel
from numpy import sqrt
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.helpers import validate_window
df = basemodel_to_df(data, index=index)
series_target = get_target_column(df, target)
validate_window(series_target, window)
returns = series_target.pct_change().dropna().rolling(window).sum().dropna()
downside_deviation = returns.rolling(window).apply(
lambda x: (x.values[x.values < 0]).std() / sqrt(252) * 100
)
results = (
((returns - target_return) / downside_deviation)
.dropna()
.reset_index(drop=False)
)
if adjusted:
results = results.map(lambda x: x / sqrt(2) if isinstance(x, float) else x)
results_ = df_to_basemodel(results)
return OBBject(results=results_)

View File

@@ -1,297 +0,0 @@
"""Quantitative Analysis Router."""
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_quantitative.models import (
ADFTestModel,
CAPMModel,
KPSSTestModel,
NormalityModel,
SummaryModel,
TestModel,
UnitRootModel,
)
from openbb_quantitative.performance.performance_router import (
router as performance_router,
)
from openbb_quantitative.rolling.rolling_router import router as rolling_router
from openbb_quantitative.stats.stats_router import router as stats_router
router = Router(prefix="", description="Quantitative analysis tools.")
router.include_router(rolling_router)
router.include_router(stats_router)
router.include_router(performance_router)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Normality Statistics.",
code=[
"stock_data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').to_df()", # noqa: E501
"obb.quantitative.normality(data=stock_data, target='close')",
],
),
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries", 8)}),
],
)
def normality(data: list[Data], target: str) -> OBBject[NormalityModel]:
"""Get Normality Statistics.
- **Kurtosis**: whether the kurtosis of a sample differs from the normal distribution.
- **Skewness**: whether the skewness of a sample differs from the normal distribution.
- **Jarque-Bera**: whether the sample data has the skewness and kurtosis matching a normal distribution.
- **Shapiro-Wilk**: whether a random sample comes from a normal distribution.
- **Kolmogorov-Smirnov**: whether two underlying one-dimensional probability distributions differ.
Parameters
----------
data : list[Data]
Time series data.
target : str
Target column name.
Returns
-------
OBBject[NormalityModel]
Normality tests summary. See qa_models.NormalityModel for details.
"""
# pylint: disable=import-outside-toplevel
from scipy import stats # noqa
from openbb_core.app.utils import ( # noqa
basemodel_to_df,
get_target_column,
)
df = basemodel_to_df(data)
series_target = get_target_column(df, target)
kt_statistic, kt_pvalue = stats.kurtosistest(series_target)
sk_statistic, sk_pvalue = stats.skewtest(series_target)
jb_statistic, jb_pvalue = stats.jarque_bera(series_target)
sh_statistic, sh_pvalue = stats.shapiro(series_target)
ks_statistic, ks_pvalue = stats.kstest(series_target, "norm")
norm_summary = NormalityModel(
kurtosis=TestModel(statistic=kt_statistic, p_value=kt_pvalue),
skewness=TestModel(statistic=sk_statistic, p_value=sk_pvalue),
jarque_bera=TestModel(statistic=jb_statistic, p_value=jb_pvalue),
shapiro_wilk=TestModel(statistic=sh_statistic, p_value=sh_pvalue),
kolmogorov_smirnov=TestModel(statistic=ks_statistic, p_value=ks_pvalue),
)
return OBBject(results=norm_summary)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Capital Asset Pricing Model (CAPM).",
code=[
"stock_data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').to_df()", # noqa: E501
"obb.quantitative.capm(data=stock_data, target='close')",
],
),
APIEx(
parameters={"target": "close", "data": APIEx.mock_data("timeseries", 31)}
),
],
)
def capm(data: list[Data], target: str) -> OBBject[CAPMModel]:
"""Get Capital Asset Pricing Model (CAPM).
CAPM offers a streamlined way to assess the expected return on an investment while accounting for its risk relative
to the market. It's a cornerstone of modern financial theory that helps investors understand the trade-off between
risk and return, guiding more informed investment choices.
Parameters
----------
data : list[Data]
Time series data.
target : str
Target column name.
Returns
-------
OBBject[CAPMModel]
CAPM model summary.
"""
# pylint: disable=import-outside-toplevel
import statsmodels.api as sm # noqa
from openbb_core.app.utils import ( # noqa``
basemodel_to_df,
get_target_columns,
)
from pandas import to_datetime # noqa
from openbb_quantitative.helpers import get_fama_raw # noqa
df = basemodel_to_df(data)
df_target = get_target_columns(df, ["date", target])
df_target = df_target.set_index("date")
df_target["return"] = df_target.pct_change()
df_target = df_target.dropna()
df_target.index = to_datetime(df_target.index)
start_date = df_target.index.min().strftime("%Y-%m-%d")
end_date = df_target.index.max().strftime("%Y-%m-%d")
df_fama = get_fama_raw(start_date, end_date)
df_target = df_target.merge(df_fama, left_index=True, right_index=True)
df_target["excess_return"] = df_target["return"] - df_target["RF"]
df_target["excess_mkt"] = df_target["MKT-RF"] - df_target["RF"]
df_target = df_target.dropna()
y = df_target[["excess_return"]]
x = df_target["excess_mkt"]
x = sm.add_constant(x)
model = sm.OLS(y, x).fit()
results = CAPMModel(
market_risk=model.params["excess_mkt"],
systematic_risk=model.rsquared,
idiosyncratic_risk=1 - model.rsquared,
)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Unit Root Test.",
code=[
"stock_data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').to_df()", # noqa: E501
"obb.quantitative.unitroot_test(data=stock_data, target='close')",
],
),
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries", 5)}),
],
)
def unitroot_test(
data: list[Data],
target: str,
fuller_reg: Literal["c", "ct", "ctt", "nc", "c"] = "c",
kpss_reg: Literal["c", "ct"] = "c",
) -> OBBject[UnitRootModel]:
"""Get Unit Root Test.
This function applies two renowned tests to assess whether your data series is stationary or if it contains a unit
root, indicating it may be influenced by time-based trends or seasonality. The Augmented Dickey-Fuller (ADF) test
helps identify the presence of a unit root, suggesting that the series could be non-stationary and potentially
unpredictable over time. On the other hand, the Kwiatkowski-Phillips-Schmidt-Shin (KPSS) test checks for the
stationarity of the series, where failing to reject the null hypothesis indicates a stable, stationary series.
Together, these tests provide a comprehensive view of your data's time series properties, essential for
accurate modeling and forecasting.
Parameters
----------
data : list[Data]
Time series data.
target : str
Target column name.
fuller_reg : Literal["c", "ct", "ctt", "nc", "c"]
Regression type for ADF test.
kpss_reg : Literal["c", "ct"]
Regression type for KPSS test.
Returns
-------
OBBject[UnitRootModel]
Unit root tests summary.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import ( # noqa
basemodel_to_df,
get_target_column,
)
from statsmodels.tsa import stattools # noqa
df = basemodel_to_df(data)
series_target = get_target_column(df, target)
adf = stattools.adfuller(series_target, regression=fuller_reg)
kpss = stattools.kpss(series_target, regression=kpss_reg, nlags="auto")
unitroot_summary = UnitRootModel(
adf=ADFTestModel(
statistic=adf[0],
p_value=adf[1],
nlags=adf[2] if isinstance(adf[2], int) else 0,
nobs=adf[3] if isinstance(adf[3], int) else 0,
icbest=adf[5] if isinstance(adf[5], float) else 0.0, # type: ignore
),
kpss=KPSSTestModel(
statistic=kpss[0],
p_value=kpss[1],
nlags=kpss[2],
),
)
return OBBject(results=unitroot_summary)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Summary Statistics.",
code=[
"stock_data = obb.equity.price.historical(symbol='TSLA', start_date='2023-01-01', provider='fmp').to_df()", # noqa: E501
"obb.quantitative.summary(data=stock_data, target='close')",
],
),
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries", 5)}),
],
)
def summary(data: list[Data], target: str) -> OBBject[SummaryModel]:
"""Get Summary Statistics.
The summary that offers a snapshot of its central tendencies, variability, and distribution.
This command calculates essential statistics, including mean, standard deviation, variance,
and specific percentiles, to provide a detailed profile of your target column. B
y examining these metrics, you gain insights into the data's overall behavior, helping to identify patterns,
outliers, or anomalies. The summary table is an invaluable tool for initial data exploration,
ensuring you have a solid foundation for further analysis or reporting.
Parameters
----------
data : list[Data]
Time series data.
target : str
Target column name.
Returns
-------
OBBject[SummaryModel]
Summary table.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
get_target_column,
)
df = basemodel_to_df(data)
series_target = get_target_column(df, target)
df_stats = series_target.describe(percentiles=[0.1, 0.25, 0.5, 0.75, 0.9])
df_stats.loc["var"] = df_stats.loc["std"] ** 2
results = SummaryModel(
count=df_stats.loc["count"],
mean=df_stats.loc["mean"],
std=df_stats.loc["std"],
var=df_stats.loc["var"],
min=df_stats.loc["min"],
p_25=df_stats.loc["25%"],
p_50=df_stats.loc["50%"],
p_75=df_stats.loc["75%"],
max=df_stats.loc["max"],
)
return OBBject(results=results)

View File

@@ -0,0 +1,54 @@
"""Views for the Quantitative Extension."""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from openbb_charting.core.openbb_figure import OpenBBFigure
class QuantitativeViews:
"""Quantitative Views."""
@staticmethod
def quantitative_factors(
**kwargs,
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Factor Regression Coefficient / P-Value Heatmap."""
# pylint: disable=import-outside-toplevel
from openbb_quantitative.views.factors_heatmap import factors_heatmap
return factors_heatmap(**kwargs)
@staticmethod
def quantitative_risk_decomposition(
**kwargs,
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Variance-Share Decomposition Stacked Bar."""
# pylint: disable=import-outside-toplevel
from openbb_quantitative.views.risk_decomposition_bar import (
risk_decomposition_bar,
)
return risk_decomposition_bar(**kwargs)
@staticmethod
def quantitative_attribution(
**kwargs,
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Return Attribution Stacked Bar."""
# pylint: disable=import-outside-toplevel
from openbb_quantitative.views.attribution_bar import attribution_bar
return attribution_bar(**kwargs)
@staticmethod
def quantitative_rolling_factors(
**kwargs,
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Rolling Factor Beta Line Chart."""
# pylint: disable=import-outside-toplevel
from openbb_quantitative.views.rolling_factors_line import (
rolling_factors_line,
)
return rolling_factors_line(**kwargs)

View File

@@ -0,0 +1,146 @@
"""Quantitative variance-share decomposition commands."""
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
from openbb_quantitative._factor_helpers import (
_DEFAULT_PERIODS,
align_inputs,
period_start,
)
router = Router(prefix="", description="Variance-share decomposition commands.")
_RESIDUAL_LABEL = "Residual"
class RiskDecompositionQueryParams(QueryParams):
"""Query parameters for the risk_decomposition endpoint."""
__category__ = "quantitative"
__output_columns__ = ("period", "factor", "contribution", "share")
data: list[Data] = Field(
description="Target time series (index column plus the target column)."
)
factors_data: list[Data] = Field(
description="Factor matrix (index column plus one column per factor)."
)
target: str = Field(
default="close",
description="Name of the column in `data` to decompose against the factors.",
)
index: str = Field(
default="date",
description="Name of the index column shared by `data` and `factors_data`.",
)
risk_free_column: str | None = Field(
default=None,
description="Optional name of the risk-free rate column in `factors_data`."
" When provided, the target is converted to excess returns before"
" decomposition and the column is dropped from the factor set.",
)
periods: list[str] = Field(
default_factory=lambda: list(_DEFAULT_PERIODS),
description="Named look-back periods to decompose over, anchored to the"
" latest factor observation.",
)
class RiskDecompositionData(Data):
"""One variance-share observation for a given look-back period."""
period: str = Field(description="Look-back period the decomposition was run over.")
factor: str = Field(
description="Factor name, or 'Residual' for the idiosyncratic component."
)
contribution: float = Field(
description="Covariance-based contribution to Var(target): for a factor,"
" beta_i * Cov(factor_i, target); for 'Residual', Var(target) * (1 - R^2)."
)
share: float = Field(
description="Fraction of Var(target) attributed to this factor (or to the"
" residual). Factor shares sum to R^2; the residual share equals 1 - R^2."
)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data("timeseries", 800),
"factors_data": APIEx.mock_data("timeseries", 800),
}
)
],
)
def risk_decomposition(
params: RiskDecompositionQueryParams,
) -> OBBject[list[RiskDecompositionData]]:
"""Decompose Var(target) into per-factor contributions plus residual variance.
For each look-back period, runs OLS of the target on the factor matrix and
splits the target's variance via the additive identity
`Var(target) = sum_i beta_i * Cov(factor_i, target) + Var(residual)`.
Returned shares sum to 1 per period: factor shares sum to R^2 and the residual
row holds `1 - R^2`. Handles correlated factors cleanly because each share is
measured by the covariance with the target rather than by the factor's
standalone variance.
"""
import statsmodels.api as sm
factor_matrix, target_series, factor_cols = align_inputs(
params.data,
params.factors_data,
target=params.target,
index=params.index,
risk_free_column=params.risk_free_column,
)
aligned = factor_matrix.assign(**{params.target: target_series})
max_date = aligned.index.max()
out: list[RiskDecompositionData] = []
for period in params.periods:
start = period_start(max_date, period)
window = aligned if start is None else aligned[aligned.index >= start]
if len(window) <= len(factor_cols) + 1:
continue
y = window[params.target]
var_y = float(y.var())
if var_y == 0.0:
continue
x = sm.add_constant(window[factor_cols])
model = sm.OLS(y, x).fit()
residual_var = float(model.resid.var())
for name in factor_cols:
beta = float(model.params[name])
cov = float(window[name].cov(y))
contribution = beta * cov
out.append(
RiskDecompositionData(
period=period,
factor=name,
contribution=contribution,
share=contribution / var_y,
)
)
out.append(
RiskDecompositionData(
period=period,
factor=_RESIDUAL_LABEL,
contribution=residual_var,
share=residual_var / var_y,
)
)
return OBBject(results=out)

View File

@@ -0,0 +1,511 @@
"""Quantitative rolling-statistics commands."""
from datetime import (
date as dateType,
datetime,
)
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, PositiveInt
router = Router(
prefix="/rolling", description="Quantitative rolling-statistics commands."
)
class RollingSkewQueryParams(QueryParams):
"""Query parameters for the rolling skew endpoint."""
__category__ = "rolling"
__output_columns__ = ("date", "skew")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
window: PositiveInt = Field(
default=21, description="Number of observations in each rolling window."
)
index: str = Field(default="date", description="Name of the index column.")
class RollingSkewData(Data):
"""One rolling skew observation."""
date: datetime | dateType | str = Field(description="Observation date.")
skew: float = Field(description="Rolling skew value.")
class RollingVarianceQueryParams(QueryParams):
"""Query parameters for the rolling variance endpoint."""
__category__ = "rolling"
__output_columns__ = ("date", "variance")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
window: PositiveInt = Field(
default=21, description="Number of observations in each rolling window."
)
index: str = Field(default="date", description="Name of the index column.")
class RollingVarianceData(Data):
"""One rolling variance observation."""
date: datetime | dateType | str = Field(description="Observation date.")
variance: float = Field(description="Rolling variance value.")
class RollingStdevQueryParams(QueryParams):
"""Query parameters for the rolling standard deviation endpoint."""
__category__ = "rolling"
__output_columns__ = ("date", "stdev")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
window: PositiveInt = Field(
default=21, description="Number of observations in each rolling window."
)
index: str = Field(default="date", description="Name of the index column.")
class RollingStdevData(Data):
"""One rolling standard deviation observation."""
date: datetime | dateType | str = Field(description="Observation date.")
stdev: float = Field(description="Rolling standard deviation value.")
class RollingKurtosisQueryParams(QueryParams):
"""Query parameters for the rolling kurtosis endpoint."""
__category__ = "rolling"
__output_columns__ = ("date", "kurtosis")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
window: PositiveInt = Field(
default=21, description="Number of observations in each rolling window."
)
index: str = Field(default="date", description="Name of the index column.")
class RollingKurtosisData(Data):
"""One rolling kurtosis observation."""
date: datetime | dateType | str = Field(description="Observation date.")
kurtosis: float = Field(description="Rolling kurtosis value.")
class RollingMeanQueryParams(QueryParams):
"""Query parameters for the rolling mean endpoint."""
__category__ = "rolling"
__output_columns__ = ("date", "mean")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
window: PositiveInt = Field(
default=21, description="Number of observations in each rolling window."
)
index: str = Field(default="date", description="Name of the index column.")
class RollingMeanData(Data):
"""One rolling mean observation."""
date: datetime | dateType | str = Field(description="Observation date.")
mean: float = Field(description="Rolling mean value.")
class RollingQuantileQueryParams(QueryParams):
"""Query parameters for the rolling quantile endpoint."""
__category__ = "rolling"
__output_columns__ = ("date", "median", "quantile")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
window: PositiveInt = Field(
default=21, description="Number of observations in each rolling window."
)
quantile_pct: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description="Quantile to compute, between 0 and 1.",
)
index: str = Field(default="date", description="Name of the index column.")
class RollingQuantileData(Data):
"""One rolling quantile observation."""
date: datetime | dateType | str = Field(description="Observation date.")
median: float = Field(description="Rolling median value.")
quantile: float = Field(description="Rolling quantile value.")
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"window": 20,
"data": APIEx.mock_data("timeseries", 60),
}
)
],
)
def skew(params: RollingSkewQueryParams) -> OBBject[list[RollingSkewData]]:
"""Calculate the rolling skew of a target column over a moving window.
Skew measures the asymmetry of a distribution about its mean. Positive skew
indicates a longer right tail, while negative skew indicates a longer left tail.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from pandas import DataFrame
from openbb_quantitative._stats_helpers import skew_
from openbb_quantitative.helpers import validate_window
df = basemodel_to_df(params.data, index=params.index)
series = get_target_column(df, params.target)
validate_window(series, params.window)
rolled = series.rolling(params.window).apply(skew_).dropna()
frame = DataFrame({"date": rolled.index, "skew": rolled.to_numpy()})
out = [
RollingSkewData(date=record["date"], skew=float(record["skew"]))
for record in frame.to_dict(orient="records")
]
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"window": 20,
"data": APIEx.mock_data("timeseries", 60),
}
)
],
)
def variance(
params: RollingVarianceQueryParams,
) -> OBBject[list[RollingVarianceData]]:
"""Calculate the rolling variance of a target column over a moving window.
Variance measures the dispersion of a set of data points around their mean. It is
a key metric for assessing the volatility and stability of a time series.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from pandas import DataFrame
from openbb_quantitative._stats_helpers import var_
from openbb_quantitative.helpers import validate_window
df = basemodel_to_df(params.data, index=params.index)
series = get_target_column(df, params.target)
validate_window(series, params.window)
rolled = series.rolling(params.window).apply(var_).dropna()
frame = DataFrame({"date": rolled.index, "variance": rolled.to_numpy()})
out = [
RollingVarianceData(date=record["date"], variance=float(record["variance"]))
for record in frame.to_dict(orient="records")
]
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"window": 20,
"data": APIEx.mock_data("timeseries", 60),
}
)
],
)
def stdev(params: RollingStdevQueryParams) -> OBBject[list[RollingStdevData]]:
"""Calculate the rolling standard deviation of a target column over a moving window.
Standard deviation measures the amount of variation or dispersion of a set of
values. It is the square root of the variance and is widely used to assess risk.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from pandas import DataFrame
from openbb_quantitative._stats_helpers import std_dev_
from openbb_quantitative.helpers import validate_window
df = basemodel_to_df(params.data, index=params.index)
series = get_target_column(df, params.target)
validate_window(series, params.window)
rolled = series.rolling(params.window).apply(std_dev_).dropna()
frame = DataFrame({"date": rolled.index, "stdev": rolled.to_numpy()})
out = [
RollingStdevData(date=record["date"], stdev=float(record["stdev"]))
for record in frame.to_dict(orient="records")
]
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"window": 20,
"data": APIEx.mock_data("timeseries", 60),
}
)
],
)
def kurtosis(
params: RollingKurtosisQueryParams,
) -> OBBject[list[RollingKurtosisData]]:
"""Calculate the rolling kurtosis of a target column over a moving window.
Kurtosis measures the tailedness of a distribution. High kurtosis indicates heavy
tails and a higher risk of extreme outcomes, while low kurtosis indicates the
opposite.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from pandas import DataFrame
from openbb_quantitative._stats_helpers import kurtosis_
from openbb_quantitative.helpers import validate_window
df = basemodel_to_df(params.data, index=params.index)
series = get_target_column(df, params.target)
validate_window(series, params.window)
rolled = series.rolling(params.window).apply(kurtosis_).dropna()
frame = DataFrame({"date": rolled.index, "kurtosis": rolled.to_numpy()})
out = [
RollingKurtosisData(date=record["date"], kurtosis=float(record["kurtosis"]))
for record in frame.to_dict(orient="records")
]
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"window": 20,
"data": APIEx.mock_data("timeseries", 60),
}
)
],
)
def mean(params: RollingMeanQueryParams) -> OBBject[list[RollingMeanData]]:
"""Calculate the rolling mean of a target column over a moving window.
The rolling mean is a simple moving average that smooths short-term fluctuations
and highlights longer-term trends or cycles in a time series.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from pandas import DataFrame
from openbb_quantitative._stats_helpers import mean_
from openbb_quantitative.helpers import validate_window
df = basemodel_to_df(params.data, index=params.index)
series = get_target_column(df, params.target)
validate_window(series, params.window)
rolled = series.rolling(params.window).apply(mean_).dropna()
frame = DataFrame({"date": rolled.index, "mean": rolled.to_numpy()})
out = [
RollingMeanData(date=record["date"], mean=float(record["mean"]))
for record in frame.to_dict(orient="records")
]
return OBBject(results=out)
@router.command(
methods=["POST"],
examples=[
APIEx(
parameters={
"target": "close",
"window": 20,
"data": APIEx.mock_data("timeseries", 60),
}
)
],
)
def quantile(
params: RollingQuantileQueryParams,
) -> OBBject[list[RollingQuantileData]]:
"""Calculate the rolling quantile of a target column over a moving window.
Quantiles divide the range of a distribution into intervals with equal
probabilities. This command returns the rolling median alongside the requested
quantile for analyzing trends, outliers, and risk.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from pandas import concat
from openbb_quantitative.helpers import validate_window
df = basemodel_to_df(params.data, index=params.index)
series = get_target_column(df, params.target)
validate_window(series, params.window)
roll = series.rolling(params.window)
result = (
concat(
[
roll.median().rename("median"),
roll.quantile(params.quantile_pct).rename("quantile"),
],
axis=1,
)
.dropna()
.reset_index()
)
out = [
RollingQuantileData(
date=r["date"], median=float(r["median"]), quantile=float(r["quantile"])
)
for r in result.to_dict(orient="records")
]
return OBBject(results=out)
class RollingFactorsQueryParams(QueryParams):
"""Query parameters for the rolling factors endpoint."""
__category__ = "rolling"
__output_columns__ = ("date", "factor", "coefficient", "t_statistic")
data: list[Data] = Field(
description="Target time series (index column plus the target column)."
)
factors_data: list[Data] = Field(
description="Factor matrix (index column plus one column per factor)."
)
target: str = Field(
default="close",
description="Name of the column in `data` to regress on the factor matrix.",
)
index: str = Field(
default="date",
description="Name of the index column shared by `data` and `factors_data`.",
)
risk_free_column: str | None = Field(
default=None,
description="Optional name of the risk-free rate column in `factors_data`."
" When provided, the target is regressed in excess form and the column is"
" dropped from the regressors.",
)
window: PositiveInt = Field(
default=252,
description="Number of observations in each rolling regression window.",
)
step: PositiveInt = Field(
default=21,
description="Stride between successive windows in observations. A value of"
" 1 refits at every observation; larger values reduce cost on long histories.",
)
class RollingFactorsData(Data):
"""One rolling factor coefficient observation."""
date: datetime | dateType | str = Field(
description="End date of the rolling window."
)
factor: str = Field(
description="Factor name (or 'const' for the regression intercept)."
)
coefficient: float = Field(
description="OLS coefficient estimate for the factor at this window end."
)
t_statistic: float = Field(
description="t-statistic for the coefficient at this window end."
)
@router.command(
methods=["POST"],
examples=[
APIEx(
description="Roll a 1-year window stepping monthly across a multi-factor"
" regression of `close` on the supplied factor matrix.",
parameters={
"target": "close",
"window": 252,
"step": 21,
"data": APIEx.mock_data("timeseries", 800),
"factors_data": APIEx.mock_data("timeseries", 800),
},
)
],
)
def factors(
params: RollingFactorsQueryParams,
) -> OBBject[list[RollingFactorsData]]:
"""Refit factor OLS on a rolling window and emit per-factor betas through time.
For each window end at stride `step`, runs OLS of the target on the factor
matrix using the trailing `window` observations and records each factor's
coefficient and t-statistic. Suitable for visualizing time-varying factor
exposures and detecting regime shifts that named-window decompositions hide.
"""
import statsmodels.api as sm
from openbb_quantitative._factor_helpers import align_inputs
factor_matrix, target_series, factor_cols = align_inputs(
params.data,
params.factors_data,
target=params.target,
index=params.index,
risk_free_column=params.risk_free_column,
)
aligned = factor_matrix.assign(**{params.target: target_series})
if len(aligned) < params.window:
raise ValueError(
f"Rolling window '{params.window}' exceeds the aligned data length"
f" '{len(aligned)}'."
)
min_obs = len(factor_cols) + 1
if params.window <= min_obs:
raise ValueError(
f"Rolling window '{params.window}' must exceed the number of regressors"
f" plus the intercept ({min_obs})."
)
out: list[RollingFactorsData] = []
end_positions = range(params.window, len(aligned) + 1, params.step)
for end in end_positions:
slice_ = aligned.iloc[end - params.window : end]
x = sm.add_constant(slice_[factor_cols])
model = sm.OLS(slice_[params.target], x).fit()
window_end = slice_.index[-1]
for name in model.params.index:
out.append(
RollingFactorsData(
date=window_end,
factor=str(name),
coefficient=float(model.params[name]),
t_statistic=float(model.tvalues[name]),
)
)
return OBBject(results=out)

View File

@@ -1,448 +0,0 @@
"""Rolling submenu of quantitative models for rolling statistics."""
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 pydantic import NonNegativeFloat, PositiveInt
router = Router(prefix="/rolling")
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Rolling Mean.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.rolling.skew(data=returns, target="close")',
],
),
APIEx(
parameters={
"target": "close",
"window": 2,
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def skew(
data: list[Data], target: str, window: PositiveInt = 21, index: str = "date"
) -> OBBject[list[Data]]:
"""Get Rolling Skew.
Skew is a statistical measure that reveals the degree of asymmetry of a distribution around its mean.
Positive skewness indicates a distribution with an extended tail to the right, while negative skewness shows a tail
that stretches left. Understanding skewness can provide insights into potential biases in data and help anticipate
the nature of future data points. It's particularly useful for identifying the likelihood of extreme outcomes in
financial returns, enabling more informed decision-making based on the distribution's shape over a specified period.
Parameters
----------
data : list[Data]
Time series data.
target : str
Target column name.
window : PositiveInt
Window size.
index : str, optional
Index column name, by default "date"
Returns
-------
OBBject[list[Data]]
Rolling skew.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.helpers import validate_window
from openbb_quantitative.statistics import skew_
df = basemodel_to_df(data, index=index)
series_target = get_target_column(df, target)
series_target.name = f"rolling_skew_{window}"
validate_window(series_target, window)
results = (
series_target.rolling(window).apply(skew_).dropna().reset_index(drop=False)
)
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Rolling Variance.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.rolling.variance(data=returns, target="close", window=252)',
],
),
APIEx(
parameters={
"target": "close",
"window": 2,
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def variance(
data: list[Data], target: str, window: PositiveInt = 21, index: str = "date"
) -> OBBject[list[Data]]:
"""
Calculate the rolling variance of a target column within a given window size.
Variance measures the dispersion of a set of data points around their mean. It is a key metric for
assessing the volatility and stability of financial returns or other time series data over a specified rolling window.
Parameters
----------
data: list[Data]
The time series data as a list of data points.
target: str
The name of the column for which to calculate variance.
window: PositiveInt
The number of observations used for calculating the rolling measure.
index: str, optional
The name of the index column, default is "date".
Returns
-------
OBBject[list[Data]]
An object containing the rolling variance values.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.helpers import validate_window
from openbb_quantitative.statistics import var_
df = basemodel_to_df(data, index=index)
series_target = get_target_column(df, target)
series_target.name = f"rolling_var_{window}"
validate_window(series_target, window)
results = series_target.rolling(window).apply(var_).dropna().reset_index(drop=False)
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Rolling Standard Deviation.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.rolling.stdev(data=returns, target="close", window=252)',
],
),
APIEx(
parameters={
"target": "close",
"window": 2,
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def stdev(
data: list[Data], target: str, window: PositiveInt = 21, index: str = "date"
) -> OBBject[list[Data]]:
"""
Calculate the rolling standard deviation of a target column within a given window size.
Standard deviation is a measure of the amount of variation or dispersion of a set of values.
It is widely used to assess the risk and volatility of financial returns or other time series data
over a specified rolling window. It is the square root of the variance.
Parameters
----------
data: list[Data]
The time series data as a list of data points.
target: str
The name of the column for which to calculate standard deviation.
window: PositiveInt
The number of observations used for calculating the rolling measure.
index: str, optional
The name of the index column, default is "date".
Returns
-------
OBBject[list[Data]]
An object containing the rolling standard deviation values.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.helpers import validate_window
from openbb_quantitative.statistics import std_dev_
df = basemodel_to_df(data, index=index)
series_target = get_target_column(df, target)
series_target.name = f"rolling_stdev_{window}"
validate_window(series_target, window)
results = (
series_target.rolling(window).apply(std_dev_).dropna().reset_index(drop=False)
)
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Rolling Kurtosis.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.rolling.kurtosis(data=returns, target="close", window=252)',
],
),
APIEx(
parameters={
"target": "close",
"window": 2,
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def kurtosis(
data: list[Data], target: str, window: PositiveInt = 21, index: str = "date"
) -> OBBject[list[Data]]:
"""
Calculate the rolling kurtosis of a target column within a given window size.
Kurtosis measures the "tailedness" of the probability distribution of a real-valued random variable.
High kurtosis indicates a distribution with heavy tails (outliers), suggesting a higher risk of extreme outcomes.
Low kurtosis indicates a distribution with lighter tails (less outliers), suggesting less risk of extreme outcomes.
This function helps in assessing the risk of outliers in financial returns or other time series data over a specified
rolling window.
Parameters
----------
data: list[Data]
The time series data as a list of data points.
target: str
The name of the column for which to calculate kurtosis.
window: PositiveInt
The number of observations used for calculating the rolling measure.
index: str, optional
The name of the index column, default is "date".
Returns
-------
OBBject[list[Data]]
An object containing the rolling kurtosis values.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.helpers import validate_window
from openbb_quantitative.statistics import kurtosis_
df = basemodel_to_df(data, index=index)
series_target = get_target_column(df, target)
series_target.name = f"rolling_kurtosis_{window}"
validate_window(series_target, window)
results = (
series_target.rolling(window).apply(kurtosis_).dropna().reset_index(drop=False)
)
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Rolling Quantile.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.rolling.quantile(data=returns, target="close", window=252, quantile_pct=0.25)',
'obb.quantitative.rolling.quantile(data=returns, target="close", window=252, quantile_pct=0.75)',
],
),
APIEx(
parameters={
"target": "close",
"window": 2,
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def quantile(
data: list[Data],
target: str,
window: PositiveInt = 21,
quantile_pct: NonNegativeFloat = 0.5,
index: str = "date",
) -> OBBject[list[Data]]:
"""
Calculate the rolling quantile of a target column within a given window size at a specified quantile percentage.
Quantiles are points dividing the range of a probability distribution into intervals with equal probabilities,
or dividing the sample in the same way. This function is useful for understanding the distribution of data
within a specified window, allowing for analysis of trends, identification of outliers, and assessment of risk.
Parameters
----------
data: list[Data]
The time series data as a list of data points.
target: str
The name of the column for which to calculate the quantile.
window: PositiveInt
The number of observations used for calculating the rolling measure.
quantile_pct: NonNegativeFloat, optional
The quantile percentage to calculate (e.g., 0.5 for median), default is 0.5.
index: str, optional
The name of the index column, default is "date".
Returns
-------
OBBject[list[Data]]
An object containing the rolling quantile values with the median.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.helpers import validate_window
from pandas import concat
df = basemodel_to_df(data, index=index)
series_target = get_target_column(df, target)
validate_window(series_target, window)
roll = series_target.rolling(window)
df_median = roll.median()
df_quantile = roll.quantile(quantile_pct)
results = (
concat(
[df_median, df_quantile],
axis=1,
keys=[
f"rolling_median_{window}",
f"rolling_quantile_{quantile_pct}_{window}",
],
)
.dropna()
.reset_index(drop=False)
)
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Rolling Mean.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.rolling.mean(data=returns, target="close", window=252)',
],
),
APIEx(
parameters={
"target": "close",
"window": 2,
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def mean(
data: list[Data], target: str, window: PositiveInt = 21, index: str = "date"
) -> OBBject[list[Data]]:
"""Calculate the rolling average of a target column within a given window size.
The rolling mean is a simple moving average that calculates the average of a target variable over a specified window.
This function is widely used in financial analysis to smooth short-term fluctuations and highlight longer-term trends
or cycles in time series data.
Parameters
----------
data: list[Data]
The time series data as a list of data points.
target: str
The name of the column for which to calculate the mean.
window: PositiveInt
The number of observations used for calculating the rolling measure.
index: str, optional
The name of the index column, default is "date".
Returns
-------
OBBject[list[Data]]
An object containing the rolling mean values.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.helpers import validate_window
from openbb_quantitative.statistics import mean_
df = basemodel_to_df(data, index=index)
series_target = get_target_column(df, target)
series_target.name = f"rolling_mean_{window}"
validate_window(series_target, window)
results = (
series_target.rolling(window).apply(mean_).dropna().reset_index(drop=False)
)
results = df_to_basemodel(results)
return OBBject(results=results)

View File

@@ -0,0 +1,24 @@
"""Top-level router for the openbb-quantitative extension."""
from openbb_core.app.router import Router
from openbb_quantitative.attribution import router as _attribution_router
from openbb_quantitative.factors import router as _factors_router
from openbb_quantitative.metrics import router as _metrics_router
from openbb_quantitative.performance import router as _performance_router
from openbb_quantitative.risk_decomposition import (
router as _risk_decomposition_router,
)
from openbb_quantitative.rolling import router as _rolling_router
from openbb_quantitative.stats import router as _stats_router
router = Router(prefix="", description="Quantitative analysis tools.")
router.include_router(_metrics_router)
router.include_router(_rolling_router)
router.include_router(_stats_router)
router.include_router(_performance_router)
router.include_router(_factors_router)
router.include_router(_risk_decomposition_router)
router.include_router(_attribution_router)
__all__ = ["router"]

View File

@@ -1,45 +0,0 @@
"""Statistics Functions."""
from numpy import (
mean as mean_np,
ndarray,
std,
var as var_np,
)
from pandas import DataFrame, Series
from scipy import stats
# Because python is weird and these being the same name as the fastapi router functions
# which overwrites the function signature, we add the _ after the function name
def kurtosis_(data: DataFrame | Series | ndarray) -> float:
"""Get Kurtosis.
It is a measure of the "tailedness" of the probability distribution of a real-valued random variable.
"""
return stats.kurtosis(data)
def skew_(data: DataFrame | Series | ndarray) -> float:
"""Get Skewness.
It is a measure of the asymmetry of the probability distribution of a
real-valued random variable about its mean.
"""
return stats.skew(data)
def mean_(data: DataFrame | Series | ndarray) -> float:
"""Get Mean which is the average of the numbers."""
return mean_np(data)
def std_dev_(data: DataFrame | Series | ndarray) -> float:
"""Get Standard deviation that is a measure of the amount of variation or dispersion of a set of values."""
return std(data)
def var_(data: DataFrame | Series | ndarray) -> float:
"""Get Variance that is a measure of the amount of variation or dispersion of a set of values."""
return var_np(data)

View File

@@ -0,0 +1,243 @@
"""Quantitative statistics commands - summary statistics of a single series."""
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="/stats", description="Quantitative statistics commands.")
class StatsSkewQueryParams(QueryParams):
"""Query parameters for the skew endpoint."""
__category__ = "stats"
__output_columns__ = ("skew",)
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
class StatsSkewData(Data):
"""Skewness of the analyzed series."""
skew: float = Field(description="Skewness of the distribution.")
class StatsVarianceQueryParams(QueryParams):
"""Query parameters for the variance endpoint."""
__category__ = "stats"
__output_columns__ = ("variance",)
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
class StatsVarianceData(Data):
"""Variance of the analyzed series."""
variance: float = Field(description="Variance of the distribution.")
class StatsStdevQueryParams(QueryParams):
"""Query parameters for the stdev endpoint."""
__category__ = "stats"
__output_columns__ = ("stdev",)
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
class StatsStdevData(Data):
"""Standard deviation of the analyzed series."""
stdev: float = Field(description="Standard deviation of the distribution.")
class StatsKurtosisQueryParams(QueryParams):
"""Query parameters for the kurtosis endpoint."""
__category__ = "stats"
__output_columns__ = ("kurtosis",)
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
class StatsKurtosisData(Data):
"""Kurtosis of the analyzed series."""
kurtosis: float = Field(description="Kurtosis of the distribution.")
class StatsMeanQueryParams(QueryParams):
"""Query parameters for the mean endpoint."""
__category__ = "stats"
__output_columns__ = ("mean",)
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
class StatsMeanData(Data):
"""Arithmetic mean of the analyzed series."""
mean: float = Field(description="Arithmetic mean of the distribution.")
class StatsQuantileQueryParams(QueryParams):
"""Query parameters for the quantile endpoint."""
__category__ = "stats"
__output_columns__ = ("median", "quantile")
data: list[Data] = Field(description="Input dataset.")
target: str = Field(description="Name of the column to analyze.")
quantile_pct: float = Field(
default=0.5,
ge=0.0,
le=1.0,
description="Quantile to compute, between 0 and 1.",
)
class StatsQuantileData(Data):
"""Quantile of the analyzed series."""
median: float = Field(description="Median (0.5 quantile) of the distribution.")
quantile: float = Field(description="Value at the requested quantile.")
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries")})
],
)
def skew(params: StatsSkewQueryParams) -> OBBject[StatsSkewData]:
"""Compute the skewness of a target column.
Skewness measures the degree of asymmetry of a distribution around its mean.
Positive skewness indicates a longer right tail, while negative skewness indicates
a longer left tail.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from openbb_quantitative._stats_helpers import skew_
series = get_target_column(basemodel_to_df(params.data), params.target)
return OBBject(results=StatsSkewData(skew=float(skew_(series))))
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries")})
],
)
def variance(params: StatsVarianceQueryParams) -> OBBject[StatsVarianceData]:
"""Compute the variance of a target column.
Variance measures the dispersion of a set of data points around their mean. It is a
key metric for assessing the volatility and stability of a time series.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from openbb_quantitative._stats_helpers import var_
series = get_target_column(basemodel_to_df(params.data), params.target)
return OBBject(results=StatsVarianceData(variance=float(var_(series))))
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries")})
],
)
def stdev(params: StatsStdevQueryParams) -> OBBject[StatsStdevData]:
"""Compute the standard deviation of a target column.
Standard deviation measures the amount of variation or dispersion of a set of
values and is the square root of the variance. It is widely used to assess risk
and volatility.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from openbb_quantitative._stats_helpers import std_dev_
series = get_target_column(basemodel_to_df(params.data), params.target)
return OBBject(results=StatsStdevData(stdev=float(std_dev_(series))))
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries")})
],
)
def kurtosis(params: StatsKurtosisQueryParams) -> OBBject[StatsKurtosisData]:
"""Compute the kurtosis of a target column.
Kurtosis measures the tailedness of a distribution. High kurtosis indicates heavy
tails and a higher risk of extreme outcomes, while low kurtosis indicates lighter
tails.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from openbb_quantitative._stats_helpers import kurtosis_
series = get_target_column(basemodel_to_df(params.data), params.target)
return OBBject(results=StatsKurtosisData(kurtosis=float(kurtosis_(series))))
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries")})
],
)
def mean(params: StatsMeanQueryParams) -> OBBject[StatsMeanData]:
"""Compute the arithmetic mean of a target column.
The mean is the average of a set of values and is widely used to summarize the
central tendency of a time series.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
from openbb_quantitative._stats_helpers import mean_
series = get_target_column(basemodel_to_df(params.data), params.target)
return OBBject(results=StatsMeanData(mean=float(mean_(series))))
@router.command(
methods=["POST"],
examples=[
APIEx(parameters={"target": "close", "data": APIEx.mock_data("timeseries")})
],
)
def quantile(params: StatsQuantileQueryParams) -> OBBject[StatsQuantileData]:
"""Compute the quantile of a target column at a specified percentage.
Quantiles are points dividing the range of a distribution into intervals with equal
probabilities. The median (0.5 quantile) is returned alongside the requested
quantile.
"""
from openbb_core.app.utils import basemodel_to_df, get_target_column
series = get_target_column(basemodel_to_df(params.data), params.target)
median = float(series.median())
quantile = float(series.quantile(params.quantile_pct))
return OBBject(results=StatsQuantileData(median=median, quantile=quantile))

View File

@@ -1,372 +0,0 @@
"""Rolling submenu of quantitative models for rolling statistics."""
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 pydantic import NonNegativeFloat
router = Router(prefix="/stats")
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Skewness.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.stats.skew(data=returns, target="close")',
],
),
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def skew(
data: list[Data],
target: str,
) -> OBBject[list[Data]]:
"""Get the skew of the data set.
Skew is a statistical measure that reveals the degree of asymmetry of a distribution around its mean.
Positive skewness indicates a distribution with an extended tail to the right, while negative skewness shows a tail
that stretches left. Understanding skewness can provide insights into potential biases in data and help anticipate
the nature of future data points. It's particularly useful for identifying the likelihood of extreme outcomes in
financial returns, enabling more informed decision-making based on the distribution's shape over a specified period.
Parameters
----------
data : list[Data]
Time series data.
target : str
Target column name.
Returns
-------
OBBject[list[Data]]
Rolling skew.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.statistics import skew_
from pandas import DataFrame
df = basemodel_to_df(data)
series_target = get_target_column(df, target)
results = DataFrame([skew_(series_target)], columns=["skew"])
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Variance.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.stats.variance(data=returns, target="close")',
],
),
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def variance(data: list[Data], target: str) -> OBBject[list[Data]]:
"""Calculate the variance of a target column.
Variance measures the dispersion of a set of data points around their mean. It is a key metric for
assessing the volatility and stability of financial returns or other time series data.
Parameters
----------
data: list[Data]
The time series data as a list of data points.
target: str
The name of the column for which to calculate variance.
Returns
-------
OBBject[list[Data]]
An object containing the rolling variance values.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.statistics import var_
from pandas import DataFrame
df = basemodel_to_df(data)
series_target = get_target_column(df, target)
results = DataFrame([var_(series_target)], columns=["variance"])
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Standard Deviation.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.stats.stdev(data=returns, target="close")',
],
),
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def stdev(data: list[Data], target: str) -> OBBject[list[Data]]:
"""Calculate the rolling standard deviation of a target column.
Standard deviation is a measure of the amount of variation or dispersion of a set of values.
It is widely used to assess the risk and volatility of financial returns or other time series data
It is the square root of the variance.
Parameters
----------
data: list[Data]
The time series data as a list of data points.
target: str
The name of the column for which to calculate standard deviation.
Returns
-------
OBBject[list[Data]]
An object containing the rolling standard deviation values.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.statistics import std_dev_
from pandas import DataFrame
df = basemodel_to_df(data)
series_target = get_target_column(df, target)
results = DataFrame([std_dev_(series_target)], columns=["stdev"])
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Kurtosis.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.stats.kurtosis(data=returns, target="close")',
],
),
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def kurtosis(data: list[Data], target: str) -> OBBject[list[Data]]:
"""Calculate the rolling kurtosis of a target column.
Kurtosis measures the "tailedness" of the probability distribution of a real-valued random variable.
High kurtosis indicates a distribution with heavy tails (outliers), suggesting a higher risk of extreme outcomes.
Low kurtosis indicates a distribution with lighter tails (less outliers), suggesting less risk of extreme outcomes.
This function helps in assessing the risk of outliers in financial returns or other time series data.
Parameters
----------
data: list[Data]
The time series data as a list of data points.
target: str
The name of the column for which to calculate kurtosis.
Returns
-------
OBBject[list[Data]]
An object containing the kurtosis value
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.statistics import kurtosis_
from pandas import DataFrame
df = basemodel_to_df(data)
series_target = get_target_column(df, target)
results = DataFrame([kurtosis_(series_target)], columns=["kurtosis"])
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Quantile.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.stats.quantile(data=returns, target="close", quantile_pct=0.75)',
],
),
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def quantile(
data: list[Data],
target: str,
quantile_pct: NonNegativeFloat = 0.5,
) -> OBBject[list[Data]]:
"""Calculate the quantile of a target column at a specified quantile percentage.
Quantiles are points dividing the range of a probability distribution into intervals with equal probabilities,
or dividing the sample in the same way.
Parameters
----------
data: list[Data]
The time series data as a list of data points.
target: str
The name of the column for which to calculate the quantile.
quantile_pct: NonNegativeFloat, optional
The quantile percentage to calculate (e.g., 0.5 for median), default is 0.5.
Returns
-------
OBBject[list[Data]]
An object containing the rolling quantile values with the median.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from pandas import DataFrame
df = basemodel_to_df(
data,
)
series_target = get_target_column(df, target)
results = DataFrame(
[series_target.quantile(quantile_pct)], columns=[f"{quantile_pct}_quantile"]
)
results = df_to_basemodel(results)
return OBBject(results=results)
@router.command(
methods=["POST"],
examples=[
PythonEx(
description="Get Mean.",
code=[
'stock_data = obb.equity.price.historical(symbol="TSLA", start_date="2023-01-01", provider="fmp").to_df()', # noqa: E501
'returns = stock_data["close"].pct_change().dropna()',
'obb.quantitative.stats.mean(data=returns, target="close")',
],
),
APIEx(
parameters={
"target": "close",
"data": APIEx.mock_data(
"timeseries",
sample={"date": "2023-01-01", "close": 0.05},
),
}
),
],
)
def mean(
data: list[Data],
target: str,
) -> OBBject[list[Data]]:
"""Calculate the average of a target column.
The rolling mean is a simple moving average that calculates the average of a target variable.
This function is widely used in financial analysis to smooth short-term fluctuations and highlight longer-term trends
or cycles in time series data.
Parameters
----------
data: list[Data]
The time series data as a list of data points.
target: str
The name of the column for which to calculate the mean.
Returns
-------
OBBject[list[Data]]
An object containing the mean value.
"""
# pylint: disable=import-outside-toplevel
from openbb_core.app.utils import (
basemodel_to_df,
df_to_basemodel,
get_target_column,
)
from openbb_quantitative.statistics import mean_
from pandas import DataFrame
df = basemodel_to_df(data)
series_target = get_target_column(df, target)
results = DataFrame([mean_(series_target)], columns=["mean"])
results = df_to_basemodel(results)
return OBBject(results=results)

View File

@@ -0,0 +1 @@
"""Charting views for the openbb-quantitative extension."""

View File

@@ -0,0 +1,121 @@
"""Stacked horizontal bar chart for the attribution endpoint."""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from openbb_charting.core.openbb_figure import OpenBBFigure # noqa
_ALPHA_LABEL = "Alpha"
_RESIDUAL_LABEL = "Residual"
def attribution_bar(
**kwargs,
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Stacked horizontal bars of per-factor return share per period.
Consumes the row schema produced by `POST /quantitative/attribution`:
rows of (`period`, `factor`, `contribution`, `share`). Bars use `share`
so every period spans roughly -100% to +100% on the same x-axis;
the absolute contribution is shown in the segment text and hover.
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.core.openbb_figure import OpenBBFigure
from openbb_core.app.utils import basemodel_to_df
from pandas import DataFrame
from plotly.graph_objs import Bar, Figure, Layout
if "data" in kwargs and isinstance(kwargs["data"], DataFrame):
df = kwargs["data"]
elif "data" in kwargs and isinstance(kwargs["data"], list):
df = basemodel_to_df(kwargs["data"], index=None)
else:
df = basemodel_to_df(kwargs["obbject_item"], index=None)
required = {"period", "factor", "contribution", "share"}
missing = required - set(df.columns)
if missing:
raise ValueError(
f"attribution_bar requires columns {sorted(required)};"
f" missing {sorted(missing)}."
)
periods = list(dict.fromkeys(df["period"].tolist()))
all_factors = list(dict.fromkeys(df["factor"].tolist()))
fixed_tail = [f for f in (_ALPHA_LABEL, _RESIDUAL_LABEL) if f in all_factors]
leading = [f for f in all_factors if f not in fixed_tail]
factor_order = leading + fixed_tail
share_wide = (
df.pivot_table(index="period", columns="factor", values="share", sort=False)
.reindex(index=periods)
.reindex(columns=factor_order)
.fillna(0.0)
)
contrib_wide = (
df.pivot_table(
index="period", columns="factor", values="contribution", sort=False
)
.reindex(index=periods)
.reindex(columns=factor_order)
.fillna(0.0)
)
title = kwargs.get("title") or "Return Attribution (Share of Period Return)"
traces = []
for factor in factor_order:
shares = share_wide[factor].to_numpy()
contribs = contrib_wide[factor].to_numpy()
# Show the absolute contribution as cell text when it's a meaningful slice.
text = [f"{c:+.4f}" if abs(s) > 0.03 else "" for s, c in zip(shares, contribs)]
traces.append(
Bar(
name=factor,
x=shares,
y=periods,
orientation="h",
text=text,
textposition="inside",
insidetextanchor="middle",
customdata=contribs.reshape(-1, 1),
hovertemplate=(
f"{factor}<br>Period: %{{y}}<br>"
"Share: %{x:.1%}<br>"
"Contribution: %{customdata[0]:+.6f}<extra></extra>"
),
)
)
layout = Layout(
title_text=title,
title_x=0.5,
barmode="relative",
xaxis=dict(
tickformat=".0%",
zeroline=True,
zerolinewidth=2,
showgrid=False,
title="Share of Period Return",
),
yaxis=dict(autorange="reversed", showgrid=False, title=""),
legend=dict(
orientation="h",
y=-0.15,
yanchor="top",
x=0.5,
xanchor="center",
),
margin=dict(t=60, b=80, l=80, r=40),
dragmode="pan",
)
fig = Figure(data=traces, layout=layout)
figure = OpenBBFigure(fig=fig)
layout_kwargs = kwargs.get("layout_kwargs") or {}
if layout_kwargs:
figure.update_layout(**layout_kwargs)
content = figure.show(external=True).to_plotly_json()
return figure, content

View File

@@ -0,0 +1,116 @@
"""Factor-regression coefficient/p-value heatmap chart."""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from openbb_charting.core.openbb_figure import OpenBBFigure # noqa
_PVALUE_COLORSCALE = [
[0.0, "blue"],
[0.05, "cyan"],
[0.10, "orange"],
[0.25, "red"],
[1.0, "darkred"],
]
def factors_heatmap(
**kwargs,
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Coefficient heatmap colored by p-value, across regression look-back periods.
Consumes the row schema produced by `POST /quantitative/factors`:
one row per (`period`, `factor`) with `coefficient` and `p_value` columns.
Cells display the coefficient as text and shade by the p-value on a
5-stop scale (low p-value = cool colors, high p-value = warm colors).
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.core.chart_style import ChartStyle
from openbb_charting.core.openbb_figure import OpenBBFigure
from openbb_core.app.utils import basemodel_to_df
from pandas import DataFrame
from plotly.graph_objs import Figure, Heatmap, Layout
if "data" in kwargs and isinstance(kwargs["data"], DataFrame):
df = kwargs["data"]
elif "data" in kwargs and isinstance(kwargs["data"], list):
df = basemodel_to_df(kwargs["data"], index=None)
else:
df = basemodel_to_df(kwargs["obbject_item"], index=None)
required = {"period", "factor", "coefficient", "p_value"}
missing = required - set(df.columns)
if missing:
raise ValueError(
f"factors_heatmap requires columns {sorted(required)}; missing"
f" {sorted(missing)}."
)
pivoted = df.pivot_table(
index="period",
columns="factor",
values=["coefficient", "p_value"],
sort=False,
)
coef_df = pivoted["coefficient"]
pval_df = pivoted["p_value"]
x_labels = [str(c).replace("const", "Constant") for c in coef_df.columns]
y_labels = list(coef_df.index)
text_color = "white" if ChartStyle().plt_style == "dark" else "black"
title = kwargs.get("title") or "Factor Regression Coefficients & P-Values"
heatmap = Heatmap(
z=pval_df.values,
x=x_labels,
y=y_labels,
xgap=1,
ygap=1,
colorscale=_PVALUE_COLORSCALE,
zmin=0.0,
zmax=1.0,
showscale=False,
text=coef_df.values,
texttemplate="%{text:.6f}",
hoverongaps=False,
hovertemplate=(
"%{x} - %{y}<br>Coefficient: %{text:.6f}<br>"
"P-Value: %{z:.6f}<extra></extra>"
),
)
layout = Layout(
title_text=title,
title_x=0.5,
xaxis=dict(
showgrid=False,
showline=False,
ticklen=5,
tickangle=0,
side="top",
automargin=True,
),
yaxis=dict(
showgrid=False,
side="left",
autorange="reversed",
showline=False,
ticklen=5,
automargin=True,
),
dragmode="pan",
font=dict(color=text_color),
margin=dict(t=75, b=50, l=85, r=50),
)
fig = Figure(data=[heatmap], layout=layout)
figure = OpenBBFigure(fig=fig)
layout_kwargs = kwargs.get("layout_kwargs") or {}
if layout_kwargs:
figure.update_layout(**layout_kwargs)
content = figure.show(external=True).to_plotly_json()
return figure, content

View File

@@ -0,0 +1,95 @@
"""Stacked horizontal bar chart for the risk_decomposition endpoint."""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from openbb_charting.core.openbb_figure import OpenBBFigure # noqa
_RESIDUAL_LABEL = "Residual"
def risk_decomposition_bar(
**kwargs,
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Stacked bars of per-factor variance share for each look-back period.
Consumes the row schema produced by `POST /quantitative/risk_decomposition`:
one row per (`period`, `factor`) with `share`. Renders one horizontal bar per
period; each segment is a factor's share of Var(target), with the residual
segment last. Bars sum to 1.0 by construction.
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.core.openbb_figure import OpenBBFigure
from openbb_core.app.utils import basemodel_to_df
from pandas import DataFrame
from plotly.graph_objs import Bar, Figure, Layout
if "data" in kwargs and isinstance(kwargs["data"], DataFrame):
df = kwargs["data"]
elif "data" in kwargs and isinstance(kwargs["data"], list):
df = basemodel_to_df(kwargs["data"], index=None)
else:
df = basemodel_to_df(kwargs["obbject_item"], index=None)
required = {"period", "factor", "share"}
missing = required - set(df.columns)
if missing:
raise ValueError(
f"risk_decomposition_bar requires columns {sorted(required)}; missing"
f" {sorted(missing)}."
)
periods = list(dict.fromkeys(df["period"].tolist()))
non_residual = [f for f in dict.fromkeys(df["factor"]) if f != _RESIDUAL_LABEL]
factor_order = non_residual + [_RESIDUAL_LABEL]
share = (
df.pivot_table(index="period", columns="factor", values="share", sort=False)
.reindex(index=periods)
.reindex(columns=factor_order)
.fillna(0.0)
)
title = kwargs.get("title") or "Risk Decomposition (Share of Variance)"
traces = [
Bar(
name=factor,
x=share[factor].to_numpy(),
y=periods,
orientation="h",
hovertemplate=(
f"{factor}<br>Period: %{{y}}<br>Share: %{{x:.2%}}<extra></extra>"
),
text=[f"{v:.1%}" for v in share[factor].to_numpy()],
textposition="inside",
insidetextanchor="middle",
)
for factor in factor_order
]
layout = Layout(
title_text=title,
title_x=0.5,
barmode="stack",
xaxis=dict(
tickformat=".0%",
range=[0, 1],
zeroline=False,
showgrid=False,
),
yaxis=dict(autorange="reversed", showgrid=False),
legend=dict(orientation="h", y=-0.15, yanchor="top", x=0.5, xanchor="center"),
margin=dict(t=60, b=60, l=80, r=40),
dragmode="pan",
)
fig = Figure(data=traces, layout=layout)
figure = OpenBBFigure(fig=fig)
layout_kwargs = kwargs.get("layout_kwargs") or {}
if layout_kwargs:
figure.update_layout(**layout_kwargs)
content = figure.show(external=True).to_plotly_json()
return figure, content

View File

@@ -0,0 +1,151 @@
"""Stacked-area chart for the rolling factors endpoint."""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from openbb_charting.core.openbb_figure import OpenBBFigure # noqa
_PALETTE = [
"#4c78a8",
"#f58518",
"#54a24b",
"#e45756",
"#72b7b2",
"#eeca3b",
"#b279a2",
"#ff9da6",
"#9d755d",
"#bab0ac",
"#a3cce9",
"#ffbf79",
"#88d27a",
"#ff9d97",
"#a0cbe8",
"#d4a6c8",
"#b8b0aa",
"#cad6d4",
]
# Bands smaller than this fraction of the final-period total are unlabeled
# to prevent overlapping annotations on hairline-thin slivers.
_LABEL_MIN_SHARE = 0.015
def rolling_factors_line(
**kwargs,
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Stacked-area chart of rolling factor betas over time.
Consumes the row schema produced by `POST /quantitative/rolling/factors`:
rows of (`date`, `factor`, `coefficient`, `t_statistic`). Each factor is
drawn as a colored band; the total height at a date is the sum of factor
betas. Factors are sorted by mean absolute beta with the dominant
exposure at the base. Bands are labeled inline at the right edge of the
chart, so there is no separate legend cluttering the layout.
The intercept (`const`) is dropped by default; pass ``include_intercept=True``
to keep it.
"""
# pylint: disable=import-outside-toplevel
from openbb_charting.core.openbb_figure import OpenBBFigure
from openbb_core.app.utils import basemodel_to_df
from pandas import DataFrame
from plotly.graph_objs import Figure, Layout, Scatter
if "data" in kwargs and isinstance(kwargs["data"], DataFrame):
df = kwargs["data"]
elif "data" in kwargs and isinstance(kwargs["data"], list):
df = basemodel_to_df(kwargs["data"], index=None)
else:
df = basemodel_to_df(kwargs["obbject_item"], index=None)
required = {"date", "factor", "coefficient"}
missing = required - set(df.columns)
if missing:
raise ValueError(
f"rolling_factors_line requires columns {sorted(required)};"
f" missing {sorted(missing)}."
)
include_intercept = bool(kwargs.get("include_intercept", False))
if not include_intercept:
df = df[df["factor"] != "const"]
wide = df.pivot_table(
index="date", columns="factor", values="coefficient", sort=False
).sort_index()
order = wide.abs().mean(axis=0).sort_values(ascending=False).index.tolist()
wide = wide[order]
title = kwargs.get("title") or "Rolling Factor Betas (Stacked)"
last_values = wide.iloc[-1]
total_at_last = float(last_values.abs().sum()) or 1.0
cum = 0.0
annotations = []
for i, factor in enumerate(order):
val = float(last_values[factor])
midpoint = cum + val / 2.0
cum += val
if abs(val) / total_at_last < _LABEL_MIN_SHARE:
continue
annotations.append(
dict(
x=wide.index[-1],
y=midpoint,
xref="x",
yref="y",
text=f"<b>{'Intercept' if factor == 'const' else factor}</b>",
font=dict(color=_PALETTE[i % len(_PALETTE)], size=12),
xanchor="left",
yanchor="middle",
xshift=8,
showarrow=False,
)
)
traces = []
for i, factor in enumerate(order):
traces.append(
Scatter(
name="Intercept" if factor == "const" else factor,
x=wide.index.to_numpy(),
y=wide[factor].to_numpy(),
mode="lines",
stackgroup="one",
line=dict(width=0.5, color=_PALETTE[i % len(_PALETTE)]),
fillcolor=_PALETTE[i % len(_PALETTE)],
hovertemplate=(
f"{factor}<br>%{{x|%Y-%m-%d}}<br>Beta: %{{y:.4f}}<extra></extra>"
),
showlegend=False,
)
)
layout = Layout(
title_text=title,
title_x=0.5,
hovermode="x unified",
showlegend=False,
xaxis=dict(showgrid=False, title="Window End"),
yaxis=dict(
title="Cumulative Beta",
showgrid=True,
gridcolor="rgba(128,128,128,0.2)",
zeroline=True,
zerolinewidth=1,
),
margin=dict(t=70, b=60, l=80, r=110),
annotations=annotations,
dragmode="pan",
)
fig = Figure(data=traces, layout=layout)
figure = OpenBBFigure(fig=fig)
layout_kwargs = kwargs.get("layout_kwargs") or {}
if layout_kwargs:
figure.update_layout(**layout_kwargs)
content = figure.show(external=True).to_plotly_json()
return figure, content

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,96 @@
[tool.poetry]
[project]
name = "openbb-quantitative"
version = "1.6.1"
description = "Quantitative Analysis extension for OpenBB"
authors = ["OpenBB Team <hello@openbb.co>"]
license = "AGPL-3.0-only"
version = "2.0.0"
description = "Quantitative analysis extension for OpenBB"
authors = [{ name = "OpenBB Team", email = "hello@openbb.co" }]
license = { text = "AGPL-3.0-only" }
readme = "README.md"
packages = [{ include = "openbb_quantitative" }]
requires-python = ">=3.10,<4"
dependencies = [
"openbb-core[pandas]>=2.0.0",
"numpy>=1.24",
"scipy>=1.11",
"statsmodels>=0.14",
]
[tool.poetry.dependencies]
python = ">=3.10,<4"
openbb-core = "^1.6.3"
pandas-ta-openbb = "^0.4.23"
[project.entry-points."openbb_core_extension"]
quantitative = "openbb_quantitative.router:router"
[project.entry-points."openbb_charting_extension"]
quantitative = "openbb_quantitative.quantitative_views:QuantitativeViews"
[project.urls]
Homepage = "https://openbb.co"
Repository = "https://github.com/OpenBB-finance/OpenBB"
[dependency-groups]
dev = ["openbb-devtools>=2.0.0", "openbb-charting>=3.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"]
quantitative = "openbb_quantitative.quantitative_router:router"
[tool.hatch.build.targets.wheel]
packages = ["openbb_quantitative"]
[tool.hatch.build.targets.sdist]
include = ["openbb_quantitative", "README.md", "pyproject.toml"]
[tool.uv.sources]
openbb-core = { path = "../../core", editable = true }
openbb-devtools = { path = "../devtools", editable = true }
openbb-charting = { path = "../../obbject_extensions/charting", 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_quantitative/*.py" = ["PLR0911", "PLR0912", "PLR0913", "PLR0914", "PLR0915", "PLR0917", "ARG001", "ARG002", "SLF001"]
"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_quantitative"]
exclude = ["tests", "integration"]
[tool.ty.rules]
unresolved-import = "warn"
[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@overload"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
addopts = "-q --strict-markers"
testpaths = ["tests"]
markers = [
"integration: end-to-end tests that hit live services (excluded from the coverage gate)",
]
filterwarnings = [
"ignore:Inheritance class ClientSession from ClientSession is discouraged:DeprecationWarning",
]

View File

@@ -0,0 +1,89 @@
"""Shared fixtures for the quantitative test suite."""
import numpy as np
import pandas as pd
import pytest
from openbb_core.app.utils import df_to_basemodel
SEED = 42
N_ROWS = 250
FACTOR_N_ROWS = 800
@pytest.fixture(scope="session")
def prices_df() -> pd.DataFrame:
"""Build a deterministic OHLCV price series with a date column."""
rng = np.random.default_rng(SEED)
t = np.arange(N_ROWS)
close = 100 + 0.1 * t + rng.normal(0, 1, N_ROWS).cumsum() * 0.5
dates = pd.date_range("2022-01-03", periods=N_ROWS, freq="B")
return pd.DataFrame(
{
"date": dates,
"open": close + rng.normal(0, 0.3, N_ROWS),
"high": close + np.abs(rng.normal(0.8, 0.3, N_ROWS)),
"low": close - np.abs(rng.normal(0.8, 0.3, N_ROWS)),
"close": close,
"volume": (1e6 + rng.normal(0, 5e4, N_ROWS)).astype(float),
}
)
@pytest.fixture(scope="session")
def prices_data(prices_df) -> list:
"""Expose the price DataFrame as a ``list[Data]``."""
return df_to_basemodel(prices_df)
@pytest.fixture(scope="session")
def factor_dates() -> pd.DatetimeIndex:
"""Daily business-day index long enough for rolling 252-day windows."""
return pd.date_range("2018-01-01", periods=FACTOR_N_ROWS, freq="B")
@pytest.fixture(scope="session")
def factor_matrix_df(factor_dates) -> pd.DataFrame:
"""Two synthetic factors plus a constant risk-free column.
Used together with ``target_returns_df`` so the true betas are known
(`y = 0.7*f1 - 0.3*f2 + rf + noise`).
"""
rng = np.random.default_rng(SEED)
n = len(factor_dates)
return pd.DataFrame(
{
"date": factor_dates,
"f1": rng.normal(0, 0.01, n),
"f2": rng.normal(0, 0.01, n),
# rf varies slightly so statsmodels keeps it as its own regressor
# instead of folding it into the intercept term.
"rf": 0.0001 + rng.normal(0, 1e-6, n),
}
)
@pytest.fixture(scope="session")
def target_returns_df(factor_dates, factor_matrix_df) -> pd.DataFrame:
"""Target series whose returns are a known linear combo of the factors."""
rng = np.random.default_rng(SEED + 1)
n = len(factor_dates)
y = (
0.7 * factor_matrix_df["f1"].to_numpy()
- 0.3 * factor_matrix_df["f2"].to_numpy()
+ factor_matrix_df["rf"].to_numpy()
+ rng.normal(0, 0.002, n)
)
return pd.DataFrame({"date": factor_dates, "close": y})
@pytest.fixture(scope="session")
def factor_matrix_data(factor_matrix_df) -> list:
"""Factor matrix as ``list[Data]``."""
return df_to_basemodel(factor_matrix_df)
@pytest.fixture(scope="session")
def target_returns_data(target_returns_df) -> list:
"""Target returns as ``list[Data]``."""
return df_to_basemodel(target_returns_df)

View File

@@ -0,0 +1,103 @@
"""Tests for ``openbb_quantitative.attribution``."""
from math import isnan
import numpy as np
import pandas as pd
import pytest
from openbb_core.app.utils import df_to_basemodel
from openbb_quantitative import attribution as att_module
def _params(target_data, factor_data, **kwargs):
return att_module.ReturnAttributionQueryParams(
data=target_data, factors_data=factor_data, target="close", **kwargs
)
def test_attribution_happy_path(target_returns_data, factor_matrix_data):
"""Returns rows for every regressor plus Alpha and Residual per period."""
out = att_module.attribution(_params(target_returns_data, factor_matrix_data))
results = out.results
assert isinstance(results, list)
assert results
periods = {r.period for r in results}
for period in periods:
names = [r.factor for r in results if r.period == period]
# const is hidden behind the Alpha label; rf is in the factor set unless
# risk_free_column is provided.
assert set(names) == {"f1", "f2", "rf", "Alpha", "Residual"}
def test_attribution_contributions_sum_to_total(
target_returns_df, factor_matrix_df, target_returns_data, factor_matrix_data
):
"""Per-period contributions sum to the period's total target return."""
out = att_module.attribution(
_params(target_returns_data, factor_matrix_data, risk_free_column="rf")
)
df = pd.DataFrame([r.model_dump() for r in out.results])
sums = df.groupby("period")["contribution"].sum()
# Recompute the expected per-period target totals (excess of rf).
target = target_returns_df.set_index("date")["close"].astype(
float
) - factor_matrix_df.set_index("date")["rf"].astype(float)
max_total = float(target.sum())
assert sums["Max"] == pytest.approx(max_total, abs=1e-9)
def test_attribution_zero_total_uses_nan_share(factor_matrix_df, factor_matrix_data):
"""When the period's total return is zero, share is NaN (no divide-by-zero)."""
n = len(factor_matrix_df)
half = n // 2
# Alternating +eps / -eps gives an exactly zero sum.
series = np.tile([1e-6, -1e-6], half + 1)[:n]
zero_target = df_to_basemodel(
pd.DataFrame({"date": factor_matrix_df["date"], "close": series})
)
out = att_module.attribution(
_params(zero_target, factor_matrix_data, periods=["Max"])
)
shares = [r.share for r in out.results]
assert shares
assert all(isnan(s) for s in shares)
def test_attribution_with_risk_free_drops_rf(target_returns_data, factor_matrix_data):
"""The risk-free column is removed from the factor set when provided."""
out = att_module.attribution(
_params(target_returns_data, factor_matrix_data, risk_free_column="rf")
)
factors = {r.factor for r in out.results}
assert "rf" not in factors
assert {"f1", "f2", "Alpha", "Residual"}.issubset(factors)
def test_attribution_skips_too_short_window(factor_matrix_df, factor_matrix_data):
"""Windows with too few observations for OLS are skipped cleanly."""
short_dates = factor_matrix_df["date"].iloc[:2]
short_target = df_to_basemodel(
pd.DataFrame({"date": short_dates, "close": [0.01, -0.005]})
)
out = att_module.attribution(
_params(short_target, factor_matrix_data, periods=["Max"])
)
assert out.results == []
def test_attribution_default_periods():
"""The default ``periods`` value enumerates the six named look-back windows."""
params = att_module.ReturnAttributionQueryParams(
data=[], factors_data=[], target="close"
)
assert params.periods == [
"1 Month",
"3 Month",
"YTD",
"1 Year",
"3 Year",
"Max",
]

View File

@@ -0,0 +1,154 @@
"""Tests for ``openbb_quantitative._factor_helpers``."""
import pandas as pd
import pytest
from openbb_core.app.utils import df_to_basemodel
from openbb_quantitative import _factor_helpers as fh
@pytest.fixture
def anchor() -> pd.Timestamp:
return pd.Timestamp("2025-06-15")
def test_period_start_one_month(anchor):
assert fh.period_start(anchor, "1 Month") == anchor - pd.DateOffset(months=1)
def test_period_start_three_month(anchor):
assert fh.period_start(anchor, "3 Month") == anchor - pd.DateOffset(months=3)
def test_period_start_ytd(anchor):
assert fh.period_start(anchor, "YTD") == pd.Timestamp(f"{anchor.year}-01-01")
def test_period_start_one_year(anchor):
assert fh.period_start(anchor, "1 Year") == anchor - pd.DateOffset(years=1)
def test_period_start_three_year(anchor):
assert fh.period_start(anchor, "3 Year") == anchor - pd.DateOffset(years=3)
def test_period_start_max(anchor):
assert fh.period_start(anchor, "Max") is None
def test_period_start_invalid(anchor):
with pytest.raises(ValueError, match="Unsupported period"):
fh.period_start(anchor, "1 Decade")
def test_align_inputs_happy_path(target_returns_data, factor_matrix_data):
factor_matrix, target_series, factor_cols = fh.align_inputs(
target_returns_data,
factor_matrix_data,
target="close",
index="date",
risk_free_column=None,
)
assert factor_cols == ["f1", "f2", "rf"]
assert list(factor_matrix.columns) == ["f1", "f2", "rf"]
assert len(factor_matrix) == len(target_series)
assert target_series.name == "close"
def test_align_inputs_with_risk_free(target_returns_data, factor_matrix_data):
factor_matrix, target_series, factor_cols = fh.align_inputs(
target_returns_data,
factor_matrix_data,
target="close",
index="date",
risk_free_column="rf",
)
assert factor_cols == ["f1", "f2"]
assert "rf" not in factor_matrix.columns
# Target should be reduced by the constant rf value at every observation.
assert target_series.iloc[0] != pytest.approx(0.0)
def test_align_inputs_target_missing(factor_matrix_data):
data = df_to_basemodel(
pd.DataFrame({"date": pd.date_range("2020", periods=3), "x": [1.0, 2.0, 3.0]})
)
with pytest.raises(ValueError, match="Target column 'close' not found"):
fh.align_inputs(
data,
factor_matrix_data,
target="close",
index="date",
risk_free_column=None,
)
def test_align_inputs_no_overlap(factor_matrix_data):
far_future = df_to_basemodel(
pd.DataFrame(
{"date": pd.date_range("2099-01-01", periods=5), "close": [1.0] * 5}
)
)
with pytest.raises(ValueError, match="No overlapping dates"):
fh.align_inputs(
far_future,
factor_matrix_data,
target="close",
index="date",
risk_free_column=None,
)
def test_align_inputs_rf_missing(target_returns_data, factor_matrix_data):
with pytest.raises(ValueError, match="risk_free_column='missing'"):
fh.align_inputs(
target_returns_data,
factor_matrix_data,
target="close",
index="date",
risk_free_column="missing",
)
def test_align_inputs_no_factor_cols(target_returns_data):
rf_only = df_to_basemodel(
pd.DataFrame(
{
"date": pd.date_range("2018-01-01", periods=800, freq="B"),
"rf": [0.0001] * 800,
}
)
)
with pytest.raises(ValueError, match="No factor columns remain"):
fh.align_inputs(
target_returns_data,
rf_only,
target="close",
index="date",
risk_free_column="rf",
)
def test_align_inputs_all_nan_after_alignment(factor_matrix_data, factor_dates):
"""When the target is NaN at every overlapping date the aligned frame is empty."""
import numpy as np
# One valid value at a date that is NOT in the factor index keeps the column
# alive through the basemodel round trip; the overlapping rows are all NaN.
anchor = factor_dates[0] - pd.Timedelta(days=1)
overlap_dates = factor_dates[:5]
target_df = pd.DataFrame(
{
"date": [anchor] + list(overlap_dates),
"close": [1.0] + [np.nan] * len(overlap_dates),
}
)
nan_target = df_to_basemodel(target_df)
with pytest.raises(ValueError, match="No observations remain"):
fh.align_inputs(
nan_target,
factor_matrix_data,
target="close",
index="date",
risk_free_column=None,
)

View File

@@ -0,0 +1,109 @@
"""Tests for ``openbb_quantitative.factors``."""
from math import isfinite
import pandas as pd
import statsmodels.api as sm
from openbb_core.app.utils import df_to_basemodel
from openbb_quantitative import factors as factors_module
def _params(target_data, factor_data, **kwargs):
return factors_module.FactorRegressionQueryParams(
data=target_data, factors_data=factor_data, target="close", **kwargs
)
def test_factors_happy_path(target_returns_data, factor_matrix_data):
"""Returns one row per (period, regressor) with finite stats."""
out = factors_module.factors(_params(target_returns_data, factor_matrix_data))
results = out.results
assert isinstance(results, list)
assert results
n_regressors = 4 # const + f1 + f2 + rf
n_periods = len(set(r.period for r in results))
assert len(results) == n_periods * n_regressors
for row in results:
assert isinstance(row, factors_module.FactorRegressionData)
for field in ("coefficient", "p_value", "lower_ci", "upper_ci", "r_squared"):
value = getattr(row, field)
assert isinstance(value, float)
assert isfinite(value)
def test_factors_max_window_matches_statsmodels(
target_returns_df, factor_matrix_df, target_returns_data, factor_matrix_data
):
"""The Max-period coefficients match a direct statsmodels OLS fit."""
out = factors_module.factors(
_params(target_returns_data, factor_matrix_data, risk_free_column="rf")
)
df = pd.DataFrame([r.model_dump() for r in out.results])
max_rows = df[df.period == "Max"].set_index("factor")
aligned = factor_matrix_df.set_index("date").drop(columns=["rf"]).astype(float)
target = target_returns_df.set_index("date")["close"].astype(
float
) - factor_matrix_df.set_index("date")["rf"].astype(float)
x = sm.add_constant(aligned)
model = sm.OLS(target, x).fit()
for name in model.params.index:
assert max_rows.loc[name, "coefficient"] == pytest.approx( # type: ignore[name-defined]
float(model.params[name]), abs=1e-9
)
def test_factors_with_risk_free(target_returns_data, factor_matrix_data):
"""When ``risk_free_column`` is set the rf column drops out of the regressors."""
out = factors_module.factors(
_params(target_returns_data, factor_matrix_data, risk_free_column="rf")
)
factor_names = {r.factor for r in out.results}
assert "rf" not in factor_names
assert factor_names == {"const", "f1", "f2"}
def test_factors_specific_periods(target_returns_data, factor_matrix_data):
"""The ``periods`` parameter restricts which look-back windows are returned."""
out = factors_module.factors(
_params(target_returns_data, factor_matrix_data, periods=["Max"])
)
assert {r.period for r in out.results} == {"Max"}
def test_factors_skips_too_short_windows(factor_matrix_df, factor_matrix_data):
"""Periods whose slice contains fewer obs than regressors+1 are skipped."""
# Build a target whose factor overlap is two days of business activity.
short_dates = factor_matrix_df["date"].iloc[:2]
short_target = df_to_basemodel(
pd.DataFrame({"date": short_dates, "close": [0.01, -0.005]})
)
out = factors_module.factors(
_params(short_target, factor_matrix_data, periods=["1 Month", "Max"])
)
# 2 observations cannot fit a 4-regressor model -> both periods are skipped.
assert out.results == []
def test_factors_default_periods():
"""The default ``periods`` value enumerates the six named look-back windows."""
params = factors_module.FactorRegressionQueryParams(
data=[], factors_data=[], target="close"
)
assert params.periods == [
"1 Month",
"3 Month",
"YTD",
"1 Year",
"3 Year",
"Max",
]
# pytest is imported here (not at the top) so the local _params helper above
# can reference it without surfacing in autocompletion.
import pytest # noqa: E402

View File

@@ -0,0 +1,125 @@
"""Tests for ``openbb_quantitative.metrics`` - quantitative analysis commands."""
import numpy as np
import pandas as pd
import pytest
from openbb_quantitative import metrics
def test_normality(prices_data):
"""Normality returns finite test statistics and p-values for every test."""
out = metrics.normality(
metrics.NormalityQueryParams(data=prices_data, target="close")
)
dumped = out.results.model_dump()
for field in metrics.NormalityQueryParams.__output_columns__:
value = dumped[field]
assert isinstance(value, float)
assert np.isfinite(value)
def test_normality_non_numeric_raises():
"""A non-numeric target column raises a clear ValueError."""
data = [{"close": "a"}, {"close": "b"}, {"close": "c"}]
params = metrics.NormalityQueryParams(data=data, target="close")
with pytest.raises(ValueError, match="must be numeric"):
metrics.normality(params)
def test_capm(prices_data, monkeypatch):
"""CAPM reports finite risk measures from mocked monthly Fama-French factors."""
def fake(start_date, end_date):
rng = np.random.default_rng(0)
index = pd.date_range(start_date, end_date, freq="MS")
return pd.DataFrame(
{
"mkt_rf": rng.normal(0.01, 0.04, len(index)),
"smb": rng.normal(0.0, 0.02, len(index)),
"hml": rng.normal(0.0, 0.02, len(index)),
"rf": rng.normal(0.002, 0.001, len(index)),
},
index=index,
)
monkeypatch.setattr("openbb_quantitative.helpers.get_fama_raw", fake)
out = metrics.capm(metrics.CapmQueryParams(data=prices_data, target="close"))
dumped = out.results.model_dump()
for field in metrics.CapmQueryParams.__output_columns__:
value = dumped[field]
assert isinstance(value, float)
assert np.isfinite(value)
def test_unitroot_test(prices_data):
"""The unit root test reports finite ADF and KPSS statistics."""
out = metrics.unitroot_test(
metrics.UnitRootTestQueryParams(data=prices_data, target="close")
)
params = metrics.UnitRootTestQueryParams(data=prices_data, target="close")
assert params.fuller_reg == "c"
assert params.kpss_reg == "c"
assert params.maxlag is None
assert params.autolag == "AIC"
assert params.nlags == "auto"
dumped = out.results.model_dump()
assert isinstance(dumped["adf_statistic"], float)
assert np.isfinite(dumped["adf_statistic"])
assert isinstance(dumped["adf_p_value"], float)
assert isinstance(dumped["adf_nlags"], int)
assert isinstance(dumped["adf_nobs"], int)
assert isinstance(dumped["adf_icbest"], float)
assert isinstance(dumped["kpss_statistic"], float)
assert isinstance(dumped["kpss_p_value"], float)
assert isinstance(dumped["kpss_nlags"], int)
assert isinstance(dumped["kpss_p_value_interpolated"], bool)
def test_unitroot_test_regression_options(prices_data):
"""The unit root test accepts non-default ADF and KPSS regression terms."""
out = metrics.unitroot_test(
metrics.UnitRootTestQueryParams(
data=prices_data,
target="close",
fuller_reg="ct",
kpss_reg="ct",
)
)
dumped = out.results.model_dump()
assert np.isfinite(dumped["adf_statistic"])
assert np.isfinite(dumped["kpss_statistic"])
def test_unitroot_test_no_autolag(prices_data):
"""With autolag disabled the ADF test uses maxlag directly and has no icbest."""
out = metrics.unitroot_test(
metrics.UnitRootTestQueryParams(
data=prices_data, target="close", maxlag=4, autolag=None
)
)
dumped = out.results.model_dump()
assert dumped["adf_nlags"] == 4
assert dumped["adf_icbest"] is None
def test_unitroot_test_explicit_kpss_nlags(prices_data):
"""The KPSS test accepts an explicit integer lag count."""
out = metrics.unitroot_test(
metrics.UnitRootTestQueryParams(data=prices_data, target="close", nlags=6)
)
assert out.results.kpss_nlags == 6
def test_summary(prices_data):
"""Summary reports finite descriptive statistics for the target column."""
out = metrics.summary(metrics.SummaryQueryParams(data=prices_data, target="close"))
dumped = out.results.model_dump()
assert isinstance(dumped["count"], int)
assert dumped["count"] == 250
for field in ("mean", "std", "var", "min", "p25", "p50", "p75", "max"):
value = dumped[field]
assert isinstance(value, float)
assert np.isfinite(value)
assert dumped["min"] <= dumped["p25"] <= dumped["p50"] <= dumped["p75"]
assert dumped["p75"] <= dumped["max"]

View File

@@ -0,0 +1,96 @@
"""Tests for ``openbb_quantitative.performance`` - performance-metric commands."""
from math import isfinite, isnan
from openbb_quantitative import performance
def test_query_params_defaults():
"""The performance QueryParams expose the documented default values."""
omega = performance.OmegaRatioQueryParams(data=[], target="close")
assert omega.threshold_start == 0.0
assert omega.threshold_end == 1.5
assert omega.bins == 50
sharpe = performance.SharpeRatioQueryParams(data=[], target="close")
assert sharpe.rfr == 0.0
assert sharpe.window == 252
assert sharpe.index == "date"
sortino = performance.SortinoRatioQueryParams(data=[], target="close")
assert sortino.target_return == 0.0
assert sortino.window == 252
assert sortino.adjusted is False
assert sortino.index == "date"
def test_omega_ratio(prices_data):
"""The Omega ratio returns one finite ratio per threshold across 50 thresholds."""
params = performance.OmegaRatioQueryParams(data=prices_data, target="close")
out = performance.omega_ratio(params)
results = out.results
assert isinstance(results, list)
assert len(results) == 50
for row in results:
assert isinstance(row, performance.OmegaRatioData)
assert isfinite(row.threshold)
assert isfinite(row.omega)
def test_omega_ratio_bins(prices_data):
"""The Omega ratio honors an explicit threshold count."""
out = performance.omega_ratio(
performance.OmegaRatioQueryParams(data=prices_data, target="close", bins=10)
)
assert len(out.results) == 10
def test_sharpe_ratio(prices_data):
"""The rolling Sharpe ratio returns finite dated observations."""
params = performance.SharpeRatioQueryParams(
data=prices_data, target="close", window=20
)
out = performance.sharpe_ratio(params)
results = out.results
assert isinstance(results, list)
assert len(results) > 0
for row in results:
assert isinstance(row, performance.SharpeRatioData)
assert row.date is not None
assert isfinite(row.sharpe_ratio)
def test_sortino_ratio(prices_data):
"""The rolling Sortino ratio returns dated numeric observations."""
params = performance.SortinoRatioQueryParams(
data=prices_data, target="close", window=20
)
out = performance.sortino_ratio(params)
results = out.results
assert isinstance(results, list)
assert len(results) > 0
for row in results:
assert isinstance(row, performance.SortinoRatioData)
assert row.date is not None
assert isfinite(row.sortino_ratio)
def test_sortino_ratio_adjusted(prices_data):
"""The adjusted Sortino ratio scales the unadjusted ratio by 1/sqrt(2)."""
from math import sqrt
unadjusted = performance.sortino_ratio(
performance.SortinoRatioQueryParams(
data=prices_data, target="close", window=20, adjusted=False
)
).results
adjusted = performance.sortino_ratio(
performance.SortinoRatioQueryParams(
data=prices_data, target="close", window=20, adjusted=True
)
).results
assert len(adjusted) == len(unadjusted)
assert len(adjusted) > 0
for adj, unadj in zip(adjusted, unadjusted):
assert not isnan(adj.sortino_ratio)
assert adj.sortino_ratio == unadj.sortino_ratio / sqrt(2)

View File

@@ -1,15 +1,81 @@
"""Test the quantitative helpers."""
"""Tests for ``openbb_quantitative.helpers``."""
import io
import zipfile
import pandas as pd
from extensions.quantitative.openbb_quantitative.helpers import (
validate_window,
import pytest
from openbb_quantitative.helpers import get_fama_raw, validate_window
_FAMA_CSV = (
"Fama/French factors -- test fixture\n"
"second descriptive line\n"
"third descriptive line\n"
",Mkt-RF,SMB,HML,RF\n"
"202201,1.00,0.50,0.30,0.01\n"
"202202,-0.50,0.20,0.10,0.01\n"
"202203,0.75,-0.10,0.05,0.01\n"
"\n"
" Annual Factors: January-December \n"
"2022,5.00,2.00,1.00,0.12\n"
)
def test_validate_window():
"""Test the validate_window function."""
input_data = pd.Series(range(1, 100))
validate_window(
input_data=input_data,
window=20,
)
def _fama_zip_bytes() -> bytes:
"""Build an in-memory Fama-French factors zip archive."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr("F-F_Research_Data_Factors.csv", _FAMA_CSV)
return buffer.getvalue()
class _FakeResponse:
"""Minimal stand-in for the urlopen response context manager."""
def __init__(self, payload: bytes):
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self) -> bytes:
return self._payload
@pytest.fixture
def mock_fama(monkeypatch):
"""Patch urlopen so get_fama_raw reads the in-memory archive, not the web."""
payload = _fama_zip_bytes()
monkeypatch.setattr("urllib.request.urlopen", lambda url: _FakeResponse(payload))
def test_get_fama_raw(mock_fama):
"""get_fama_raw parses the factor archive into a date-indexed DataFrame."""
df = get_fama_raw("2022-01-01", "2022-12-31")
assert list(df.columns) == ["mkt_rf", "smb", "hml", "rf"]
assert df.index.name == "date"
# Only the three monthly rows survive the six-character-date filter.
assert len(df) == 3
# Percent figures are converted to decimal fractions.
assert df["mkt_rf"].iloc[0] == pytest.approx(0.01)
def test_get_fama_raw_start_date_too_late(mock_fama):
"""A start date after the last observation raises a ValueError."""
with pytest.raises(ValueError, match="after the last available"):
get_fama_raw("2099-01-01", "2099-12-31")
def test_validate_window_within_bounds():
"""validate_window accepts a window no larger than the data."""
validate_window(pd.Series(range(100)), 20)
def test_validate_window_too_large():
"""validate_window raises when the window exceeds the data length."""
with pytest.raises(ValueError, match="larger than the data length"):
validate_window(pd.Series(range(10)), 50)

View File

@@ -0,0 +1,92 @@
"""Tests for ``openbb_quantitative.risk_decomposition``."""
import pandas as pd
import pytest
from openbb_core.app.utils import df_to_basemodel
from openbb_quantitative import risk_decomposition as rd_module
def _params(target_data, factor_data, **kwargs):
return rd_module.RiskDecompositionQueryParams(
data=target_data, factors_data=factor_data, target="close", **kwargs
)
def test_risk_decomposition_happy_path(target_returns_data, factor_matrix_data):
"""Returns rows for every factor plus a Residual row per period."""
out = rd_module.risk_decomposition(_params(target_returns_data, factor_matrix_data))
results = out.results
assert isinstance(results, list)
assert results
periods = {r.period for r in results}
for period in periods:
period_factors = [r.factor for r in results if r.period == period]
# f1, f2, rf, Residual
assert set(period_factors) == {"f1", "f2", "rf", "Residual"}
def test_risk_decomposition_shares_sum_to_one(target_returns_data, factor_matrix_data):
"""Per-period shares sum to 1.0 within floating-point tolerance."""
out = rd_module.risk_decomposition(
_params(target_returns_data, factor_matrix_data, risk_free_column="rf")
)
df = pd.DataFrame([r.model_dump() for r in out.results])
sums = df.groupby("period")["share"].sum()
for period_sum in sums:
assert period_sum == pytest.approx(1.0, abs=1e-9)
def test_risk_decomposition_with_risk_free(target_returns_data, factor_matrix_data):
"""The risk-free column is dropped from the factor list when provided."""
out = rd_module.risk_decomposition(
_params(target_returns_data, factor_matrix_data, risk_free_column="rf")
)
factors = {r.factor for r in out.results}
assert "rf" not in factors
assert {"f1", "f2", "Residual"}.issubset(factors)
def test_risk_decomposition_constant_target_skipped(
factor_matrix_df, factor_matrix_data
):
"""Periods where Var(target) is zero are skipped, not divided by zero."""
constant_target = df_to_basemodel(
pd.DataFrame(
{"date": factor_matrix_df["date"], "close": [1.0] * len(factor_matrix_df)}
)
)
out = rd_module.risk_decomposition(
_params(constant_target, factor_matrix_data, periods=["Max"])
)
assert out.results == []
def test_risk_decomposition_skips_too_short_window(
factor_matrix_df, factor_matrix_data
):
"""Windows with too few observations for OLS are skipped cleanly."""
short_dates = factor_matrix_df["date"].iloc[:2]
short_target = df_to_basemodel(
pd.DataFrame({"date": short_dates, "close": [0.01, -0.005]})
)
out = rd_module.risk_decomposition(
_params(short_target, factor_matrix_data, periods=["Max"])
)
assert out.results == []
def test_risk_decomposition_default_periods():
"""The default ``periods`` value enumerates the six named look-back windows."""
params = rd_module.RiskDecompositionQueryParams(
data=[], factors_data=[], target="close"
)
assert params.periods == [
"1 Month",
"3 Month",
"YTD",
"1 Year",
"3 Year",
"Max",
]

View File

@@ -0,0 +1,177 @@
"""Tests for ``openbb_quantitative.rolling`` - rolling-statistics commands."""
from math import isfinite
from openbb_quantitative import rolling
def test_rolling_skew(prices_data):
"""Rolling skew returns finite values for each completed window."""
params = rolling.RollingSkewQueryParams(data=prices_data, target="close", window=20)
out = rolling.skew(params)
results = out.results
assert isinstance(results, list)
assert results
for row in results:
assert isinstance(row, rolling.RollingSkewData)
assert isfinite(row.skew)
assert row.date is not None
def test_rolling_variance(prices_data):
"""Rolling variance returns finite, non-negative values."""
params = rolling.RollingVarianceQueryParams(
data=prices_data, target="close", window=20
)
out = rolling.variance(params)
results = out.results
assert isinstance(results, list)
assert results
for row in results:
assert isinstance(row, rolling.RollingVarianceData)
assert isfinite(row.variance)
assert row.variance >= 0.0
assert row.date is not None
def test_rolling_stdev(prices_data):
"""Rolling standard deviation returns finite, non-negative values."""
params = rolling.RollingStdevQueryParams(
data=prices_data, target="close", window=20
)
out = rolling.stdev(params)
results = out.results
assert isinstance(results, list)
assert results
for row in results:
assert isinstance(row, rolling.RollingStdevData)
assert isfinite(row.stdev)
assert row.stdev >= 0.0
assert row.date is not None
def test_rolling_kurtosis(prices_data):
"""Rolling kurtosis returns finite values for each completed window."""
params = rolling.RollingKurtosisQueryParams(
data=prices_data, target="close", window=20
)
out = rolling.kurtosis(params)
results = out.results
assert isinstance(results, list)
assert results
for row in results:
assert isinstance(row, rolling.RollingKurtosisData)
assert isfinite(row.kurtosis)
assert row.date is not None
def test_rolling_mean(prices_data):
"""Rolling mean returns finite values for each completed window."""
params = rolling.RollingMeanQueryParams(data=prices_data, target="close", window=20)
out = rolling.mean(params)
results = out.results
assert isinstance(results, list)
assert results
for row in results:
assert isinstance(row, rolling.RollingMeanData)
assert isfinite(row.mean)
assert row.date is not None
def test_rolling_quantile(prices_data):
"""Rolling quantile returns finite median and quantile values."""
params = rolling.RollingQuantileQueryParams(
data=prices_data, target="close", window=20
)
out = rolling.quantile(params)
results = out.results
assert isinstance(results, list)
assert results
for row in results:
assert isinstance(row, rolling.RollingQuantileData)
assert isfinite(row.median)
assert isfinite(row.quantile)
assert row.date is not None
def test_rolling_query_params_defaults():
"""The rolling QueryParams expose the documented default values."""
skew_params = rolling.RollingSkewQueryParams(data=[], target="close")
assert skew_params.window == 21
assert skew_params.index == "date"
variance_params = rolling.RollingVarianceQueryParams(data=[], target="close")
assert variance_params.window == 21
assert variance_params.index == "date"
stdev_params = rolling.RollingStdevQueryParams(data=[], target="close")
assert stdev_params.window == 21
assert stdev_params.index == "date"
kurtosis_params = rolling.RollingKurtosisQueryParams(data=[], target="close")
assert kurtosis_params.window == 21
assert kurtosis_params.index == "date"
mean_params = rolling.RollingMeanQueryParams(data=[], target="close")
assert mean_params.window == 21
assert mean_params.index == "date"
quantile_params = rolling.RollingQuantileQueryParams(data=[], target="close")
assert quantile_params.window == 21
assert quantile_params.index == "date"
assert quantile_params.quantile_pct == 0.5
def test_rolling_factors_happy_path(target_returns_data, factor_matrix_data):
"""Rolling factors emits one row per (window-end, regressor) with finite stats."""
params = rolling.RollingFactorsQueryParams(
data=target_returns_data,
factors_data=factor_matrix_data,
target="close",
window=252,
step=21,
risk_free_column="rf",
)
out = rolling.factors(params)
results = out.results
assert isinstance(results, list)
assert results
factors_set = {r.factor for r in results}
assert factors_set == {"const", "f1", "f2"}
for row in results:
assert isinstance(row, rolling.RollingFactorsData)
assert isfinite(row.coefficient)
assert isfinite(row.t_statistic)
def test_rolling_factors_default_params():
"""Default ``window`` and ``step`` match the documented values."""
params = rolling.RollingFactorsQueryParams(data=[], factors_data=[], target="close")
assert params.window == 252
assert params.step == 21
assert params.index == "date"
assert params.risk_free_column is None
def test_rolling_factors_window_too_large(target_returns_data, factor_matrix_data):
"""A window longer than the aligned data raises ValueError."""
import pytest
params = rolling.RollingFactorsQueryParams(
data=target_returns_data,
factors_data=factor_matrix_data,
target="close",
window=10_000,
)
with pytest.raises(ValueError, match="exceeds the aligned data length"):
rolling.factors(params)
def test_rolling_factors_window_too_small(target_returns_data, factor_matrix_data):
"""A window not large enough to fit regressors+1 raises ValueError."""
import pytest
params = rolling.RollingFactorsQueryParams(
data=target_returns_data,
factors_data=factor_matrix_data,
target="close",
window=3,
)
with pytest.raises(ValueError, match="must exceed the number of regressors"):
rolling.factors(params)

View File

@@ -0,0 +1,39 @@
"""Tests for ``openbb_quantitative.router`` - the aggregating router."""
from openbb_core.app.router import Router
from openbb_quantitative.router import router
EXPECTED_ROUTES = {
"/normality",
"/capm",
"/unitroot_test",
"/summary",
"/factors",
"/risk_decomposition",
"/attribution",
"/rolling/skew",
"/rolling/variance",
"/rolling/stdev",
"/rolling/kurtosis",
"/rolling/mean",
"/rolling/quantile",
"/rolling/factors",
"/stats/skew",
"/stats/variance",
"/stats/stdev",
"/stats/kurtosis",
"/stats/mean",
"/stats/quantile",
"/performance/omega_ratio",
"/performance/sharpe_ratio",
"/performance/sortino_ratio",
}
def test_router_aggregates_every_command():
"""The top-level router wires every quantitative command exactly once."""
assert isinstance(router, Router)
paths = [r.path for r in router.api_router.routes]
assert set(paths) == EXPECTED_ROUTES
assert len(paths) == len(EXPECTED_ROUTES)

View File

@@ -0,0 +1,83 @@
"""Tests for ``openbb_quantitative.stats`` - summary statistics commands."""
import math
from openbb_quantitative import stats
def test_skew(prices_data):
"""The skew command returns a finite skewness value."""
out = stats.skew(stats.StatsSkewQueryParams(data=prices_data, target="close"))
result = out.results
assert isinstance(result, stats.StatsSkewData)
assert isinstance(result.skew, float)
assert math.isfinite(result.skew)
def test_variance(prices_data):
"""The variance command returns a finite, non-negative variance value."""
out = stats.variance(
stats.StatsVarianceQueryParams(data=prices_data, target="close")
)
result = out.results
assert isinstance(result, stats.StatsVarianceData)
assert isinstance(result.variance, float)
assert math.isfinite(result.variance)
assert result.variance >= 0.0
def test_stdev(prices_data):
"""The stdev command returns a finite, non-negative standard deviation."""
out = stats.stdev(stats.StatsStdevQueryParams(data=prices_data, target="close"))
result = out.results
assert isinstance(result, stats.StatsStdevData)
assert isinstance(result.stdev, float)
assert math.isfinite(result.stdev)
assert result.stdev >= 0.0
def test_kurtosis(prices_data):
"""The kurtosis command returns a finite kurtosis value."""
out = stats.kurtosis(
stats.StatsKurtosisQueryParams(data=prices_data, target="close")
)
result = out.results
assert isinstance(result, stats.StatsKurtosisData)
assert isinstance(result.kurtosis, float)
assert math.isfinite(result.kurtosis)
def test_mean(prices_data):
"""The mean command returns a finite arithmetic mean."""
out = stats.mean(stats.StatsMeanQueryParams(data=prices_data, target="close"))
result = out.results
assert isinstance(result, stats.StatsMeanData)
assert isinstance(result.mean, float)
assert math.isfinite(result.mean)
def test_quantile_default(prices_data):
"""The quantile command defaults to the median (0.5 quantile)."""
params = stats.StatsQuantileQueryParams(data=prices_data, target="close")
assert params.quantile_pct == 0.5
out = stats.quantile(params)
result = out.results
assert isinstance(result, stats.StatsQuantileData)
assert isinstance(result.median, float)
assert isinstance(result.quantile, float)
assert math.isfinite(result.median)
assert math.isfinite(result.quantile)
assert result.quantile == result.median
def test_quantile_explicit(prices_data):
"""The quantile command honors an explicit quantile percentage."""
params = stats.StatsQuantileQueryParams(
data=prices_data, target="close", quantile_pct=0.9
)
assert params.quantile_pct == 0.9
out = stats.quantile(params)
result = out.results
assert isinstance(result, stats.StatsQuantileData)
assert math.isfinite(result.quantile)
assert result.quantile >= result.median

View File

@@ -1,8 +1,15 @@
"""Tests for the statistics module."""
"""Tests for ``openbb_quantitative._stats_helpers``."""
import pandas as pd
import pytest
from openbb_quantitative.statistics import kurtosis_, mean_, skew_, std_dev_, var_
from openbb_quantitative._stats_helpers import (
kurtosis_,
mean_,
skew_,
std_dev_,
var_,
)
test_data = pd.Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])

View File

@@ -0,0 +1,259 @@
"""Tests for the chart view functions and the ``QuantitativeViews`` dispatcher."""
import pandas as pd
import pytest
from openbb_core.app.utils import df_to_basemodel
from openbb_quantitative import (
attribution as att_module,
factors as factors_module,
risk_decomposition as rd_module,
rolling,
)
from openbb_quantitative.quantitative_views import QuantitativeViews
from openbb_quantitative.views.attribution_bar import attribution_bar
from openbb_quantitative.views.factors_heatmap import factors_heatmap
from openbb_quantitative.views.risk_decomposition_bar import risk_decomposition_bar
from openbb_quantitative.views.rolling_factors_line import rolling_factors_line
# ---------------------------------------------------------------------------
# Helpers — run the matching endpoint once per session and reuse the rows.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def factors_results(target_returns_data, factor_matrix_data):
out = factors_module.factors(
factors_module.FactorRegressionQueryParams(
data=target_returns_data,
factors_data=factor_matrix_data,
target="close",
risk_free_column="rf",
)
)
return out.results
@pytest.fixture(scope="module")
def rd_results(target_returns_data, factor_matrix_data):
out = rd_module.risk_decomposition(
rd_module.RiskDecompositionQueryParams(
data=target_returns_data,
factors_data=factor_matrix_data,
target="close",
risk_free_column="rf",
)
)
return out.results
@pytest.fixture(scope="module")
def attribution_results(target_returns_data, factor_matrix_data):
out = att_module.attribution(
att_module.ReturnAttributionQueryParams(
data=target_returns_data,
factors_data=factor_matrix_data,
target="close",
risk_free_column="rf",
)
)
return out.results
@pytest.fixture(scope="module")
def rolling_results(target_returns_data, factor_matrix_data):
out = rolling.factors(
rolling.RollingFactorsQueryParams(
data=target_returns_data,
factors_data=factor_matrix_data,
target="close",
window=252,
step=63,
risk_free_column="rf",
)
)
return out.results
# ---------------------------------------------------------------------------
# factors_heatmap
# ---------------------------------------------------------------------------
def test_factors_heatmap_via_obbject(factors_results):
fig, content = factors_heatmap(obbject_item=factors_results)
trace = content["data"][0]
assert trace["type"] == "heatmap"
assert set(trace["x"]) >= {"f1", "f2", "Constant"}
assert (
content["layout"]["title"]["text"]
== "Factor Regression Coefficients & P-Values"
)
def test_factors_heatmap_via_data_list(factors_results):
payload = [r.model_dump() for r in factors_results]
fig, content = factors_heatmap(data=df_to_basemodel(pd.DataFrame(payload)))
assert content["data"][0]["type"] == "heatmap"
def test_factors_heatmap_via_dataframe(factors_results):
df = pd.DataFrame([r.model_dump() for r in factors_results])
fig, content = factors_heatmap(
data=df, title="Custom Title", layout_kwargs={"height": 700}
)
assert content["layout"]["title"]["text"] == "Custom Title"
assert content["layout"]["height"] == 700
def test_factors_heatmap_missing_columns():
bad = pd.DataFrame({"period": ["Max"], "factor": ["f1"]})
with pytest.raises(ValueError, match="factors_heatmap requires"):
factors_heatmap(data=bad)
# ---------------------------------------------------------------------------
# risk_decomposition_bar
# ---------------------------------------------------------------------------
def test_risk_decomposition_bar_via_obbject(rd_results):
fig, content = risk_decomposition_bar(obbject_item=rd_results)
assert content["layout"]["barmode"] == "stack"
trace_names = [t["name"] for t in content["data"]]
assert trace_names[-1] == "Residual"
def test_risk_decomposition_bar_via_data_list(rd_results):
payload = df_to_basemodel(pd.DataFrame([r.model_dump() for r in rd_results]))
fig, content = risk_decomposition_bar(data=payload, layout_kwargs={"width": 800})
assert content["layout"]["width"] == 800
def test_risk_decomposition_bar_via_dataframe(rd_results):
df = pd.DataFrame([r.model_dump() for r in rd_results])
fig, content = risk_decomposition_bar(data=df, title="Risk Shares")
assert content["layout"]["title"]["text"] == "Risk Shares"
def test_risk_decomposition_bar_missing_columns():
bad = pd.DataFrame({"period": ["Max"], "factor": ["f1"]})
with pytest.raises(ValueError, match="risk_decomposition_bar requires"):
risk_decomposition_bar(data=bad)
# ---------------------------------------------------------------------------
# attribution_bar
# ---------------------------------------------------------------------------
def test_attribution_bar_via_obbject(attribution_results):
fig, content = attribution_bar(obbject_item=attribution_results)
trace_names = [t["name"] for t in content["data"]]
assert "Alpha" in trace_names
assert "Residual" in trace_names
# Alpha and Residual are last in the legend ordering.
assert trace_names[-2:] == ["Alpha", "Residual"]
def test_attribution_bar_via_data_list(attribution_results):
payload = df_to_basemodel(
pd.DataFrame([r.model_dump() for r in attribution_results])
)
fig, content = attribution_bar(data=payload)
assert content["layout"]["barmode"] == "relative"
def test_attribution_bar_via_dataframe(attribution_results):
df = pd.DataFrame([r.model_dump() for r in attribution_results])
fig, content = attribution_bar(
data=df, title="Return Attribution", layout_kwargs={"height": 650}
)
assert content["layout"]["title"]["text"] == "Return Attribution"
assert content["layout"]["height"] == 650
def test_attribution_bar_missing_columns():
bad = pd.DataFrame({"period": ["Max"], "factor": ["f1"]})
with pytest.raises(ValueError, match="attribution_bar requires"):
attribution_bar(data=bad)
# ---------------------------------------------------------------------------
# rolling_factors_line
# ---------------------------------------------------------------------------
def test_rolling_factors_line_via_obbject(rolling_results):
"""By default the intercept is dropped from the stack."""
fig, content = rolling_factors_line(obbject_item=rolling_results)
trace_names = [t["name"] for t in content["data"]]
assert "Intercept" not in trace_names
assert "f1" in trace_names
assert "f2" in trace_names
assert all(t.get("stackgroup") == "one" for t in content["data"])
# No separate legend — every band labeled inline via annotations.
assert content["layout"]["showlegend"] is False
labels = {a["text"] for a in content["layout"]["annotations"]}
assert any("f1" in lbl for lbl in labels)
def test_rolling_factors_line_include_intercept(rolling_results):
"""``include_intercept=True`` adds the intercept as another stacked band."""
fig, content = rolling_factors_line(
obbject_item=rolling_results, include_intercept=True
)
trace_names = [t["name"] for t in content["data"]]
assert "Intercept" in trace_names
def test_rolling_factors_line_via_data_list(rolling_results):
payload = df_to_basemodel(pd.DataFrame([r.model_dump() for r in rolling_results]))
fig, content = rolling_factors_line(data=payload)
assert all(t["type"] == "scatter" for t in content["data"])
def test_rolling_factors_line_via_dataframe(rolling_results):
df = pd.DataFrame([r.model_dump() for r in rolling_results])
fig, content = rolling_factors_line(
data=df, title="Rolling Betas", layout_kwargs={"width": 900}
)
assert content["layout"]["title"]["text"] == "Rolling Betas"
assert content["layout"]["width"] == 900
def test_rolling_factors_line_missing_columns():
bad = pd.DataFrame({"date": ["2020-01-01"], "factor": ["f1"]})
with pytest.raises(ValueError, match="rolling_factors_line requires"):
rolling_factors_line(data=bad)
# ---------------------------------------------------------------------------
# QuantitativeViews dispatcher
# ---------------------------------------------------------------------------
def test_views_quantitative_factors(factors_results):
fig, content = QuantitativeViews.quantitative_factors(obbject_item=factors_results)
assert content["data"][0]["type"] == "heatmap"
def test_views_quantitative_risk_decomposition(rd_results):
fig, content = QuantitativeViews.quantitative_risk_decomposition(
obbject_item=rd_results
)
assert content["layout"]["barmode"] == "stack"
def test_views_quantitative_attribution(attribution_results):
fig, content = QuantitativeViews.quantitative_attribution(
obbject_item=attribution_results
)
assert content["layout"]["barmode"] == "relative"
def test_views_quantitative_rolling_factors(rolling_results):
fig, content = QuantitativeViews.quantitative_rolling_factors(
obbject_item=rolling_results
)
assert all(t["type"] == "scatter" for t in content["data"])

3716
openbb_platform/extensions/quantitative/uv.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff