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:
Pratik Bhadane
2026-03-30 12:45:52 +05:30
parent 2d776b6f90
commit 436954138f
174 changed files with 29297 additions and 10773 deletions
File diff suppressed because it is too large Load Diff
+546
View File
@@ -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)
+159
View File
@@ -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,
}
+387
View File
@@ -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)
+224
View File
@@ -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
+296
View File
@@ -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
+20 -21
View File
@@ -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
# ---------------------------------------------------------------------------
+175 -1
View File
@@ -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: