Files
OpenBB/openbb_platform/extensions/technical/tests/signals/test_crossovers.py
Danglewood d3d33a219b [V5] openbb-technical: Refactor for V5 & Add More Quick Win Endpoints (#7502)
* 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 <>
2026-05-29 10:48:51 +03:00

146 lines
4.9 KiB
Python

"""Tests for openbb_technical.signals.crossovers."""
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.crossovers import (
CrossoverEvent,
CrossoversQueryParams,
crossovers,
)
@pytest.fixture(scope="module")
def monotone_records(ohlcv_df):
"""Strictly increasing OHLCV — fast and slow MAs never cross."""
return df_to_basemodel(ohlcv_df.reset_index())
@pytest.fixture(scope="module")
def sinusoidal_records():
"""Sinusoidal close series that guarantees multiple bullish/bearish crossovers."""
periods = 200
t = np.arange(periods)
close = 100 + 10 * np.sin(t / 8.0)
df = pd.DataFrame(
{
"open": close,
"high": close + 0.5,
"low": close - 0.5,
"close": close,
"volume": close * 100,
},
index=pd.date_range("2020-01-01", periods=periods, freq="D", name="date"),
)
return df_to_basemodel(df.reset_index())
class TestCrossoversBehaviour:
def test_default_returns_obbject(self, sinusoidal_records):
result = crossovers(
CrossoversQueryParams(
data=sinusoidal_records, fast_length=5, slow_length=20
)
)
assert result.results
assert all(isinstance(r, CrossoverEvent) for r in result.results)
@pytest.mark.parametrize("mamode", ["sma", "ema", "wma", "hma", "zlma"])
def test_each_mamode_produces_events(self, sinusoidal_records, mamode):
result = crossovers(
CrossoversQueryParams(
data=sinusoidal_records, fast_length=5, slow_length=20, mamode=mamode
)
)
assert result.results
assert all(r.direction in {"bullish", "bearish"} for r in result.results)
def test_sparse_output_no_crossovers(self, monotone_records):
"""Strictly increasing series produces zero crossovers."""
result = crossovers(
CrossoversQueryParams(data=monotone_records, fast_length=5, slow_length=20)
)
assert result.results == []
def test_distance_sign_and_magnitude(self, sinusoidal_records):
"""`distance` equals fast - slow and matches direction polarity."""
result = crossovers(
CrossoversQueryParams(
data=sinusoidal_records, fast_length=5, slow_length=20, mamode="sma"
)
)
assert result.results
for ev in result.results:
assert ev.distance == pytest.approx(ev.fast_value - ev.slow_value)
if ev.direction == "bullish":
assert ev.distance > 0
else:
assert ev.distance < 0
def test_alternating_directions(self, sinusoidal_records):
"""Adjacent crossover events must alternate direction."""
result = crossovers(
CrossoversQueryParams(
data=sinusoidal_records, fast_length=5, slow_length=20, mamode="sma"
)
)
directions = [e.direction for e in result.results]
for prev, nxt in zip(directions, directions[1:]):
assert prev != nxt
class TestCrossoversValidator:
def test_slow_must_exceed_fast(self, sinusoidal_records):
with pytest.raises(
ValueError, match="slow_length must be greater than fast_length"
):
CrossoversQueryParams(
data=sinusoidal_records, fast_length=50, slow_length=20
)
def test_slow_equal_to_fast_rejected(self, sinusoidal_records):
with pytest.raises(
ValueError, match="slow_length must be greater than fast_length"
):
CrossoversQueryParams(
data=sinusoidal_records, fast_length=20, slow_length=20
)
class TestCrossoversQueryParamDefaults:
def test_defaults(self, sinusoidal_records):
params = CrossoversQueryParams(data=sinusoidal_records)
assert params.target == "close"
assert params.index == "date"
assert params.fast_length == 20
assert params.slow_length == 50
assert params.mamode == "sma"
class TestCrossoversEdgeCases:
def test_empty_diff_yields_no_events(self):
"""Insufficient warmup for ``hma`` leaves the diff empty after dropna."""
periods = 20
close = np.arange(1, periods + 1, dtype="float64")
df = pd.DataFrame(
{
"open": close,
"high": close + 0.5,
"low": close - 0.5,
"close": close,
"volume": close * 100,
},
index=pd.date_range("2020-01-01", periods=periods, freq="D", name="date"),
)
records = df_to_basemodel(df.reset_index())
result = crossovers(
CrossoversQueryParams(
data=records, fast_length=5, slow_length=20, mamode="hma"
)
)
assert result.results == []