mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-11-03 16:02:04 +00:00
[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:
163
.github/workflows/publish-openbb-quantitative.yml
vendored
Normal file
163
.github/workflows/publish-openbb-quantitative.yml
vendored
Normal 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
|
||||
122
.github/workflows/test-unit-openbb-quantitative.yml
vendored
Normal file
122
.github/workflows/test-unit-openbb-quantitative.yml
vendored
Normal 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
|
||||
135
openbb_platform/extensions/quantitative/README.md
vendored
135
openbb_platform/extensions/quantitative/README.md
vendored
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Quantitative analysis extension for OpenBB Platform."""
|
||||
"""OpenBB Quantitative Analysis Extension."""
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)}'."
|
||||
)
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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_)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Charting views for the openbb-quantitative extension."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
2241
openbb_platform/extensions/quantitative/poetry.lock
generated
vendored
2241
openbb_platform/extensions/quantitative/poetry.lock
generated
vendored
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
]
|
||||
|
||||
89
openbb_platform/extensions/quantitative/tests/conftest.py
Normal file
89
openbb_platform/extensions/quantitative/tests/conftest.py
Normal 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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
109
openbb_platform/extensions/quantitative/tests/test_factors.py
Normal file
109
openbb_platform/extensions/quantitative/tests/test_factors.py
Normal 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
|
||||
125
openbb_platform/extensions/quantitative/tests/test_metrics.py
Normal file
125
openbb_platform/extensions/quantitative/tests/test_metrics.py
Normal 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"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
177
openbb_platform/extensions/quantitative/tests/test_rolling.py
Normal file
177
openbb_platform/extensions/quantitative/tests/test_rolling.py
Normal 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)
|
||||
39
openbb_platform/extensions/quantitative/tests/test_router.py
Normal file
39
openbb_platform/extensions/quantitative/tests/test_router.py
Normal 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)
|
||||
83
openbb_platform/extensions/quantitative/tests/test_stats.py
Normal file
83
openbb_platform/extensions/quantitative/tests/test_stats.py
Normal 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
|
||||
@@ -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])
|
||||
|
||||
259
openbb_platform/extensions/quantitative/tests/test_views.py
Normal file
259
openbb_platform/extensions/quantitative/tests/test_views.py
Normal 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
3716
openbb_platform/extensions/quantitative/uv.lock
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user