Files
OpenBB/openbb_platform/extensions/quantitative/tests/test_metrics.py
Danglewood 32a2870c70 [V5] Fix a Number of V5 Bugs (#7707)
* fix a number of v5 bugs

* fix lint error

* more lint

* fixes for quantitative, technical, charting, famafrench for version bumps

---------

Co-authored-by: deeleeramone <>
2026-09-28 01:43:14 -07:00

217 lines
7.9 KiB
Python

import numpy as np
import pandas as pd
import pytest
from statsmodels.stats.diagnostic import lilliefors
from openbb_quantitative import metrics, stats
MONTH_STARTS = pd.date_range("2023-02-01", periods=7, freq="MS")
MONTH_ENDS = MONTH_STARTS - pd.Timedelta(days=1)
def _month_end_prices(market_excess, risk_free, alpha=0.002, beta=1.5):
prices = [100.0]
for market, rf in zip(market_excess, risk_free):
prices.append(prices[-1] * (1 + rf + alpha + beta * market))
return [
{"date": day.date(), "close": price} for day, price in zip(MONTH_ENDS, prices)
]
def _factors(market_excess, risk_free, index):
return pd.DataFrame(
{"mkt_rf": market_excess, "smb": 0.0, "hml": 0.0, "rf": risk_free},
index=index,
)
def test_normality(prices_data):
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():
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):
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_capm_recovers_known_beta(monkeypatch):
market_excess = [-0.03, -0.01, 0.01, 0.02, 0.04, -0.02]
risk_free = [0.001, 0.002, 0.001, 0.003, 0.002, 0.001]
factors = _factors(market_excess, risk_free, MONTH_STARTS[:6])
monkeypatch.setattr(
"openbb_quantitative.helpers.get_fama_raw", lambda start, end: factors
)
out = metrics.capm(
metrics.CapmQueryParams(
data=_month_end_prices(market_excess, risk_free), target="close"
)
).results
assert out.market_risk == pytest.approx(1.5)
assert out.systematic_risk == pytest.approx(1.0)
assert out.idiosyncratic_risk == pytest.approx(0.0, abs=1e-9)
def test_capm_single_month_of_daily_prices_raises():
days = pd.bdate_range("2023-03-01", "2023-03-31")
data = [{"date": day.date(), "close": 100.0 + i} for i, day in enumerate(days)]
with pytest.raises(
ValueError, match="at least 3 monthly returns; the data produced 0"
):
metrics.capm(metrics.CapmQueryParams(data=data, target="close"))
def test_capm_two_monthly_returns_raises():
data = _month_end_prices([0.01, 0.02], [0.001, 0.001])
with pytest.raises(
ValueError, match="at least 3 monthly returns; the data produced 2"
):
metrics.capm(metrics.CapmQueryParams(data=data, target="close"))
def test_capm_insufficient_factor_overlap_raises(monkeypatch):
market_excess = [-0.03, -0.01, 0.01, 0.02, 0.04, -0.02]
risk_free = [0.001] * 6
factors = _factors([0.01, 0.02], [0.001, 0.001], MONTH_STARTS[:2])
monkeypatch.setattr(
"openbb_quantitative.helpers.get_fama_raw", lambda start, end: factors
)
with pytest.raises(
ValueError, match="overlapping the Fama-French factors; found 2"
):
metrics.capm(
metrics.CapmQueryParams(
data=_month_end_prices(market_excess, risk_free), target="close"
)
)
def test_normality_kolmogorov_smirnov_uses_sample_moments():
sample = np.random.default_rng(3).normal(100.0, 2.0, 400)
data = [{"close": float(value)} for value in sample]
out = metrics.normality(
metrics.NormalityQueryParams(data=data, target="close")
).results
statistic, p_value = lilliefors(sample, dist="norm")
assert out.kolmogorov_smirnov_statistic == pytest.approx(statistic)
assert out.kolmogorov_smirnov_p_value == pytest.approx(p_value)
assert out.kolmogorov_smirnov_p_value > 0.05
def test_normality_kolmogorov_smirnov_rejects_heavy_tails():
sample = np.random.default_rng(3).standard_t(2, 400)
data = [{"close": float(value)} for value in sample]
out = metrics.normality(
metrics.NormalityQueryParams(data=data, target="close")
).results
assert out.kolmogorov_smirnov_p_value < 0.05
def test_stats_dispersion_matches_summary():
data = [{"close": float(value)} for value in [1, 2, 3, 4, 5]]
summary = metrics.summary(metrics.SummaryQueryParams(data=data, target="close"))
stdev = stats.stdev(stats.StatsStdevQueryParams(data=data, target="close"))
variance = stats.variance(stats.StatsVarianceQueryParams(data=data, target="close"))
assert stdev.results.stdev == pytest.approx(summary.results.std)
assert variance.results.variance == pytest.approx(summary.results.var)
assert summary.results.var == pytest.approx(2.5)
def test_unitroot_test(prices_data):
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):
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):
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):
out = metrics.unitroot_test(
metrics.UnitRootTestQueryParams(data=prices_data, target="close", nlags=6)
)
assert out.results.kpss_nlags == 6
def test_summary(prices_data):
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"]