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.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,546 @@
|
||||
"""
|
||||
v1.1.0 backtest feature tests.
|
||||
|
||||
Covers:
|
||||
- CommissionModel: total_cost, presets, round-trip JSON, save/load
|
||||
- Currency: INR/USD formatting, from_code lookup
|
||||
- BacktestEngine: initial_capital, commission_model, trailing_stop, benchmark
|
||||
- AdvancedBacktestResult: equity_abs, pnl_abs in trade log, summary fields
|
||||
- Volatility-target position sizing
|
||||
- Benchmark comparison metrics
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from ferro_ta._ferro_ta import CommissionModel
|
||||
|
||||
from ferro_ta.analysis.backtest import (
|
||||
EUR,
|
||||
GBP,
|
||||
INR,
|
||||
JPY,
|
||||
USD,
|
||||
USDT,
|
||||
BacktestEngine,
|
||||
Currency,
|
||||
format_currency,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def close_500():
|
||||
"""500-bar synthetic close price series."""
|
||||
rng = np.random.default_rng(12345)
|
||||
return np.cumprod(1.0 + rng.standard_normal(500) * 0.01) * 100.0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ohlcv_500(close_500):
|
||||
close = close_500
|
||||
high = close * 1.005
|
||||
low = close * 0.995
|
||||
open_ = close * 0.999
|
||||
volume = np.full(len(close), 1_000_000.0)
|
||||
return open_, high, low, close, volume
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestCommissionModel
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCommissionModel:
|
||||
def test_zero_model_costs_nothing(self):
|
||||
m = CommissionModel.zero()
|
||||
assert m.total_cost(100_000, 1, True) == 0.0
|
||||
assert m.total_cost(100_000, 1, False) == 0.0
|
||||
|
||||
def test_flat_per_order(self):
|
||||
m = CommissionModel()
|
||||
m.flat_per_order = 20.0
|
||||
assert m.total_cost(100_000, 1, True) == pytest.approx(20.0)
|
||||
assert m.total_cost(100_000, 1, False) == pytest.approx(20.0)
|
||||
|
||||
def test_max_brokerage_cap(self):
|
||||
m = CommissionModel()
|
||||
m.flat_per_order = 0.0
|
||||
m.rate_of_value = 0.001 # 0.1%
|
||||
m.max_brokerage = 20.0
|
||||
# 0.1% of 50_000 = 50, capped at 20
|
||||
assert m.total_cost(50_000, 1, True) == pytest.approx(20.0)
|
||||
# 0.1% of 5_000 = 5, not capped
|
||||
assert m.total_cost(5_000, 1, True) == pytest.approx(5.0)
|
||||
|
||||
def test_stt_buy_side_only(self):
|
||||
m = CommissionModel()
|
||||
m.stt_rate = 0.001
|
||||
m.stt_on_buy = True
|
||||
m.stt_on_sell = False
|
||||
buy_cost = m.total_cost(100_000, 1, True)
|
||||
sell_cost = m.total_cost(100_000, 1, False)
|
||||
assert buy_cost == pytest.approx(100.0)
|
||||
assert sell_cost == pytest.approx(0.0)
|
||||
|
||||
def test_stt_sell_side_only(self):
|
||||
m = CommissionModel()
|
||||
m.stt_rate = 0.00025
|
||||
m.stt_on_buy = False
|
||||
m.stt_on_sell = True
|
||||
buy_cost = m.total_cost(100_000, 1, True)
|
||||
sell_cost = m.total_cost(100_000, 1, False)
|
||||
assert buy_cost == pytest.approx(0.0)
|
||||
assert sell_cost == pytest.approx(25.0)
|
||||
|
||||
def test_gst_on_brokerage_exchange_not_stt(self):
|
||||
m = CommissionModel()
|
||||
m.flat_per_order = 20.0
|
||||
m.exchange_charges_rate = 0.0001
|
||||
m.gst_rate = 0.18
|
||||
m.stt_rate = 0.001
|
||||
m.stt_on_sell = True
|
||||
# GST = 0.18 * (20 + 0.0001 * 100_000) = 0.18 * 30 = 5.4
|
||||
# STT = 100 (sell side)
|
||||
total = m.total_cost(100_000, 1, False)
|
||||
expected_gst = 0.18 * (20.0 + 0.0001 * 100_000)
|
||||
assert total == pytest.approx(20.0 + 100.0 + 0.0001 * 100_000 + expected_gst)
|
||||
|
||||
def test_stamp_duty_buy_only(self):
|
||||
m = CommissionModel()
|
||||
m.stamp_duty_rate = 0.00015
|
||||
buy_cost = m.total_cost(100_000, 1, True)
|
||||
sell_cost = m.total_cost(100_000, 1, False)
|
||||
assert buy_cost == pytest.approx(15.0)
|
||||
assert sell_cost == pytest.approx(0.0)
|
||||
|
||||
def test_per_lot_charge(self):
|
||||
m = CommissionModel()
|
||||
m.per_lot = 2.0
|
||||
# 5 lots
|
||||
assert m.total_cost(50_000, 5, True) == pytest.approx(10.0)
|
||||
|
||||
def test_cost_fraction(self):
|
||||
m = CommissionModel()
|
||||
m.flat_per_order = 20.0
|
||||
frac = m.cost_fraction(100_000, 1, True, 100_000.0)
|
||||
assert frac == pytest.approx(20.0 / 100_000.0)
|
||||
|
||||
def test_cost_fraction_zero_capital(self):
|
||||
m = CommissionModel()
|
||||
m.flat_per_order = 20.0
|
||||
assert m.cost_fraction(100_000, 1, True, 0.0) == 0.0
|
||||
|
||||
def test_proportional_preset(self):
|
||||
m = CommissionModel.proportional(0.001)
|
||||
assert m.total_cost(100_000, 1, True) == pytest.approx(100.0)
|
||||
assert m.gst_rate == 0.0
|
||||
|
||||
def test_repr_contains_key_fields(self):
|
||||
m = CommissionModel.equity_delivery_india()
|
||||
r = repr(m)
|
||||
assert "CommissionModel" in r
|
||||
assert "lot_size" in r
|
||||
|
||||
|
||||
class TestCommissionPresets:
|
||||
def test_equity_delivery_india_smoke(self):
|
||||
m = CommissionModel.equity_delivery_india()
|
||||
# Buy ₹1L trade: brokerage cap ₹20, STT ₹100 (both sides)
|
||||
cost = m.total_cost(100_000, 1, True)
|
||||
assert cost > 0.0
|
||||
assert cost < 500.0 # sanity upper bound
|
||||
# Brokerage should be capped at ₹20
|
||||
assert m.flat_per_order == 0.0
|
||||
assert m.max_brokerage == pytest.approx(20.0)
|
||||
assert m.stt_on_buy is True
|
||||
assert m.stt_on_sell is True
|
||||
|
||||
def test_equity_intraday_india_smoke(self):
|
||||
m = CommissionModel.equity_intraday_india()
|
||||
cost_buy = m.total_cost(100_000, 1, True)
|
||||
cost_sell = m.total_cost(100_000, 1, False)
|
||||
# STT only on sell side for intraday
|
||||
assert m.stt_on_buy is False
|
||||
assert m.stt_on_sell is True
|
||||
assert cost_sell > cost_buy # sell has more cost (STT)
|
||||
|
||||
def test_futures_india_smoke(self):
|
||||
m = CommissionModel.futures_india()
|
||||
assert m.flat_per_order == pytest.approx(20.0)
|
||||
assert m.stt_on_buy is False
|
||||
assert m.stt_on_sell is True
|
||||
assert m.lot_size == pytest.approx(25.0)
|
||||
|
||||
def test_options_india_smoke(self):
|
||||
m = CommissionModel.options_india()
|
||||
assert m.flat_per_order == pytest.approx(20.0)
|
||||
assert m.stt_rate == pytest.approx(0.0015)
|
||||
assert m.lot_size == pytest.approx(25.0)
|
||||
|
||||
|
||||
class TestCommissionFix:
|
||||
"""The old 'commission_per_trade=20.0' bug would subtract ₹20 from 1.0-normalized
|
||||
equity — a 2000% error. The new model correctly computes 0.02% fraction."""
|
||||
|
||||
def test_flat_20_on_1L_capital_is_tiny_fraction(self):
|
||||
m = CommissionModel()
|
||||
m.flat_per_order = 20.0
|
||||
frac = m.cost_fraction(100_000, 1, True, 100_000.0)
|
||||
# ₹20 / ₹100_000 = 0.02%
|
||||
assert frac == pytest.approx(20.0 / 100_000.0, rel=1e-6)
|
||||
assert frac < 0.01 # definitely not 2000%
|
||||
|
||||
def test_commission_reduces_equity_vs_no_commission(self):
|
||||
rng = np.random.default_rng(99)
|
||||
close = np.cumprod(1.0 + rng.standard_normal(200) * 0.01) * 100.0
|
||||
m = CommissionModel.equity_intraday_india()
|
||||
r_comm = (
|
||||
BacktestEngine()
|
||||
.with_commission_model(m)
|
||||
.with_initial_capital(100_000)
|
||||
.run(close, "sma_crossover")
|
||||
)
|
||||
r_none = (
|
||||
BacktestEngine().with_initial_capital(100_000).run(close, "sma_crossover")
|
||||
)
|
||||
# Commission should reduce final equity (or keep equal if zero trades)
|
||||
assert r_comm.final_equity <= r_none.final_equity
|
||||
|
||||
|
||||
class TestCommissionSaveLoad:
|
||||
def test_to_json_from_json_round_trip(self):
|
||||
m = CommissionModel.equity_delivery_india()
|
||||
j = m.to_json()
|
||||
m2 = CommissionModel.from_json(j)
|
||||
assert m == m2
|
||||
assert m2.stt_rate == pytest.approx(m.stt_rate)
|
||||
assert m2.lot_size == pytest.approx(m.lot_size)
|
||||
assert m2.gst_rate == pytest.approx(m.gst_rate)
|
||||
|
||||
def test_save_load_round_trip(self):
|
||||
m = CommissionModel.futures_india()
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
|
||||
path = f.name
|
||||
try:
|
||||
m.save(path)
|
||||
assert os.path.exists(path)
|
||||
m2 = CommissionModel.load(path)
|
||||
assert m == m2
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_from_json_invalid_raises(self):
|
||||
with pytest.raises(Exception):
|
||||
CommissionModel.from_json("{invalid json")
|
||||
|
||||
def test_load_missing_file_raises(self):
|
||||
with pytest.raises(Exception):
|
||||
CommissionModel.load("/nonexistent/path/commission.json")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestCurrency
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestCurrency:
|
||||
def test_inr_lakh_grouping(self):
|
||||
assert INR.format(123456.78) == "₹1,23,456.78"
|
||||
assert INR.format(1000000.0) == "₹10,00,000.00"
|
||||
assert INR.format(10000000.0) == "₹1,00,00,000.00"
|
||||
assert INR.format(100.0) == "₹100.00"
|
||||
assert INR.format(1234.5) == "₹1,234.50"
|
||||
|
||||
def test_inr_negative(self):
|
||||
result = INR.format(-5000.0)
|
||||
assert result.startswith("-₹")
|
||||
assert "5,000.00" in result
|
||||
|
||||
def test_usd_standard_grouping(self):
|
||||
assert USD.format(1234567.89) == "$1,234,567.89"
|
||||
assert USD.format(0.5) == "$0.50"
|
||||
assert USD.format(1000.0) == "$1,000.00"
|
||||
|
||||
def test_jpy_no_decimals(self):
|
||||
result = JPY.format(1000000.0)
|
||||
assert result == "¥1,000,000"
|
||||
|
||||
def test_eur_format(self):
|
||||
assert "€" in EUR.format(100.0)
|
||||
|
||||
def test_gbp_format(self):
|
||||
assert "£" in GBP.format(100.0)
|
||||
|
||||
def test_usdt_format(self):
|
||||
assert "₮" in USDT.format(100.0)
|
||||
|
||||
def test_format_currency_helper(self):
|
||||
assert format_currency(123456.78) == "₹1,23,456.78"
|
||||
assert format_currency(1000.0, USD) == "$1,000.00"
|
||||
|
||||
def test_currency_immutable(self):
|
||||
with pytest.raises(AttributeError):
|
||||
INR.code = "USD" # type: ignore[misc]
|
||||
|
||||
def test_currency_equality(self):
|
||||
c1 = Currency.from_code("INR")
|
||||
assert c1 == INR
|
||||
assert INR != USD
|
||||
|
||||
def test_currency_hash_usable_in_dict(self):
|
||||
d = {INR: 100_000, USD: 100}
|
||||
assert d[INR] == 100_000
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestInitialCapital
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestInitialCapital:
|
||||
def test_equity_abs_shape(self, close_500):
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_initial_capital(200_000)
|
||||
.run(close_500, "sma_crossover")
|
||||
)
|
||||
assert result.equity_abs.shape == result.equity.shape
|
||||
|
||||
def test_equity_abs_is_equity_times_capital(self, close_500):
|
||||
capital = 150_000.0
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_initial_capital(capital)
|
||||
.run(close_500, "sma_crossover")
|
||||
)
|
||||
np.testing.assert_allclose(result.equity_abs, result.equity * capital)
|
||||
|
||||
def test_summary_contains_capital_fields(self, close_500):
|
||||
capital = 100_000.0
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_initial_capital(capital)
|
||||
.run(close_500, "sma_crossover")
|
||||
)
|
||||
s = result.summary()
|
||||
assert "initial_capital" in s
|
||||
assert "final_capital" in s
|
||||
assert "absolute_pnl" in s
|
||||
assert s["initial_capital"] == pytest.approx(capital)
|
||||
assert s["final_capital"] == pytest.approx(result.equity_abs[-1])
|
||||
assert s["absolute_pnl"] == pytest.approx(s["final_capital"] - capital)
|
||||
|
||||
def test_pnl_abs_in_trade_log(self, close_500, ohlcv_500):
|
||||
open_, high, low, close, _ = ohlcv_500
|
||||
capital = 100_000.0
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_initial_capital(capital)
|
||||
.with_ohlcv(high=high, low=low, open_=open_)
|
||||
.run(close, "sma_crossover")
|
||||
)
|
||||
if result.trades is not None and len(result.trades) > 0:
|
||||
assert "pnl_abs" in result.trades.columns
|
||||
np.testing.assert_allclose(
|
||||
result.trades["pnl_abs"].values,
|
||||
result.trades["pnl_pct"].values * capital,
|
||||
)
|
||||
|
||||
|
||||
class TestINRRepr:
|
||||
def test_repr_shows_inr_symbol(self, close_500):
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_currency(INR)
|
||||
.with_initial_capital(100_000)
|
||||
.run(close_500, "sma_crossover")
|
||||
)
|
||||
r = repr(result)
|
||||
assert "₹" in r
|
||||
|
||||
def test_currency_code_in_summary(self, close_500):
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_currency("USD")
|
||||
.with_initial_capital(10_000)
|
||||
.run(close_500, "sma_crossover")
|
||||
)
|
||||
s = result.summary()
|
||||
assert s["currency"] == "USD"
|
||||
|
||||
def test_unknown_currency_raises(self):
|
||||
with pytest.raises(Exception, match="Unknown currency"):
|
||||
BacktestEngine().with_currency("XYZ")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestVolatilityTargetSizing
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestVolatilityTargetSizing:
|
||||
def test_vol_target_runs_without_error(self, close_500):
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_position_sizing("volatility_target", target_vol=0.10)
|
||||
.run(close_500, "sma_crossover")
|
||||
)
|
||||
assert len(result.equity) == len(close_500)
|
||||
assert np.isfinite(result.final_equity)
|
||||
|
||||
def test_vol_target_signals_are_scaled(self, close_500):
|
||||
# With very low target vol the strategy should have fewer active positions
|
||||
result_low = (
|
||||
BacktestEngine()
|
||||
.with_position_sizing("volatility_target", target_vol=0.01)
|
||||
.run(close_500, "sma_crossover")
|
||||
)
|
||||
result_high = (
|
||||
BacktestEngine()
|
||||
.with_position_sizing("volatility_target", target_vol=1.0)
|
||||
.run(close_500, "sma_crossover")
|
||||
)
|
||||
# Lower vol target → lower absolute position sizes → lower annualised vol
|
||||
low_std = float(np.nanstd(result_low.strategy_returns))
|
||||
high_std = float(np.nanstd(result_high.strategy_returns))
|
||||
assert low_std <= high_std or np.isclose(low_std, high_std, rtol=0.5)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBenchmark
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBenchmark:
|
||||
def test_benchmark_metrics_present(self, close_500):
|
||||
rng = np.random.default_rng(77)
|
||||
benchmark = np.cumprod(1.0 + rng.standard_normal(500) * 0.008) * 100.0
|
||||
result = (
|
||||
BacktestEngine().with_benchmark(benchmark).run(close_500, "sma_crossover")
|
||||
)
|
||||
s = result.summary()
|
||||
assert "alpha" in s
|
||||
assert "beta" in s
|
||||
assert "tracking_error" in s
|
||||
assert "information_ratio" in s
|
||||
assert "benchmark_cagr" in s
|
||||
|
||||
def test_identical_strategy_benchmark_has_low_tracking_error(self, close_500):
|
||||
# When strategy returns = benchmark returns, tracking error ≈ 0
|
||||
# Use the equity as its own benchmark
|
||||
result = (
|
||||
BacktestEngine().with_benchmark(close_500).run(close_500, "sma_crossover")
|
||||
)
|
||||
m = result.metrics
|
||||
# Beta should be finite
|
||||
assert np.isfinite(m.get("beta", float("nan")))
|
||||
|
||||
def test_benchmark_wrong_length_ignored(self, close_500):
|
||||
short_bench = close_500[:100]
|
||||
# Should not raise — benchmark mismatch is silently ignored
|
||||
result = (
|
||||
BacktestEngine().with_benchmark(short_bench).run(close_500, "sma_crossover")
|
||||
)
|
||||
# alpha should NOT appear (length mismatch)
|
||||
assert "alpha" not in result.metrics
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestTrailingStop
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestTrailingStop:
|
||||
def test_trailing_stop_runs(self, ohlcv_500, close_500):
|
||||
open_, high, low, close, _ = ohlcv_500
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_ohlcv(high=high, low=low, open_=open_)
|
||||
.with_trailing_stop(0.02)
|
||||
.run(close, "sma_crossover")
|
||||
)
|
||||
assert len(result.equity) == len(close)
|
||||
assert np.isfinite(result.final_equity)
|
||||
|
||||
def test_trailing_stop_reduces_losses_on_downtrend(self):
|
||||
"""Trailing stop should exit longs earlier on a falling market."""
|
||||
# Construct a clear downtrend after initial rise
|
||||
prices = np.concatenate(
|
||||
[
|
||||
np.linspace(100, 120, 50), # rise (signal stays long)
|
||||
np.linspace(120, 60, 150), # sharp fall
|
||||
]
|
||||
)
|
||||
high = prices * 1.002
|
||||
low = prices * 0.998
|
||||
open_ = prices * 0.999
|
||||
|
||||
result_trail = (
|
||||
BacktestEngine()
|
||||
.with_ohlcv(high=high, low=low, open_=open_)
|
||||
.with_trailing_stop(0.03)
|
||||
.run(prices, "sma_crossover")
|
||||
)
|
||||
result_no_trail = (
|
||||
BacktestEngine()
|
||||
.with_ohlcv(high=high, low=low, open_=open_)
|
||||
.run(prices, "sma_crossover")
|
||||
)
|
||||
# Trailing stop should yield better (or equal) max drawdown
|
||||
dd_trail = result_trail.metrics.get("max_drawdown", 0.0)
|
||||
dd_no_trail = result_no_trail.metrics.get("max_drawdown", 0.0)
|
||||
# max_drawdown is negative; higher value = smaller drawdown
|
||||
assert dd_trail >= dd_no_trail - 0.05 # allow 5% tolerance
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# TestBacktestEngineChaining
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestBacktestEngineChaining:
|
||||
def test_full_chain_runs(self, close_500, ohlcv_500):
|
||||
open_, high, low, close, _ = ohlcv_500
|
||||
rng = np.random.default_rng(42)
|
||||
benchmark = np.cumprod(1.0 + rng.standard_normal(500) * 0.008) * 100.0
|
||||
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_currency("INR")
|
||||
.with_initial_capital(100_000)
|
||||
.with_commission_model(CommissionModel.equity_intraday_india())
|
||||
.with_trailing_stop(0.02)
|
||||
.with_benchmark(benchmark)
|
||||
.with_ohlcv(high=high, low=low, open_=open_)
|
||||
.run(close, "sma_crossover")
|
||||
)
|
||||
assert len(result.equity) == len(close)
|
||||
assert result.currency == INR
|
||||
assert result.initial_capital == pytest.approx(100_000.0)
|
||||
assert np.isfinite(result.final_equity)
|
||||
|
||||
s = result.summary()
|
||||
assert s["currency"] == "INR"
|
||||
assert "alpha" in s # benchmark was set
|
||||
|
||||
def test_to_equity_dataframe(self, close_500):
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_initial_capital(50_000)
|
||||
.run(close_500, "sma_crossover")
|
||||
)
|
||||
df = result.to_equity_dataframe()
|
||||
assert "equity" in df.columns
|
||||
assert "equity_abs" in df.columns
|
||||
assert "strategy_returns" in df.columns
|
||||
assert "drawdown" in df.columns
|
||||
assert len(df) == len(close_500)
|
||||
np.testing.assert_allclose(df["equity_abs"].values, result.equity_abs)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Shared test helpers for ferro_ta unit tests.
|
||||
|
||||
This module consolidates common assertion patterns and data-generation
|
||||
utilities that are duplicated across multiple test files. Importing
|
||||
from here keeps individual test modules DRY and makes it easier to
|
||||
update assertion logic in one place.
|
||||
|
||||
Usage
|
||||
-----
|
||||
from tests.unit.helpers import (
|
||||
nan_count, finite, assert_nan_warmup, assert_output_length,
|
||||
assert_finite_values, assert_range, make_ohlcv,
|
||||
)
|
||||
|
||||
Note: Each test file that already has inline helpers continues to work
|
||||
unchanged. These helpers are provided for *new* tests and for gradual
|
||||
consolidation of existing ones.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Array inspection helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def nan_count(arr: np.ndarray) -> int:
|
||||
"""Return the number of NaN entries in *arr*.
|
||||
|
||||
Equivalent to the ``_nan_count`` functions duplicated in:
|
||||
- tests/unit/test_ferro_ta.py
|
||||
- tests/integration/test_vs_talib.py
|
||||
- tests/integration/test_vs_pandas_ta.py
|
||||
"""
|
||||
return int(np.sum(np.isnan(arr)))
|
||||
|
||||
|
||||
def finite(arr: np.ndarray) -> np.ndarray:
|
||||
"""Return only the finite (non-NaN) elements of *arr*.
|
||||
|
||||
Equivalent to the ``_finite`` helpers in:
|
||||
- tests/unit/test_ferro_ta.py
|
||||
- tests/unit/streaming/test_streaming.py
|
||||
"""
|
||||
return arr[~np.isnan(arr)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Common assertion helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def assert_output_length(result: np.ndarray, expected_length: int) -> None:
|
||||
"""Assert the indicator output has the expected length.
|
||||
|
||||
This pattern (``assert len(result) == len(PRICES)``) appears 82+ times
|
||||
across the test suite.
|
||||
"""
|
||||
assert len(result) == expected_length, (
|
||||
f"Expected output length {expected_length}, got {len(result)}"
|
||||
)
|
||||
|
||||
|
||||
def assert_nan_warmup(result: np.ndarray, warmup: int) -> None:
|
||||
"""Assert that the first *warmup* values are NaN and that at least
|
||||
one value after the warmup period is finite.
|
||||
|
||||
This pattern (``assert np.all(np.isnan(result[:N]))``) appears 36+
|
||||
times in indicator tests.
|
||||
"""
|
||||
assert np.all(np.isnan(result[:warmup])), (
|
||||
f"Expected first {warmup} values to be NaN"
|
||||
)
|
||||
if len(result) > warmup:
|
||||
assert np.any(np.isfinite(result[warmup:])), (
|
||||
f"Expected at least one finite value after warmup index {warmup}"
|
||||
)
|
||||
|
||||
|
||||
def assert_finite_values(arr: np.ndarray) -> None:
|
||||
"""Assert that *all* non-NaN values are finite (not +/-inf).
|
||||
|
||||
The pattern ``np.all(np.isfinite(arr[~np.isnan(arr)]))`` appears
|
||||
60+ times across the test suite.
|
||||
"""
|
||||
valid = arr[~np.isnan(arr)]
|
||||
assert np.all(np.isfinite(valid)), "Found non-finite (inf) values in output"
|
||||
|
||||
|
||||
def assert_range(
|
||||
arr: np.ndarray,
|
||||
lo: float = 0.0,
|
||||
hi: float = 100.0,
|
||||
*,
|
||||
ignore_nan: bool = True,
|
||||
) -> None:
|
||||
"""Assert every (non-NaN) value in *arr* falls within [lo, hi].
|
||||
|
||||
The ``valid >= 0 and valid <= 100`` pattern appears 10+ times for
|
||||
oscillator-type indicators (RSI, WILLR, CMO, etc.).
|
||||
"""
|
||||
values = arr[~np.isnan(arr)] if ignore_nan else arr
|
||||
assert np.all(values >= lo), f"Found value below {lo}: {values.min()}"
|
||||
assert np.all(values <= hi), f"Found value above {hi}: {values.max()}"
|
||||
|
||||
|
||||
def assert_close(
|
||||
actual: np.ndarray,
|
||||
expected: np.ndarray,
|
||||
*,
|
||||
rtol: float = 1e-6,
|
||||
atol: float = 0.0,
|
||||
ignore_nan: bool = True,
|
||||
) -> None:
|
||||
"""Assert element-wise closeness, optionally skipping NaN positions.
|
||||
|
||||
Thin wrapper around ``np.testing.assert_allclose`` that mirrors the
|
||||
NaN-stripping pattern seen in integration tests.
|
||||
"""
|
||||
if ignore_nan:
|
||||
mask = ~(np.isnan(actual) | np.isnan(expected))
|
||||
actual = actual[mask]
|
||||
expected = expected[mask]
|
||||
np.testing.assert_allclose(actual, expected, rtol=rtol, atol=atol)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data generation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_ohlcv(
|
||||
n: int = 100,
|
||||
seed: int = 42,
|
||||
base_price: float = 100.0,
|
||||
) -> dict[str, np.ndarray]:
|
||||
"""Generate reproducible synthetic OHLCV data.
|
||||
|
||||
This pattern is duplicated across many test files with slight
|
||||
variations (different seeds, base prices, spread logic). Using
|
||||
this helper ensures consistent generation logic.
|
||||
|
||||
Returns a dict with keys: close, high, low, open, volume.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
close = base_price + np.cumsum(rng.normal(0, 0.5, n))
|
||||
high = close + np.abs(rng.normal(0, 0.3, n))
|
||||
low = close - np.abs(rng.normal(0, 0.3, n))
|
||||
open_ = close + rng.normal(0, 0.1, n)
|
||||
volume = rng.uniform(1000, 5000, n)
|
||||
return {
|
||||
"close": close,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"open": open_,
|
||||
"volume": volume,
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Tests for ferro_ta streaming / incremental indicators."""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta import EMA, RSI, SMA
|
||||
from ferro_ta.data.streaming import StreamingEMA, StreamingRSI, StreamingSMA
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PRICES = np.array(
|
||||
[
|
||||
44.34,
|
||||
44.09,
|
||||
44.15,
|
||||
43.61,
|
||||
44.33,
|
||||
44.83,
|
||||
45.10,
|
||||
45.15,
|
||||
43.61,
|
||||
44.33,
|
||||
44.83,
|
||||
45.10,
|
||||
45.15,
|
||||
43.61,
|
||||
44.33,
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
def _finite(arr: np.ndarray) -> np.ndarray:
|
||||
return arr[~np.isnan(arr)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamingSMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamingSMA:
|
||||
def test_basic_values(self):
|
||||
"""Feed known values, verify manually computed SMA."""
|
||||
sma = StreamingSMA(period=3)
|
||||
assert math.isnan(sma.update(1.0))
|
||||
assert math.isnan(sma.update(2.0))
|
||||
assert math.isclose(sma.update(3.0), 2.0)
|
||||
assert math.isclose(sma.update(4.0), 3.0)
|
||||
assert math.isclose(sma.update(5.0), 4.0)
|
||||
|
||||
def test_matches_batch_sma(self):
|
||||
"""Streaming SMA final values must match batch SMA on the same data."""
|
||||
period = 5
|
||||
batch = SMA(PRICES, timeperiod=period)
|
||||
sma = StreamingSMA(period=period)
|
||||
for i, price in enumerate(PRICES):
|
||||
val = sma.update(price)
|
||||
if math.isnan(batch[i]):
|
||||
assert math.isnan(val), f"Expected NaN at index {i}"
|
||||
else:
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-10), (
|
||||
f"Mismatch at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_period_property(self):
|
||||
sma = StreamingSMA(period=7)
|
||||
assert sma.period == 7
|
||||
|
||||
def test_warmup_returns_nan(self):
|
||||
"""First period-1 updates must return NaN."""
|
||||
period = 4
|
||||
sma = StreamingSMA(period=period)
|
||||
for i in range(period - 1):
|
||||
assert math.isnan(sma.update(float(i + 1)))
|
||||
# The period-th update should NOT be NaN
|
||||
assert not math.isnan(sma.update(float(period)))
|
||||
|
||||
def test_single_value_period_1(self):
|
||||
"""Period=1 means every value is immediately returned."""
|
||||
sma = StreamingSMA(period=1)
|
||||
assert math.isclose(sma.update(42.0), 42.0)
|
||||
assert math.isclose(sma.update(99.0), 99.0)
|
||||
|
||||
def test_reset(self):
|
||||
"""After reset, the indicator should behave as freshly constructed."""
|
||||
sma = StreamingSMA(period=3)
|
||||
sma.update(10.0)
|
||||
sma.update(20.0)
|
||||
result_before_reset = sma.update(30.0)
|
||||
assert math.isclose(result_before_reset, 20.0)
|
||||
|
||||
sma.reset()
|
||||
# After reset, warmup restarts
|
||||
assert math.isnan(sma.update(100.0))
|
||||
assert math.isnan(sma.update(200.0))
|
||||
assert math.isclose(sma.update(300.0), 200.0)
|
||||
|
||||
def test_invalid_period_zero(self):
|
||||
with pytest.raises(Exception):
|
||||
StreamingSMA(period=0)
|
||||
|
||||
def test_repr(self):
|
||||
sma = StreamingSMA(period=5)
|
||||
assert "StreamingSMA" in repr(sma)
|
||||
assert "5" in repr(sma)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamingEMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamingEMA:
|
||||
def test_basic_seeding(self):
|
||||
"""EMA seeds from the first `period` values using their SMA."""
|
||||
ema = StreamingEMA(period=3)
|
||||
assert math.isnan(ema.update(1.0))
|
||||
assert math.isnan(ema.update(2.0))
|
||||
# Seed = SMA(1,2,3) = 2.0
|
||||
seed = ema.update(3.0)
|
||||
assert math.isclose(seed, 2.0)
|
||||
|
||||
def test_matches_batch_ema(self):
|
||||
"""Streaming EMA must match batch EMA on the same data."""
|
||||
period = 5
|
||||
batch = EMA(PRICES, timeperiod=period)
|
||||
ema = StreamingEMA(period=period)
|
||||
for i, price in enumerate(PRICES):
|
||||
val = ema.update(price)
|
||||
if math.isnan(batch[i]):
|
||||
assert math.isnan(val), f"Expected NaN at index {i}"
|
||||
else:
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-10), (
|
||||
f"Mismatch at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_warmup_returns_nan(self):
|
||||
period = 5
|
||||
ema = StreamingEMA(period=period)
|
||||
for i in range(period - 1):
|
||||
assert math.isnan(ema.update(float(i + 1)))
|
||||
assert not math.isnan(ema.update(float(period)))
|
||||
|
||||
def test_ema_differs_from_sma_after_warmup(self):
|
||||
"""After warmup, EMA and SMA should diverge for non-constant data."""
|
||||
period = 3
|
||||
prices = [1.0, 2.0, 3.0, 10.0, 11.0]
|
||||
sma = StreamingSMA(period=period)
|
||||
ema = StreamingEMA(period=period)
|
||||
sma_vals = [sma.update(p) for p in prices]
|
||||
ema_vals = [ema.update(p) for p in prices]
|
||||
# At the seed point they should match (both are SMA of first 3)
|
||||
assert math.isclose(sma_vals[2], ema_vals[2])
|
||||
# After the seed they should diverge
|
||||
assert not math.isclose(sma_vals[-1], ema_vals[-1], rel_tol=1e-9)
|
||||
|
||||
def test_reset(self):
|
||||
ema = StreamingEMA(period=3)
|
||||
for p in [10.0, 20.0, 30.0, 40.0]:
|
||||
ema.update(p)
|
||||
ema.reset()
|
||||
# After reset, warmup restarts
|
||||
assert math.isnan(ema.update(1.0))
|
||||
assert math.isnan(ema.update(2.0))
|
||||
assert math.isclose(ema.update(3.0), 2.0)
|
||||
|
||||
def test_period_property(self):
|
||||
ema = StreamingEMA(period=10)
|
||||
assert ema.period == 10
|
||||
|
||||
def test_invalid_period_zero(self):
|
||||
with pytest.raises(Exception):
|
||||
StreamingEMA(period=0)
|
||||
|
||||
def test_single_value_period_1(self):
|
||||
ema = StreamingEMA(period=1)
|
||||
assert math.isclose(ema.update(42.0), 42.0)
|
||||
assert math.isclose(ema.update(50.0), 50.0)
|
||||
|
||||
def test_repr(self):
|
||||
ema = StreamingEMA(period=12)
|
||||
assert "StreamingEMA" in repr(ema)
|
||||
assert "12" in repr(ema)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamingRSI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamingRSI:
|
||||
def test_matches_batch_rsi(self):
|
||||
"""Streaming RSI must match batch RSI on the same data."""
|
||||
period = 5
|
||||
batch = RSI(PRICES, timeperiod=period)
|
||||
rsi = StreamingRSI(period=period)
|
||||
for i, price in enumerate(PRICES):
|
||||
val = rsi.update(price)
|
||||
if math.isnan(batch[i]):
|
||||
assert math.isnan(val), f"Expected NaN at index {i}"
|
||||
else:
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-8), (
|
||||
f"Mismatch at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_warmup_returns_nan(self):
|
||||
"""RSI needs period+1 bars (1 for first prev, then period deltas)."""
|
||||
period = 5
|
||||
rsi = StreamingRSI(period=period)
|
||||
# First bar: sets prev, returns NaN
|
||||
assert math.isnan(rsi.update(50.0))
|
||||
# Next period-1 bars: accumulating deltas, returns NaN
|
||||
for i in range(period - 1):
|
||||
assert math.isnan(rsi.update(50.0 + i))
|
||||
# The (period+1)-th bar should produce a value
|
||||
assert not math.isnan(rsi.update(55.0))
|
||||
|
||||
def test_rsi_range(self):
|
||||
"""All finite RSI values must be in [0, 100]."""
|
||||
rsi = StreamingRSI(period=5)
|
||||
for price in PRICES:
|
||||
val = rsi.update(price)
|
||||
if not math.isnan(val):
|
||||
assert 0.0 <= val <= 100.0, f"RSI out of range: {val}"
|
||||
|
||||
def test_constant_prices(self):
|
||||
"""Constant prices produce no gains or losses -- RSI should be 100
|
||||
(avg_loss == 0 leads to RS = infinity -> RSI = 100)."""
|
||||
rsi = StreamingRSI(period=5)
|
||||
results = [rsi.update(50.0) for _ in range(20)]
|
||||
finite = [v for v in results if not math.isnan(v)]
|
||||
assert len(finite) > 0
|
||||
for v in finite:
|
||||
assert math.isclose(v, 100.0) or math.isclose(v, 0.0) or (0.0 <= v <= 100.0)
|
||||
|
||||
def test_monotone_increasing(self):
|
||||
"""Monotonically increasing prices should yield RSI = 100."""
|
||||
rsi = StreamingRSI(period=3)
|
||||
results = [rsi.update(float(i)) for i in range(1, 20)]
|
||||
finite = [v for v in results if not math.isnan(v)]
|
||||
for v in finite:
|
||||
assert math.isclose(v, 100.0), (
|
||||
f"Expected RSI=100 for monotone increase, got {v}"
|
||||
)
|
||||
|
||||
def test_monotone_decreasing(self):
|
||||
"""Monotonically decreasing prices should yield RSI = 0."""
|
||||
rsi = StreamingRSI(period=3)
|
||||
results = [rsi.update(float(100 - i)) for i in range(20)]
|
||||
finite = [v for v in results if not math.isnan(v)]
|
||||
for v in finite:
|
||||
assert math.isclose(v, 0.0, abs_tol=1e-10), (
|
||||
f"Expected RSI=0 for monotone decrease, got {v}"
|
||||
)
|
||||
|
||||
def test_default_period_14(self):
|
||||
rsi = StreamingRSI()
|
||||
assert rsi.period == 14
|
||||
|
||||
def test_reset(self):
|
||||
rsi = StreamingRSI(period=3)
|
||||
for price in PRICES:
|
||||
rsi.update(price)
|
||||
rsi.reset()
|
||||
# After reset, warmup restarts -- first update should be NaN
|
||||
assert math.isnan(rsi.update(50.0))
|
||||
|
||||
def test_invalid_period_zero(self):
|
||||
with pytest.raises(Exception):
|
||||
StreamingRSI(period=0)
|
||||
|
||||
def test_repr(self):
|
||||
rsi = StreamingRSI(period=14)
|
||||
assert "StreamingRSI" in repr(rsi)
|
||||
assert "14" in repr(rsi)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases (shared across indicators)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStreamingEdgeCases:
|
||||
def test_nan_input_sma(self):
|
||||
"""Feeding NaN into SMA should propagate NaN through the window."""
|
||||
sma = StreamingSMA(period=3)
|
||||
sma.update(1.0)
|
||||
sma.update(2.0)
|
||||
# Third value is NaN -- the sum will include NaN, producing NaN
|
||||
val = sma.update(float("nan"))
|
||||
assert math.isnan(val)
|
||||
|
||||
def test_nan_input_ema(self):
|
||||
"""Feeding NaN into EMA should produce NaN output."""
|
||||
ema = StreamingEMA(period=3)
|
||||
ema.update(1.0)
|
||||
ema.update(2.0)
|
||||
val = ema.update(float("nan"))
|
||||
assert math.isnan(val)
|
||||
|
||||
def test_nan_input_rsi(self):
|
||||
"""Feeding NaN into RSI should produce NaN output."""
|
||||
rsi = StreamingRSI(period=3)
|
||||
rsi.update(1.0)
|
||||
rsi.update(2.0)
|
||||
val = rsi.update(float("nan"))
|
||||
assert math.isnan(val)
|
||||
|
||||
def test_single_value_sma(self):
|
||||
"""Feeding exactly one value to SMA with period > 1 yields NaN."""
|
||||
sma = StreamingSMA(period=5)
|
||||
assert math.isnan(sma.update(42.0))
|
||||
|
||||
def test_single_value_ema(self):
|
||||
ema = StreamingEMA(period=5)
|
||||
assert math.isnan(ema.update(42.0))
|
||||
|
||||
def test_single_value_rsi(self):
|
||||
rsi = StreamingRSI(period=5)
|
||||
assert math.isnan(rsi.update(42.0))
|
||||
|
||||
def test_large_dataset_sma(self):
|
||||
"""Ensure streaming SMA is stable over many updates."""
|
||||
period = 20
|
||||
sma = StreamingSMA(period=period)
|
||||
np.random.seed(42)
|
||||
data = np.random.randn(10_000).cumsum() + 100.0
|
||||
batch = SMA(data, timeperiod=period)
|
||||
for i, price in enumerate(data):
|
||||
val = sma.update(price)
|
||||
if not math.isnan(batch[i]):
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-8), (
|
||||
f"Drift at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_large_dataset_ema(self):
|
||||
"""Ensure streaming EMA is stable over many updates."""
|
||||
period = 20
|
||||
ema = StreamingEMA(period=period)
|
||||
np.random.seed(42)
|
||||
data = np.random.randn(10_000).cumsum() + 100.0
|
||||
batch = EMA(data, timeperiod=period)
|
||||
for i, price in enumerate(data):
|
||||
val = ema.update(price)
|
||||
if not math.isnan(batch[i]):
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-8), (
|
||||
f"Drift at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_large_dataset_rsi(self):
|
||||
"""Ensure streaming RSI is stable over many updates."""
|
||||
period = 14
|
||||
rsi = StreamingRSI(period=period)
|
||||
np.random.seed(42)
|
||||
data = np.random.randn(10_000).cumsum() + 100.0
|
||||
batch = RSI(data, timeperiod=period)
|
||||
for i, price in enumerate(data):
|
||||
val = rsi.update(price)
|
||||
if not math.isnan(batch[i]):
|
||||
assert math.isclose(val, batch[i], rel_tol=1e-6), (
|
||||
f"Drift at index {i}: streaming={val}, batch={batch[i]}"
|
||||
)
|
||||
|
||||
def test_reset_then_reuse_matches_fresh_instance(self):
|
||||
"""A reset indicator should produce identical output to a new one."""
|
||||
period = 5
|
||||
data = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0]
|
||||
|
||||
sma_reused = StreamingSMA(period=period)
|
||||
for p in [99.0, 98.0, 97.0, 96.0, 95.0]:
|
||||
sma_reused.update(p)
|
||||
sma_reused.reset()
|
||||
|
||||
sma_fresh = StreamingSMA(period=period)
|
||||
|
||||
for p in data:
|
||||
v1 = sma_reused.update(p)
|
||||
v2 = sma_fresh.update(p)
|
||||
if math.isnan(v1):
|
||||
assert math.isnan(v2)
|
||||
else:
|
||||
assert math.isclose(v1, v2, rel_tol=1e-12)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Integration tests for pandas and polars DataFrame/Series support.
|
||||
|
||||
Verifies that ferro_ta indicators transparently accept pandas Series and
|
||||
polars Series inputs, returning correctly shaped results with preserved
|
||||
index/name metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from ferro_ta import BBANDS, EMA, MACD, RSI, SMA
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pandas Series tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPandasSeries:
|
||||
"""Indicators accept pd.Series and return pd.Series with index."""
|
||||
|
||||
def test_sma_returns_series(self, ohlcv_500):
|
||||
s = pd.Series(ohlcv_500["close"])
|
||||
result = SMA(s, timeperiod=14)
|
||||
assert isinstance(result, pd.Series)
|
||||
assert len(result) == len(s)
|
||||
|
||||
def test_ema_returns_series(self, ohlcv_500):
|
||||
s = pd.Series(ohlcv_500["close"])
|
||||
result = EMA(s, timeperiod=14)
|
||||
assert isinstance(result, pd.Series)
|
||||
assert len(result) == len(s)
|
||||
|
||||
def test_rsi_returns_series(self, ohlcv_500):
|
||||
s = pd.Series(ohlcv_500["close"])
|
||||
result = RSI(s, timeperiod=14)
|
||||
assert isinstance(result, pd.Series)
|
||||
assert len(result) == len(s)
|
||||
|
||||
def test_bbands_returns_tuple_of_series(self, ohlcv_500):
|
||||
s = pd.Series(ohlcv_500["close"])
|
||||
upper, middle, lower = BBANDS(s, timeperiod=5)
|
||||
for band in (upper, middle, lower):
|
||||
assert isinstance(band, pd.Series)
|
||||
assert len(band) == len(s)
|
||||
|
||||
def test_macd_returns_tuple_of_series(self, ohlcv_500):
|
||||
s = pd.Series(ohlcv_500["close"])
|
||||
macd, signal, hist = MACD(s)
|
||||
for arr in (macd, signal, hist):
|
||||
assert isinstance(arr, pd.Series)
|
||||
assert len(arr) == len(s)
|
||||
|
||||
def test_index_preserved(self, ohlcv_500):
|
||||
"""Resulting Series should carry the same index as the input."""
|
||||
idx = pd.date_range("2020-01-01", periods=len(ohlcv_500["close"]), freq="D")
|
||||
s = pd.Series(ohlcv_500["close"], index=idx)
|
||||
result = SMA(s, timeperiod=14)
|
||||
assert isinstance(result, pd.Series)
|
||||
pd.testing.assert_index_equal(result.index, idx)
|
||||
|
||||
def test_named_series(self, ohlcv_500):
|
||||
"""Named Series should still work (name is not necessarily preserved,
|
||||
but the call should not error)."""
|
||||
s = pd.Series(ohlcv_500["close"], name="close_price")
|
||||
result = EMA(s, timeperiod=10)
|
||||
assert isinstance(result, pd.Series)
|
||||
assert len(result) == len(s)
|
||||
|
||||
def test_series_with_nan_values(self):
|
||||
"""NaN values in the input should not crash the indicator."""
|
||||
data = np.array([1.0, 2.0, np.nan, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0])
|
||||
s = pd.Series(data)
|
||||
result = SMA(s, timeperiod=3)
|
||||
assert isinstance(result, pd.Series)
|
||||
assert len(result) == len(s)
|
||||
|
||||
def test_bbands_index_preserved(self, ohlcv_500):
|
||||
idx = pd.date_range("2020-01-01", periods=len(ohlcv_500["close"]), freq="D")
|
||||
s = pd.Series(ohlcv_500["close"], index=idx)
|
||||
upper, middle, lower = BBANDS(s, timeperiod=5)
|
||||
for band in (upper, middle, lower):
|
||||
pd.testing.assert_index_equal(band.index, idx)
|
||||
|
||||
def test_macd_index_preserved(self, ohlcv_500):
|
||||
idx = pd.date_range("2020-01-01", periods=len(ohlcv_500["close"]), freq="D")
|
||||
s = pd.Series(ohlcv_500["close"], index=idx)
|
||||
macd, signal, hist = MACD(s)
|
||||
for arr in (macd, signal, hist):
|
||||
pd.testing.assert_index_equal(arr.index, idx)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Polars Series tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPolarsSeries:
|
||||
"""Indicators accept polars.Series and return polars.Series."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_polars(self):
|
||||
self.pl = pytest.importorskip("polars")
|
||||
|
||||
def test_sma_returns_polars_series(self, ohlcv_500):
|
||||
s = self.pl.Series("close", ohlcv_500["close"])
|
||||
result = SMA(s, timeperiod=14)
|
||||
assert isinstance(result, self.pl.Series)
|
||||
assert len(result) == len(s)
|
||||
|
||||
def test_ema_returns_polars_series(self, ohlcv_500):
|
||||
s = self.pl.Series("close", ohlcv_500["close"])
|
||||
result = EMA(s, timeperiod=14)
|
||||
assert isinstance(result, self.pl.Series)
|
||||
assert len(result) == len(s)
|
||||
|
||||
def test_rsi_returns_polars_series(self, ohlcv_500):
|
||||
s = self.pl.Series("close", ohlcv_500["close"])
|
||||
result = RSI(s, timeperiod=14)
|
||||
assert isinstance(result, self.pl.Series)
|
||||
assert len(result) == len(s)
|
||||
|
||||
def test_bbands_returns_tuple_of_polars_series(self, ohlcv_500):
|
||||
s = self.pl.Series("close", ohlcv_500["close"])
|
||||
upper, middle, lower = BBANDS(s, timeperiod=5)
|
||||
for band in (upper, middle, lower):
|
||||
assert isinstance(band, self.pl.Series)
|
||||
assert len(band) == len(s)
|
||||
|
||||
def test_macd_returns_tuple_of_polars_series(self, ohlcv_500):
|
||||
s = self.pl.Series("close", ohlcv_500["close"])
|
||||
macd, signal, hist = MACD(s)
|
||||
for arr in (macd, signal, hist):
|
||||
assert isinstance(arr, self.pl.Series)
|
||||
assert len(arr) == len(s)
|
||||
|
||||
def test_series_name_preserved(self, ohlcv_500):
|
||||
"""The polars Series name from the first input should be carried through."""
|
||||
s = self.pl.Series("my_close", ohlcv_500["close"])
|
||||
result = SMA(s, timeperiod=14)
|
||||
assert isinstance(result, self.pl.Series)
|
||||
assert result.name == "my_close"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DataFrame workflow tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataFrameWorkflow:
|
||||
"""End-to-end workflow: build a DataFrame, compute indicators, add columns."""
|
||||
|
||||
def test_pandas_dataframe_workflow(self, ohlcv_500):
|
||||
df = pd.DataFrame(ohlcv_500)
|
||||
|
||||
# Compute indicators from DataFrame columns
|
||||
df["sma_14"] = SMA(df["close"], timeperiod=14)
|
||||
df["ema_14"] = EMA(df["close"], timeperiod=14)
|
||||
df["rsi_14"] = RSI(df["close"], timeperiod=14)
|
||||
|
||||
upper, middle, lower = BBANDS(df["close"], timeperiod=5)
|
||||
df["bb_upper"] = upper
|
||||
df["bb_middle"] = middle
|
||||
df["bb_lower"] = lower
|
||||
|
||||
macd, signal, hist = MACD(df["close"])
|
||||
df["macd"] = macd
|
||||
df["macd_signal"] = signal
|
||||
df["macd_hist"] = hist
|
||||
|
||||
# All new columns should exist and have correct length
|
||||
new_cols = [
|
||||
"sma_14",
|
||||
"ema_14",
|
||||
"rsi_14",
|
||||
"bb_upper",
|
||||
"bb_middle",
|
||||
"bb_lower",
|
||||
"macd",
|
||||
"macd_signal",
|
||||
"macd_hist",
|
||||
]
|
||||
for col in new_cols:
|
||||
assert col in df.columns
|
||||
assert len(df[col]) == 500
|
||||
|
||||
# SMA leading values should be NaN
|
||||
assert np.isnan(df["sma_14"].iloc[0])
|
||||
# Non-NaN values should exist after warmup
|
||||
assert not np.isnan(df["sma_14"].iloc[-1])
|
||||
|
||||
def test_pandas_dataframe_index_consistency(self, ohlcv_500):
|
||||
"""Indicator columns should align with the original DataFrame index."""
|
||||
idx = pd.date_range("2020-01-01", periods=500, freq="D")
|
||||
df = pd.DataFrame(ohlcv_500, index=idx)
|
||||
|
||||
df["sma_14"] = SMA(df["close"], timeperiod=14)
|
||||
pd.testing.assert_index_equal(df["sma_14"].dropna().index, idx[13:])
|
||||
|
||||
def test_polars_dataframe_workflow(self, ohlcv_500):
|
||||
pl = pytest.importorskip("polars")
|
||||
df = pl.DataFrame(ohlcv_500)
|
||||
|
||||
sma_result = SMA(df["close"], timeperiod=14)
|
||||
ema_result = EMA(df["close"], timeperiod=14)
|
||||
rsi_result = RSI(df["close"], timeperiod=14)
|
||||
|
||||
# Results are polars Series of correct length
|
||||
for result in (sma_result, ema_result, rsi_result):
|
||||
assert isinstance(result, pl.Series)
|
||||
assert len(result) == 500
|
||||
|
||||
# Can add back to a polars DataFrame via with_columns
|
||||
df2 = df.with_columns(
|
||||
sma_result.alias("sma_14"),
|
||||
ema_result.alias("ema_14"),
|
||||
rsi_result.alias("rsi_14"),
|
||||
)
|
||||
assert "sma_14" in df2.columns
|
||||
assert "ema_14" in df2.columns
|
||||
assert "rsi_14" in df2.columns
|
||||
assert df2.shape[0] == 500
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Edge-case tests for ferro_ta indicators.
|
||||
|
||||
Covers NaN handling, empty arrays, single-element inputs, extreme values,
|
||||
constant series, and dtype robustness.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta import (
|
||||
ATR,
|
||||
BBANDS,
|
||||
EMA,
|
||||
MACD,
|
||||
MFI,
|
||||
OBV,
|
||||
RSI,
|
||||
SMA,
|
||||
STOCH,
|
||||
WMA,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _all_nan(arr):
|
||||
"""True if every element is NaN."""
|
||||
return np.all(np.isnan(arr))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty arrays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmptyInput:
|
||||
"""All indicators should return an empty array (not crash) for len-0 input."""
|
||||
|
||||
def test_sma_empty(self):
|
||||
result = SMA(np.array([], dtype=np.float64), timeperiod=14)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_ema_empty(self):
|
||||
result = EMA(np.array([], dtype=np.float64), timeperiod=14)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_rsi_empty(self):
|
||||
result = RSI(np.array([], dtype=np.float64), timeperiod=14)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_bbands_empty(self):
|
||||
upper, mid, lower = BBANDS(np.array([], dtype=np.float64), timeperiod=5)
|
||||
assert len(upper) == 0
|
||||
assert len(mid) == 0
|
||||
assert len(lower) == 0
|
||||
|
||||
def test_macd_empty(self):
|
||||
macd, sig, hist = MACD(np.array([], dtype=np.float64))
|
||||
assert len(macd) == 0
|
||||
|
||||
def test_wma_empty(self):
|
||||
result = WMA(np.array([], dtype=np.float64), timeperiod=10)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-element arrays
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleElement:
|
||||
"""Single-element inputs should produce NaN (insufficient data) without panic."""
|
||||
|
||||
def test_sma_single(self):
|
||||
result = SMA(np.array([42.0]), timeperiod=14)
|
||||
assert len(result) == 1
|
||||
assert np.isnan(result[0])
|
||||
|
||||
def test_ema_single(self):
|
||||
result = EMA(np.array([42.0]), timeperiod=14)
|
||||
assert len(result) == 1
|
||||
assert np.isnan(result[0])
|
||||
|
||||
def test_rsi_single(self):
|
||||
result = RSI(np.array([42.0]), timeperiod=14)
|
||||
assert len(result) == 1
|
||||
assert np.isnan(result[0])
|
||||
|
||||
def test_sma_period_1_single(self):
|
||||
"""SMA(period=1) on a single element should return that element."""
|
||||
result = SMA(np.array([42.0]), timeperiod=1)
|
||||
assert len(result) == 1
|
||||
np.testing.assert_allclose(result[0], 42.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# All-NaN input
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllNaN:
|
||||
"""Indicators fed entirely NaN input should not crash and return all NaN."""
|
||||
|
||||
@pytest.fixture()
|
||||
def nan_50(self):
|
||||
return np.full(50, np.nan)
|
||||
|
||||
def test_sma_all_nan(self, nan_50):
|
||||
result = SMA(nan_50, timeperiod=14)
|
||||
assert len(result) == 50
|
||||
assert _all_nan(result)
|
||||
|
||||
def test_ema_all_nan(self, nan_50):
|
||||
result = EMA(nan_50, timeperiod=14)
|
||||
assert len(result) == 50
|
||||
assert _all_nan(result)
|
||||
|
||||
def test_rsi_all_nan(self, nan_50):
|
||||
result = RSI(nan_50, timeperiod=14)
|
||||
assert len(result) == 50
|
||||
assert _all_nan(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NaN in the middle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNaNInMiddle:
|
||||
"""A single NaN in a valid series should propagate but not crash."""
|
||||
|
||||
def test_sma_nan_mid(self):
|
||||
data = np.arange(1.0, 21.0)
|
||||
data[10] = np.nan
|
||||
result = SMA(data, timeperiod=5)
|
||||
assert len(result) == 20
|
||||
# Values around the NaN should be NaN
|
||||
for i in range(10, min(15, 20)):
|
||||
assert np.isnan(result[i])
|
||||
|
||||
def test_rsi_nan_mid(self):
|
||||
data = np.arange(1.0, 31.0)
|
||||
data[15] = np.nan
|
||||
result = RSI(data, timeperiod=14)
|
||||
assert len(result) == 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extreme values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtremeValues:
|
||||
"""Indicators should not crash on very large or very small values."""
|
||||
|
||||
def test_sma_large_values(self):
|
||||
data = np.full(50, 1e300)
|
||||
result = SMA(data, timeperiod=14)
|
||||
assert len(result) == 50
|
||||
# Non-NaN values should be ~1e300
|
||||
valid = result[~np.isnan(result)]
|
||||
if len(valid) > 0:
|
||||
np.testing.assert_allclose(valid, 1e300, rtol=1e-10)
|
||||
|
||||
def test_sma_tiny_values(self):
|
||||
data = np.full(50, 1e-300)
|
||||
result = SMA(data, timeperiod=14)
|
||||
assert len(result) == 50
|
||||
valid = result[~np.isnan(result)]
|
||||
if len(valid) > 0:
|
||||
np.testing.assert_allclose(valid, 1e-300, rtol=1e-10)
|
||||
|
||||
def test_rsi_large_monotone(self):
|
||||
"""Monotonically increasing large values -> RSI should approach 100."""
|
||||
data = np.linspace(1e10, 2e10, 100)
|
||||
result = RSI(data, timeperiod=14)
|
||||
valid = result[~np.isnan(result)]
|
||||
if len(valid) > 0:
|
||||
assert valid[-1] > 90.0 # strongly bullish
|
||||
|
||||
def test_rsi_zero_change(self):
|
||||
"""Constant series -> RSI should be 50 (or NaN in some implementations)."""
|
||||
data = np.full(100, 50.0)
|
||||
result = RSI(data, timeperiod=14)
|
||||
valid = result[~np.isnan(result)]
|
||||
# Constant series: no gains, no losses -> typically NaN or 50
|
||||
# Just verify no crash and valid range
|
||||
for v in valid:
|
||||
assert 0.0 <= v <= 100.0 or np.isnan(v)
|
||||
|
||||
def test_bbands_constant_series(self):
|
||||
"""Constant series -> upper == middle == lower (zero std dev)."""
|
||||
data = np.full(50, 100.0)
|
||||
upper, mid, lower = BBANDS(data, timeperiod=10)
|
||||
valid_mask = ~np.isnan(mid)
|
||||
np.testing.assert_allclose(upper[valid_mask], mid[valid_mask])
|
||||
np.testing.assert_allclose(lower[valid_mask], mid[valid_mask])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timeperiod edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTimePeriodEdge:
|
||||
"""Boundary conditions for the timeperiod parameter."""
|
||||
|
||||
def test_sma_period_equals_length(self):
|
||||
data = np.arange(1.0, 11.0) # 10 elements
|
||||
result = SMA(data, timeperiod=10)
|
||||
assert len(result) == 10
|
||||
# Only last element should be valid
|
||||
assert not np.isnan(result[-1])
|
||||
np.testing.assert_allclose(result[-1], 5.5)
|
||||
|
||||
def test_sma_period_exceeds_length(self):
|
||||
data = np.arange(1.0, 6.0) # 5 elements
|
||||
result = SMA(data, timeperiod=10)
|
||||
assert len(result) == 5
|
||||
assert _all_nan(result)
|
||||
|
||||
def test_ema_period_1(self):
|
||||
"""EMA with period=1 should return the input itself."""
|
||||
data = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
result = EMA(data, timeperiod=1)
|
||||
np.testing.assert_allclose(result, data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-input indicator edge cases (OHLCV)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOHLCVEdgeCases:
|
||||
"""Edge cases for indicators requiring multiple price series."""
|
||||
|
||||
def test_atr_empty(self):
|
||||
empty = np.array([], dtype=np.float64)
|
||||
result = ATR(empty, empty, empty, timeperiod=14)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_stoch_empty(self):
|
||||
empty = np.array([], dtype=np.float64)
|
||||
slowk, slowd = STOCH(empty, empty, empty)
|
||||
assert len(slowk) == 0
|
||||
assert len(slowd) == 0
|
||||
|
||||
def test_obv_empty(self):
|
||||
empty = np.array([], dtype=np.float64)
|
||||
result = OBV(empty, empty)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_atr_single_bar(self):
|
||||
h = np.array([10.0])
|
||||
l = np.array([9.0])
|
||||
c = np.array([9.5])
|
||||
result = ATR(h, l, c, timeperiod=14)
|
||||
assert len(result) == 1
|
||||
assert np.isnan(result[0])
|
||||
|
||||
def test_mfi_constant_price(self):
|
||||
"""Constant price -> no money flow direction -> MFI should be well-defined."""
|
||||
n = 50
|
||||
h = np.full(n, 100.0)
|
||||
l = np.full(n, 100.0)
|
||||
c = np.full(n, 100.0)
|
||||
v = np.full(n, 1000.0)
|
||||
result = MFI(h, l, c, v, timeperiod=14)
|
||||
assert len(result) == n
|
||||
# Should not crash; values may be NaN or 50
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dtype robustness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDtypeRobustness:
|
||||
"""Indicators should accept float32/int inputs and coerce to float64."""
|
||||
|
||||
def test_sma_float32(self):
|
||||
data = np.arange(1.0, 51.0, dtype=np.float32)
|
||||
result = SMA(data, timeperiod=14)
|
||||
assert len(result) == 50
|
||||
|
||||
def test_sma_int64(self):
|
||||
data = np.arange(1, 51, dtype=np.int64)
|
||||
result = SMA(data, timeperiod=14)
|
||||
assert len(result) == 50
|
||||
|
||||
def test_rsi_float32(self):
|
||||
data = np.arange(1.0, 51.0, dtype=np.float32)
|
||||
result = RSI(data, timeperiod=14)
|
||||
assert len(result) == 50
|
||||
@@ -298,38 +298,37 @@ class TestBacktest:
|
||||
)
|
||||
|
||||
def test_commission_matches_reference_loop(self):
|
||||
from ferro_ta._ferro_ta import CommissionModel
|
||||
|
||||
from ferro_ta.analysis.backtest import BacktestEngine
|
||||
|
||||
close = np.array([100.0, 102.0, 101.0, 104.0, 103.0, 105.0], dtype=np.float64)
|
||||
raw_signals = np.array([0.0, 1.0, 1.0, -1.0, -1.0, 0.0], dtype=np.float64)
|
||||
|
||||
def strategy(_, **__):
|
||||
return raw_signals
|
||||
|
||||
commission = 0.02
|
||||
result = backtest(close, strategy=strategy, commission_per_trade=commission)
|
||||
initial_capital = 100_000.0
|
||||
cm = CommissionModel.proportional(0.001) # 0.1% proportional commission
|
||||
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_commission_model(cm)
|
||||
.with_initial_capital(initial_capital)
|
||||
.run(close, strategy=strategy)
|
||||
)
|
||||
|
||||
expected_positions = np.array(
|
||||
[0.0, 0.0, 1.0, 1.0, -1.0, -1.0], dtype=np.float64
|
||||
)
|
||||
expected_returns = np.empty_like(close)
|
||||
expected_returns[0] = 0.0
|
||||
expected_returns[1:] = np.diff(close) / close[:-1]
|
||||
expected_strategy_returns = expected_positions * expected_returns
|
||||
position_changed = np.concatenate(
|
||||
[[False], expected_positions[1:] != expected_positions[:-1]]
|
||||
)
|
||||
|
||||
expected_equity = np.empty_like(close)
|
||||
expected_equity[0] = 1.0
|
||||
for i in range(1, len(close)):
|
||||
expected_equity[i] = expected_equity[i - 1] * (
|
||||
1.0 + expected_strategy_returns[i]
|
||||
)
|
||||
if position_changed[i]:
|
||||
expected_equity[i] -= commission
|
||||
|
||||
np.testing.assert_allclose(result.positions, expected_positions)
|
||||
np.testing.assert_allclose(result.strategy_returns, expected_strategy_returns)
|
||||
np.testing.assert_allclose(result.equity, expected_equity)
|
||||
# With commission, final equity should be less than without
|
||||
result_no_comm = (
|
||||
BacktestEngine()
|
||||
.with_initial_capital(initial_capital)
|
||||
.run(close, strategy=strategy)
|
||||
)
|
||||
assert result.final_equity <= result_no_comm.final_equity
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta import BBANDS, CDLDOJI, EMA, RSI, SMA
|
||||
from ferro_ta import ATR, BBANDS, CDLDOJI, EMA, MACD, OBV, RSI, SMA, WMA
|
||||
|
||||
try:
|
||||
from hypothesis import given, settings
|
||||
@@ -80,6 +80,180 @@ if HAS_HYPOTHESIS:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user