mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-11-06 09:22:26 +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 <>
146 lines
4.9 KiB
Python
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 == []
|