mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-11-03 16:02:04 +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 <>
192 lines
6.8 KiB
Python
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)
|