mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 12:40:35 +00:00
* refactor openbb-technical for v5 * forward refs * linting * no python 3.15 in test matrix * reame * ty * move all params to body --------- Co-authored-by: deeleeramone <>
223 lines
7.8 KiB
Python
223 lines
7.8 KiB
Python
"""Tests for openbb_technical.signals.thresholds."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
from openbb_core.app.utils import df_to_basemodel
|
|
|
|
from openbb_technical.signals.thresholds import (
|
|
_DEFAULT_THRESHOLDS,
|
|
OscillatorSignal,
|
|
OscillatorSignalsQueryParams,
|
|
_compute_oscillator,
|
|
oscillator_signals,
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def long_records(ohlcv_df):
|
|
"""Monotone OHLCV — used for happy-path defaults."""
|
|
return df_to_basemodel(ohlcv_df.reset_index())
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def regime_change_df() -> pd.DataFrame:
|
|
"""A constructed series that visits overbought, neutral, and oversold regimes."""
|
|
n = 80
|
|
prices = np.full(n, 100.0)
|
|
prices[10:25] = np.linspace(100, 150, 15)
|
|
prices[25:40] = 150.0
|
|
prices[40:55] = np.linspace(150, 70, 15)
|
|
prices[55:] = 70.0
|
|
return pd.DataFrame(
|
|
{
|
|
"open": prices,
|
|
"high": prices + 1,
|
|
"low": prices - 1,
|
|
"close": prices,
|
|
"volume": np.full(n, 1000.0),
|
|
},
|
|
index=pd.date_range("2021-01-01", periods=n, freq="D", name="date"),
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def regime_records(regime_change_df) -> list:
|
|
return df_to_basemodel(regime_change_df.reset_index())
|
|
|
|
|
|
class TestOscillatorSignalsBranches:
|
|
"""Cover every Literal branch of ``indicator``."""
|
|
|
|
@pytest.mark.parametrize("indicator", list(_DEFAULT_THRESHOLDS.keys()))
|
|
def test_each_indicator_returns_dense_series(self, long_records, indicator):
|
|
result = oscillator_signals(
|
|
OscillatorSignalsQueryParams(
|
|
data=long_records, indicator=indicator, length=14
|
|
)
|
|
)
|
|
assert len(result.results) == len(long_records)
|
|
assert all(isinstance(r, OscillatorSignal) for r in result.results)
|
|
|
|
@pytest.mark.parametrize("indicator", list(_DEFAULT_THRESHOLDS.keys()))
|
|
def test_first_row_has_no_crossing_flags(self, long_records, indicator):
|
|
"""The first bar has no prior — every flag must be False."""
|
|
result = oscillator_signals(
|
|
OscillatorSignalsQueryParams(
|
|
data=long_records, indicator=indicator, length=14
|
|
)
|
|
)
|
|
first = result.results[0]
|
|
assert first.crossed_into_overbought is False
|
|
assert first.crossed_into_oversold is False
|
|
assert first.crossed_out_of_overbought is False
|
|
assert first.crossed_out_of_oversold is False
|
|
|
|
|
|
class TestThresholdDefaults:
|
|
"""Confirm the documented per-indicator defaults are actually applied."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"indicator,ob,os_",
|
|
[
|
|
("rsi", 70.0, 30.0),
|
|
("mfi", 80.0, 20.0),
|
|
("stoch", 80.0, 20.0),
|
|
("williams_r", -20.0, -80.0),
|
|
("cci", 100.0, -100.0),
|
|
],
|
|
)
|
|
def test_default_table(self, indicator, ob, os_):
|
|
assert _DEFAULT_THRESHOLDS[indicator] == (ob, os_)
|
|
|
|
def test_explicit_overrides_take_precedence(self, regime_records):
|
|
"""If both thresholds are given, defaults must NOT be used."""
|
|
result = oscillator_signals(
|
|
OscillatorSignalsQueryParams(
|
|
data=regime_records,
|
|
indicator="rsi",
|
|
length=14,
|
|
overbought_threshold=-1e9,
|
|
oversold_threshold=-2e9,
|
|
)
|
|
)
|
|
non_warmup = [r for r in result.results if r.value != 0.0]
|
|
assert all(r.regime == "overbought" for r in non_warmup)
|
|
|
|
def test_overbought_only_override(self, regime_records):
|
|
"""Only setting ``overbought_threshold`` keeps the default oversold band."""
|
|
result = oscillator_signals(
|
|
OscillatorSignalsQueryParams(
|
|
data=regime_records,
|
|
indicator="rsi",
|
|
length=14,
|
|
overbought_threshold=200.0,
|
|
)
|
|
)
|
|
regimes = {r.regime for r in result.results}
|
|
assert "overbought" not in regimes
|
|
assert "oversold" in regimes
|
|
|
|
def test_oversold_only_override(self, regime_records):
|
|
"""Only setting ``oversold_threshold`` keeps the default overbought band."""
|
|
result = oscillator_signals(
|
|
OscillatorSignalsQueryParams(
|
|
data=regime_records,
|
|
indicator="rsi",
|
|
length=14,
|
|
oversold_threshold=-200.0,
|
|
)
|
|
)
|
|
regimes = {r.regime for r in result.results}
|
|
assert "oversold" not in regimes
|
|
assert "overbought" in regimes
|
|
|
|
|
|
class TestRegimeAndCrossings:
|
|
def test_visits_all_three_regimes(self, regime_records):
|
|
result = oscillator_signals(
|
|
OscillatorSignalsQueryParams(
|
|
data=regime_records, indicator="rsi", length=14
|
|
)
|
|
)
|
|
regimes = {r.regime for r in result.results}
|
|
assert regimes == {"overbought", "neutral", "oversold"}
|
|
|
|
def test_crossing_flag_count_matches_regime_changes(self, regime_records):
|
|
"""The crossings_into_X count == number of regime->X transitions."""
|
|
result = oscillator_signals(
|
|
OscillatorSignalsQueryParams(
|
|
data=regime_records, indicator="rsi", length=14
|
|
)
|
|
)
|
|
labels = [r.regime for r in result.results]
|
|
into_ob_manual = sum(
|
|
1
|
|
for i in range(1, len(labels))
|
|
if labels[i] == "overbought" and labels[i - 1] != "overbought"
|
|
)
|
|
into_os_manual = sum(
|
|
1
|
|
for i in range(1, len(labels))
|
|
if labels[i] == "oversold" and labels[i - 1] != "oversold"
|
|
)
|
|
into_ob_flag = sum(r.crossed_into_overbought for r in result.results)
|
|
into_os_flag = sum(r.crossed_into_oversold for r in result.results)
|
|
assert into_ob_flag == into_ob_manual
|
|
assert into_os_flag == into_os_manual
|
|
|
|
def test_crossing_out_matches_regime_exits(self, regime_records):
|
|
result = oscillator_signals(
|
|
OscillatorSignalsQueryParams(
|
|
data=regime_records, indicator="rsi", length=14
|
|
)
|
|
)
|
|
labels = [r.regime for r in result.results]
|
|
out_ob_manual = sum(
|
|
1
|
|
for i in range(1, len(labels))
|
|
if labels[i - 1] == "overbought" and labels[i] != "overbought"
|
|
)
|
|
out_os_manual = sum(
|
|
1
|
|
for i in range(1, len(labels))
|
|
if labels[i - 1] == "oversold" and labels[i] != "oversold"
|
|
)
|
|
out_ob_flag = sum(r.crossed_out_of_overbought for r in result.results)
|
|
out_os_flag = sum(r.crossed_out_of_oversold for r in result.results)
|
|
assert out_ob_flag == out_ob_manual
|
|
assert out_os_flag == out_os_manual
|
|
|
|
|
|
class TestComputeOscillatorDispatcher:
|
|
"""Direct unit-coverage on the dispatch helper."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"indicator",
|
|
["rsi", "mfi", "stoch", "williams_r", "cci"],
|
|
)
|
|
def test_dispatch(self, ohlcv_df, indicator):
|
|
series = _compute_oscillator(ohlcv_df, indicator, length=14, target="close")
|
|
assert series.ndim == 1
|
|
assert series.dropna().shape[0] > 0
|
|
|
|
|
|
class TestQueryParamModel:
|
|
def test_defaults(self, long_records):
|
|
params = OscillatorSignalsQueryParams(data=long_records, indicator="rsi")
|
|
assert params.target == "close"
|
|
assert params.index == "date"
|
|
assert params.length == 14
|
|
assert params.overbought_threshold is None
|
|
assert params.oversold_threshold is None
|
|
|
|
def test_indicator_required(self, long_records):
|
|
with pytest.raises(Exception):
|
|
OscillatorSignalsQueryParams(data=long_records) # type: ignore[call-arg]
|
|
|
|
def test_rejects_invalid_indicator(self, long_records):
|
|
with pytest.raises(Exception):
|
|
OscillatorSignalsQueryParams(data=long_records, indicator="macd") # type: ignore[arg-type]
|