Files
ferro-ta/tests/unit/test_property_based.py
Pratik Bhadane 436954138f chore: prepare v1.1.0 release
Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
2026-03-30 12:45:52 +05:30

264 lines
10 KiB
Python

"""Property-based tests (Hypothesis) for ferro-ta."""
import numpy as np
import pytest
from ferro_ta import ATR, BBANDS, CDLDOJI, EMA, MACD, OBV, RSI, SMA, WMA
try:
from hypothesis import given, settings
from hypothesis.strategies import floats, integers, lists
HAS_HYPOTHESIS = True
except ImportError:
HAS_HYPOTHESIS = False
if HAS_HYPOTHESIS:
# Strategy: finite floats, reasonable length
finite_floats = floats(
min_value=1e-6, max_value=1e6, allow_nan=False, allow_infinity=False
)
price_arrays = lists(finite_floats, min_size=2, max_size=500).map(np.array)
periods = integers(min_value=1, max_value=100)
@given(price_arrays, periods)
@settings(max_examples=50, deadline=5000)
def test_sma_output_length_matches_input(close, timeperiod):
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 1:
timeperiod = 1
result = SMA(close, timeperiod=timeperiod)
assert len(result) == len(close)
@given(price_arrays, periods)
@settings(max_examples=50, deadline=5000)
def test_ema_output_length_matches_input(close, timeperiod):
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 1:
timeperiod = 1
result = EMA(close, timeperiod=timeperiod)
assert len(result) == len(close)
@given(price_arrays, periods)
@settings(max_examples=50, deadline=5000)
def test_rsi_output_length_matches_input(close, timeperiod):
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 1:
timeperiod = 1
result = RSI(close, timeperiod=timeperiod)
assert len(result) == len(close)
@given(price_arrays, periods)
@settings(max_examples=30, deadline=5000)
def test_bbands_three_outputs_same_length(close, timeperiod):
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 1:
timeperiod = 1
upper, middle, lower = BBANDS(close, timeperiod=timeperiod)
assert len(upper) == len(close)
assert len(middle) == len(close)
assert len(lower) == len(close)
@given(
lists(finite_floats, min_size=3, max_size=100).map(np.array),
lists(finite_floats, min_size=3, max_size=100).map(np.array),
lists(finite_floats, min_size=3, max_size=100).map(np.array),
lists(finite_floats, min_size=3, max_size=100).map(np.array),
)
@settings(max_examples=20, deadline=5000)
def test_cdl_pattern_output_values_in_set(open_, high, low, close):
n = min(len(open_), len(high), len(low), len(close))
open_ = open_[:n]
high = high[:n]
low = low[:n]
close = close[:n]
result = CDLDOJI(open_, high, low, close)
assert len(result) == n
assert all(v in (-100, 0, 100) for v in result)
# ------------------------------------------------------------------
# EMA extended properties
# ------------------------------------------------------------------
@given(price_arrays, integers(min_value=2, max_value=50))
@settings(max_examples=50, deadline=5000)
def test_ema_values_finite_when_input_finite(close, timeperiod):
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 1:
timeperiod = 1
result = EMA(close, timeperiod=timeperiod)
assert np.all(np.isfinite(result) | np.isnan(result))
# All non-NaN values must be finite
valid = result[~np.isnan(result)]
assert np.all(np.isfinite(valid))
@given(price_arrays)
@settings(max_examples=50, deadline=5000)
def test_ema_period_1_equals_input(close):
result = EMA(close, timeperiod=1)
assert len(result) == len(close)
# EMA with period=1 should reproduce the input exactly
np.testing.assert_allclose(result, close, rtol=1e-10)
# ------------------------------------------------------------------
# BBANDS extended properties
# ------------------------------------------------------------------
@given(price_arrays, integers(min_value=2, max_value=50))
@settings(max_examples=30, deadline=5000)
def test_bbands_upper_ge_middle_ge_lower(close, timeperiod):
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 1:
timeperiod = 1
upper, middle, lower = BBANDS(close, timeperiod=timeperiod)
# Where all three are finite, upper >= middle >= lower
mask = np.isfinite(upper) & np.isfinite(middle) & np.isfinite(lower)
assert np.all(upper[mask] >= middle[mask] - 1e-10)
assert np.all(middle[mask] >= lower[mask] - 1e-10)
@given(price_arrays, integers(min_value=2, max_value=50))
@settings(max_examples=30, deadline=5000)
def test_bbands_middle_equals_sma(close, timeperiod):
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 1:
timeperiod = 1
_, middle, _ = BBANDS(close, timeperiod=timeperiod)
sma = SMA(close, timeperiod=timeperiod)
mask = np.isfinite(middle) & np.isfinite(sma)
np.testing.assert_allclose(middle[mask], sma[mask], rtol=1e-10)
# ------------------------------------------------------------------
# MACD properties
# ------------------------------------------------------------------
@given(
lists(finite_floats, min_size=40, max_size=500).map(np.array),
)
@settings(max_examples=50, deadline=5000)
def test_macd_output_lengths(close):
macd, signal, hist = MACD(close, fastperiod=12, slowperiod=26, signalperiod=9)
assert len(macd) == len(close)
assert len(signal) == len(close)
assert len(hist) == len(close)
@given(
lists(finite_floats, min_size=40, max_size=500).map(np.array),
)
@settings(max_examples=50, deadline=5000)
def test_macd_histogram_equals_macd_minus_signal(close):
macd, signal, hist = MACD(close, fastperiod=12, slowperiod=26, signalperiod=9)
mask = np.isfinite(macd) & np.isfinite(signal) & np.isfinite(hist)
if np.any(mask):
np.testing.assert_allclose(
hist[mask], macd[mask] - signal[mask], atol=1e-10
)
# ------------------------------------------------------------------
# ATR properties
# ------------------------------------------------------------------
@given(
lists(finite_floats, min_size=20, max_size=500).map(np.array),
integers(min_value=2, max_value=50),
)
@settings(max_examples=50, deadline=5000)
def test_atr_output_length(prices, timeperiod):
# Build high/low/close from prices with valid OHLC relationships
close = prices
high = prices * 1.01
low = prices * 0.99
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 1:
timeperiod = 1
result = ATR(high, low, close, timeperiod=timeperiod)
assert len(result) == len(close)
@given(
lists(finite_floats, min_size=20, max_size=500).map(np.array),
integers(min_value=2, max_value=50),
)
@settings(max_examples=50, deadline=5000)
def test_atr_non_negative(prices, timeperiod):
close = prices
high = prices * 1.01
low = prices * 0.99
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 1:
timeperiod = 1
result = ATR(high, low, close, timeperiod=timeperiod)
valid = result[~np.isnan(result)]
assert np.all(valid >= 0)
# ------------------------------------------------------------------
# WMA properties
# ------------------------------------------------------------------
@given(price_arrays, integers(min_value=2, max_value=50))
@settings(max_examples=50, deadline=5000)
def test_wma_output_length(close, timeperiod):
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 1:
timeperiod = 1
result = WMA(close, timeperiod=timeperiod)
assert len(result) == len(close)
@given(
lists(finite_floats, min_size=20, max_size=500).map(np.array),
integers(min_value=2, max_value=50),
)
@settings(max_examples=50, deadline=5000)
def test_wma_leading_nans(close, timeperiod):
if len(close) < timeperiod:
timeperiod = min(timeperiod, len(close))
if timeperiod < 2:
timeperiod = 2
result = WMA(close, timeperiod=timeperiod)
# First (timeperiod - 1) values should be NaN
assert np.all(np.isnan(result[: timeperiod - 1]))
# ------------------------------------------------------------------
# OBV properties
# ------------------------------------------------------------------
@given(
lists(finite_floats, min_size=20, max_size=500).map(np.array),
lists(finite_floats, min_size=20, max_size=500).map(np.array),
)
@settings(max_examples=50, deadline=5000)
def test_obv_output_length(close, volume):
n = min(len(close), len(volume))
close = close[:n]
volume = volume[:n]
result = OBV(close, volume)
assert len(result) == n
@given(
lists(finite_floats, min_size=20, max_size=500).map(np.array),
lists(finite_floats, min_size=20, max_size=500).map(np.array),
)
@settings(max_examples=50, deadline=5000)
def test_obv_all_finite(close, volume):
n = min(len(close), len(volume))
close = close[:n]
volume = volume[:n]
result = OBV(close, volume)
assert np.all(np.isfinite(result))
@pytest.mark.skipif(not HAS_HYPOTHESIS, reason="hypothesis not installed")
class TestPropertyBased:
"""Placeholder for running property-based tests as a class."""
def test_import(self):
assert HAS_HYPOTHESIS