Files
OpenBB/openbb_platform/extensions/technical/tests/signals/test_breakouts.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

192 lines
6.8 KiB
Python

"""Tests for openbb_technical.signals.breakouts."""
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.breakouts import (
BreakoutEvent,
BreakoutsQueryParams,
_channel_bands,
breakouts,
)
@pytest.fixture(scope="module")
def long_records(ohlcv_df):
"""Monotone OHLCV fixture — every bar is a new high (always breaking upside)."""
return df_to_basemodel(ohlcv_df.reset_index())
@pytest.fixture(scope="module")
def regime_records() -> list:
"""OHLCV that produces both upside and downside breakouts.
Builds a flat base, a rally that punches above the channel, a sideways
pause, a sell-off through the lower band, then another flat period.
"""
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
df = 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"),
)
return df_to_basemodel(df.reset_index())
@pytest.fixture(scope="module")
def flat_records() -> list:
"""A flat series — should produce zero breakouts under either method."""
n = 60
prices = np.full(n, 100.0)
df = pd.DataFrame(
{
"open": prices,
"high": prices + 0.01,
"low": prices - 0.01,
"close": prices,
"volume": np.full(n, 1000.0),
},
index=pd.date_range("2021-01-01", periods=n, freq="D", name="date"),
)
return df_to_basemodel(df.reset_index())
class TestMethodBranches:
@pytest.mark.parametrize("method", ["donchian", "bollinger"])
def test_each_method_returns_breakout_events(self, regime_records, method):
result = breakouts(
BreakoutsQueryParams(data=regime_records, method=method, length=20)
)
assert result.results
assert all(isinstance(r, BreakoutEvent) for r in result.results)
def test_bollinger_uses_default_std_when_not_given(self, regime_records):
default = breakouts(
BreakoutsQueryParams(data=regime_records, method="bollinger", length=20)
)
explicit = breakouts(
BreakoutsQueryParams(
data=regime_records, method="bollinger", length=20, band_std=2.0
)
)
assert len(default.results) == len(explicit.results)
def test_bollinger_smaller_std_yields_more_breakouts(self, regime_records):
wide = breakouts(
BreakoutsQueryParams(
data=regime_records, method="bollinger", length=20, band_std=3.0
)
)
narrow = breakouts(
BreakoutsQueryParams(
data=regime_records, method="bollinger", length=20, band_std=1.0
)
)
assert len(narrow.results) > len(wide.results)
class TestSparseEmission:
def test_flat_series_emits_no_events(self, flat_records):
result = breakouts(
BreakoutsQueryParams(data=flat_records, method="donchian", length=20)
)
assert result.results == []
def test_flat_series_emits_no_events_bollinger(self, flat_records):
result = breakouts(
BreakoutsQueryParams(data=flat_records, method="bollinger", length=20)
)
assert result.results == []
def test_events_are_sparse_subset_of_bars(self, regime_records):
result = breakouts(
BreakoutsQueryParams(data=regime_records, method="donchian", length=20)
)
assert 0 < len(result.results) < len(regime_records)
class TestEventFields:
def test_upside_direction(self, long_records):
"""A strictly monotone-rising series produces only upside breakouts."""
result = breakouts(
BreakoutsQueryParams(data=long_records, method="donchian", length=20)
)
assert result.results
assert all(r.direction == "upside" for r in result.results)
assert all(r.magnitude > 0 for r in result.results)
assert all(r.price > r.band for r in result.results)
def test_downside_direction(self, regime_records):
result = breakouts(
BreakoutsQueryParams(data=regime_records, method="donchian", length=20)
)
downsides = [r for r in result.results if r.direction == "downside"]
assert downsides
assert all(r.magnitude < 0 for r in downsides)
assert all(r.price < r.band for r in downsides)
def test_bars_in_range_monotone_for_first_event(self, regime_records):
"""``bars_in_range`` on the first event equals the bar index."""
result = breakouts(
BreakoutsQueryParams(data=regime_records, method="donchian", length=20)
)
first = result.results[0]
assert first.bars_in_range >= 0
def test_bars_in_range_is_inter_event_gap(self, regime_records):
"""For each event after the first, bars_in_range = gap to prior event."""
result = breakouts(
BreakoutsQueryParams(data=regime_records, method="donchian", length=20)
)
assert len(result.results) >= 2
for r in result.results[1:]:
assert r.bars_in_range >= 1
class TestChannelBandsDispatcher:
@pytest.mark.parametrize("method", ["donchian", "bollinger"])
def test_each_method_returns_two_series(self, ohlcv_df, method):
upper, lower = _channel_bands(ohlcv_df, method, length=20, band_std=None)
assert upper.ndim == 1
assert lower.ndim == 1
valid = upper.dropna().index.intersection(lower.dropna().index)
assert (upper.loc[valid] >= lower.loc[valid]).all()
def test_bollinger_band_std_passed_through(self, ohlcv_df):
u1, l1 = _channel_bands(ohlcv_df, "bollinger", length=20, band_std=1.0)
u3, l3 = _channel_bands(ohlcv_df, "bollinger", length=20, band_std=3.0)
valid = u1.dropna().index.intersection(u3.dropna().index)
assert (u3.loc[valid] >= u1.loc[valid]).all()
assert (l3.loc[valid] <= l1.loc[valid]).all()
class TestQueryParamModel:
def test_defaults(self, long_records):
params = BreakoutsQueryParams(data=long_records)
assert params.index == "date"
assert params.method == "donchian"
assert params.length == 20
assert params.band_std is None
def test_rejects_invalid_method(self, long_records):
with pytest.raises(Exception):
BreakoutsQueryParams(data=long_records, method="keltner") # type: ignore[arg-type]
def test_band_std_must_be_positive(self, long_records):
with pytest.raises(Exception):
BreakoutsQueryParams(data=long_records, band_std=-1.0)