Files
ferro-ta/tests/unit/test_coverage.py
T
Pratik Bhadane 307beeca02 release: cut v1.0.0
Prepare the first public 1.0.0 release and finish the remaining CI hardening work.

Highlights:
- align Python, Rust, WASM, Conda, API, MCP, and docs version metadata to 1.0.0
- promote package metadata to Production/Stable and update stability/versioning docs for the stable series
- move the accumulated Unreleased notes into a dated 1.0.0 changelog section and keep a fresh top-level Unreleased block
- strengthen the changelog checker so it validates a single top-level Unreleased section
- fix the CI/package support mismatch by declaring Python >=3.10 consistently and gating pandas-ta extras to Python 3.12+
- restore Sphinx autodoc compatibility for documented ferro_ta.<module> imports by registering module aliases
- make the TA-Lib benchmark guardrail less flaky by checking median and tail-percentile speedups instead of failing on a single mild outlier
- switch PyPI publishing to OIDC-only trusted publishing and wire the changelog check into the required CI gate
- apply the Ruff-driven cleanup across the Python and test tree and refresh uv/cargo lockfiles

Validated locally:
- python3 scripts/check_changelog.py
- uv run --with ruff ruff check python tests
- uv run --with ruff ruff format --check python tests
- uv lock --check
- sphinx-build -b html docs docs/_build -W --keep-going
- build/install the ferro_ta 1.0.0 wheel successfully
2026-03-23 23:57:30 +05:30

2514 lines
85 KiB
Python

"""Additional tests to improve code coverage across all ferro_ta modules.
These tests target previously uncovered code paths including:
- Error-handling branches in indicator wrappers (except ValueError blocks)
- Utility helpers with untested code paths
- Module-level imports and edge cases
"""
from __future__ import annotations
import sys
from unittest.mock import patch
import numpy as np
import pytest
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
RNG = np.random.default_rng(42)
CLOSE = np.cumprod(1 + RNG.normal(0, 0.01, 200)) * 100.0
HIGH = CLOSE * RNG.uniform(1.001, 1.01, 200)
LOW = CLOSE * RNG.uniform(0.99, 0.999, 200)
OPEN = CLOSE * RNG.uniform(0.999, 1.001, 200)
VOLUME = RNG.uniform(500, 5000, 200)
# 2D array — triggers ValueError("Input must be a 1-D array") in _to_f64
_2D = np.array([[1.0, 2.0, 3.0, 4.0, 5.0]])
# ===========================================================================
# _utils.py — uncovered paths
# ===========================================================================
class TestUtilsUncovered:
"""Cover uncovered paths in _utils.py."""
def test_to_f64_pandas_series(self):
"""Line 43: pandas Series path in _to_f64."""
pd = pytest.importorskip("pandas")
from ferro_ta._utils import _to_f64
s = pd.Series([1.0, 2.0, 3.0])
result = _to_f64(s)
assert result.dtype == np.float64
np.testing.assert_array_equal(result, [1.0, 2.0, 3.0])
def test_to_f64_polars_series(self):
"""Lines 47-50: polars Series path in _to_f64."""
pl = pytest.importorskip("polars")
from ferro_ta._utils import _to_f64
s = pl.Series("close", [1.0, 2.0, 3.0])
result = _to_f64(s)
assert result.dtype == np.float64
assert len(result) == 3
def test_get_ohlcv_non_dataframe_raises(self):
"""Lines 100-101: get_ohlcv raises TypeError for non-DataFrame."""
pytest.importorskip("pandas")
from ferro_ta._utils import get_ohlcv
with pytest.raises(TypeError, match="pandas.DataFrame"):
get_ohlcv({"not": "a dataframe"})
def test_get_ohlcv_missing_column_raises(self):
"""Line 104: get_ohlcv raises KeyError for missing column."""
pd = pytest.importorskip("pandas")
from ferro_ta._utils import get_ohlcv
df = pd.DataFrame({"open": [1.0], "high": [1.1], "low": [0.9], "close": [1.0]})
# Pass a custom close_col that doesn't exist → raises KeyError
with pytest.raises(KeyError):
get_ohlcv(df, close_col="nonexistent_col")
def test_get_ohlcv_none_volume_col(self):
"""Lines 108-110: volume_col=None returns NaN array."""
pd = pytest.importorskip("pandas")
from ferro_ta._utils import get_ohlcv
df = pd.DataFrame(
{
"open": [1.0, 2.0],
"high": [1.1, 2.1],
"low": [0.9, 1.9],
"close": [1.0, 2.0],
}
)
o, h, l, c, v = get_ohlcv(
df,
open_col="open",
high_col="high",
low_col="low",
close_col="close",
volume_col=None,
)
assert np.all(np.isnan(v))
def test_pandas_wrap_dataframe_single_col(self):
"""Lines 160-161: pandas_wrap handles single-column DataFrame."""
pd = pytest.importorskip("pandas")
from ferro_ta import SMA
df = pd.DataFrame({"close": CLOSE[:50]})
result = SMA(df, timeperiod=5)
assert isinstance(result, (pd.Series, np.ndarray))
def test_pandas_wrap_tuple_output(self):
"""Lines 172-178: pandas_wrap wraps tuple output in Series."""
pd = pytest.importorskip("pandas")
from ferro_ta import BBANDS
s = pd.Series(CLOSE[:50])
upper, mid, lower = BBANDS(s, timeperiod=5)
assert isinstance(upper, pd.Series)
assert isinstance(mid, pd.Series)
assert isinstance(lower, pd.Series)
def test_polars_wrap_tuple_output(self):
"""Lines 233-234: polars_wrap wraps tuple output."""
pl = pytest.importorskip("polars")
from ferro_ta import BBANDS
s = pl.Series("close", CLOSE[:50].tolist())
upper, mid, lower = BBANDS(s, timeperiod=5)
assert isinstance(upper, pl.Series)
def test_polars_wrap_single_output(self):
"""Line 246: polars_wrap wraps single ndarray output."""
pl = pytest.importorskip("polars")
from ferro_ta import SMA
s = pl.Series("close", CLOSE[:50].tolist())
result = SMA(s, timeperiod=5)
assert isinstance(result, pl.Series)
def test_to_f64_polars_cast_exception_fallback(self):
"""Lines 49-50: polars Series with cast exception falls back to to_list."""
from ferro_ta._utils import _to_f64
# Create a mock that simulates a polars-like Series:
# - no 'to_numpy' attribute (so the pandas path is skipped)
# - has 'to_list()' method
# - type name is 'Series' (polars Series match condition)
# - has 'cast()' that raises an exception
class _FakePolars:
def to_list(self):
return [1.0, 2.0, 3.0]
def cast(self, *args, **kwargs):
raise Exception("cast failed")
_FakePolars.__name__ = "Series"
result = _to_f64(_FakePolars())
assert result.dtype == np.float64
np.testing.assert_array_equal(result, [1.0, 2.0, 3.0])
# ===========================================================================
# _binding.py — full coverage
# ===========================================================================
class TestBindingCall:
"""Cover binding_call in _binding.py."""
def test_basic_call_success(self):
"""Basic binding_call with timeperiod validation."""
from ferro_ta._ferro_ta import sma as _sma
from ferro_ta._binding import binding_call
result = binding_call(
_sma,
array_params=["close"],
timeperiod_param="timeperiod",
close=CLOSE,
timeperiod=5,
)
assert len(result) == len(CLOSE)
def test_timeperiod_validation_raises(self):
"""binding_call raises FerroTAValueError for invalid timeperiod."""
from ferro_ta._ferro_ta import sma as _sma
from ferro_ta._binding import binding_call
from ferro_ta.core.exceptions import FerroTAValueError
with pytest.raises(FerroTAValueError):
binding_call(
_sma,
array_params=["close"],
timeperiod_param="timeperiod",
close=CLOSE,
timeperiod=0,
)
def test_equal_length_validation_raises(self):
"""binding_call raises FerroTAInputError for mismatched lengths."""
from ferro_ta._ferro_ta import atr as _atr
from ferro_ta._binding import binding_call
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
binding_call(
_atr,
array_params=["high", "low", "close"],
equal_length_groups=[["high", "low", "close"]],
timeperiod_param="timeperiod",
high=HIGH,
low=LOW[:10], # mismatched
close=CLOSE,
timeperiod=14,
)
def test_rust_error_normalization(self):
"""binding_call normalizes Rust ValueError via _normalize_rust_error."""
from ferro_ta._binding import binding_call
from ferro_ta.core.exceptions import FerroTAValueError
# Use a function that will raise ValueError from Rust (invalid timeperiod)
def bad_fn(*args, **kwargs):
raise ValueError("timeperiod must be >= 1")
with pytest.raises(FerroTAValueError):
binding_call(bad_fn, array_params=["close"], close=CLOSE)
def test_no_timeperiod_param(self):
"""binding_call without timeperiod_param skips timeperiod check."""
from ferro_ta._ferro_ta import sma as _sma
from ferro_ta._binding import binding_call
result = binding_call(_sma, array_params=["close"], close=CLOSE, timeperiod=5)
assert len(result) == len(CLOSE)
# ===========================================================================
# raw.py — import coverage
# ===========================================================================
class TestRawImport:
"""Import ferro_ta.raw to cover the re-export statements."""
def test_raw_import(self):
"""Importing ferro_ta.raw covers the re-export lines."""
import ferro_ta.core.raw as raw
assert hasattr(raw, "sma")
assert hasattr(raw, "ema")
assert hasattr(raw, "rsi")
assert hasattr(raw, "batch_sma")
def test_raw_sma(self):
"""ferro_ta.raw.sma works directly."""
from ferro_ta.core.raw import sma
result = sma(CLOSE, 5)
assert len(result) == len(CLOSE)
# ===========================================================================
# mcp/__main__.py — import coverage
# ===========================================================================
class TestMCPMain:
"""Import ferro_ta.mcp.__main__ to cover module-level lines."""
def test_main_import(self):
"""Importing mcp.__main__ covers lines 3-4."""
import importlib
mod = importlib.import_module("ferro_ta.mcp.__main__")
assert hasattr(mod, "run_server")
# ===========================================================================
# cycle.py — error-path coverage
# ===========================================================================
class TestCycleErrorPaths:
"""Cover except ValueError branches in cycle.py."""
def test_ht_trendline_2d_raises(self):
from ferro_ta import HT_TRENDLINE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
HT_TRENDLINE(_2D)
def test_ht_dcperiod_2d_raises(self):
from ferro_ta import HT_DCPERIOD
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
HT_DCPERIOD(_2D)
def test_ht_dcphase_2d_raises(self):
from ferro_ta import HT_DCPHASE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
HT_DCPHASE(_2D)
def test_ht_phasor_2d_raises(self):
from ferro_ta import HT_PHASOR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
HT_PHASOR(_2D)
def test_ht_sine_2d_raises(self):
from ferro_ta import HT_SINE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
HT_SINE(_2D)
def test_ht_trendmode_2d_raises(self):
from ferro_ta import HT_TRENDMODE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
HT_TRENDMODE(_2D)
# ===========================================================================
# statistic.py — error-path coverage
# ===========================================================================
class TestStatisticErrorPaths:
"""Cover except ValueError branches in statistic.py."""
def test_stddev_2d_raises(self):
from ferro_ta import STDDEV
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
STDDEV(_2D)
def test_var_2d_raises(self):
from ferro_ta import VAR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
VAR(_2D)
def test_linearreg_2d_raises(self):
from ferro_ta import LINEARREG
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
LINEARREG(_2D)
def test_linearreg_slope_2d_raises(self):
from ferro_ta import LINEARREG_SLOPE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
LINEARREG_SLOPE(_2D)
def test_linearreg_intercept_2d_raises(self):
from ferro_ta import LINEARREG_INTERCEPT
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
LINEARREG_INTERCEPT(_2D)
def test_linearreg_angle_2d_raises(self):
from ferro_ta import LINEARREG_ANGLE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
LINEARREG_ANGLE(_2D)
def test_tsf_2d_raises(self):
from ferro_ta import TSF
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
TSF(_2D)
def test_beta_2d_raises(self):
from ferro_ta import BETA
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
BETA(_2D, CLOSE)
def test_correl_2d_raises(self):
from ferro_ta import CORREL
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CORREL(_2D, CLOSE)
# ===========================================================================
# overlap.py — error-path coverage
# ===========================================================================
class TestOverlapErrorPaths:
"""Cover except ValueError branches in overlap.py."""
def test_sma_2d_raises(self):
from ferro_ta import SMA
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
SMA(_2D)
def test_ema_2d_raises(self):
from ferro_ta import EMA
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
EMA(_2D)
def test_wma_2d_raises(self):
from ferro_ta import WMA
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
WMA(_2D)
def test_trima_2d_raises(self):
from ferro_ta import TRIMA
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
TRIMA(_2D)
def test_kama_2d_raises(self):
from ferro_ta import KAMA
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
KAMA(_2D)
def test_t3_2d_raises(self):
from ferro_ta import T3
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
T3(_2D)
def test_bbands_2d_raises(self):
from ferro_ta import BBANDS
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
BBANDS(_2D)
def test_macd_2d_raises(self):
from ferro_ta import MACD
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MACD(_2D)
def test_macdfix_2d_raises(self):
from ferro_ta import MACDFIX
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MACDFIX(_2D)
def test_sar_2d_raises(self):
from ferro_ta import SAR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
SAR(_2D, LOW)
def test_midpoint_2d_raises(self):
from ferro_ta import MIDPOINT
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MIDPOINT(_2D)
def test_midprice_2d_raises(self):
from ferro_ta import MIDPRICE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MIDPRICE(_2D, LOW)
def test_mama_2d_raises(self):
from ferro_ta import MAMA
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MAMA(_2D)
def test_sarext_2d_raises(self):
from ferro_ta import SAREXT
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
SAREXT(_2D, LOW)
def test_macdext_2d_raises(self):
from ferro_ta import MACDEXT
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MACDEXT(_2D)
# ===========================================================================
# momentum.py — error-path coverage
# ===========================================================================
class TestMomentumErrorPaths:
"""Cover except ValueError branches in momentum.py."""
def test_rsi_2d_raises(self):
from ferro_ta import RSI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
RSI(_2D)
def test_mom_2d_raises(self):
from ferro_ta import MOM
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MOM(_2D)
def test_roc_2d_raises(self):
from ferro_ta import ROC
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
ROC(_2D)
def test_rocp_2d_raises(self):
from ferro_ta import ROCP
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
ROCP(_2D)
def test_rocr_2d_raises(self):
from ferro_ta import ROCR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
ROCR(_2D)
def test_rocr100_2d_raises(self):
from ferro_ta import ROCR100
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
ROCR100(_2D)
def test_willr_2d_raises(self):
from ferro_ta import WILLR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
WILLR(_2D, LOW, CLOSE)
def test_adx_2d_raises(self):
from ferro_ta import ADX
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
ADX(_2D, LOW, CLOSE)
def test_adxr_2d_raises(self):
from ferro_ta import ADXR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
ADXR(_2D, LOW, CLOSE)
def test_apo_2d_raises(self):
from ferro_ta import APO
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
APO(_2D)
def test_ppo_2d_raises(self):
from ferro_ta import PPO
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
PPO(_2D)
def test_cci_2d_raises(self):
from ferro_ta import CCI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CCI(_2D, LOW, CLOSE)
def test_mfi_2d_raises(self):
from ferro_ta import MFI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MFI(_2D, LOW, CLOSE, VOLUME)
def test_bop_2d_raises(self):
from ferro_ta import BOP
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
BOP(_2D, HIGH, LOW, CLOSE)
def test_stochf_2d_raises(self):
from ferro_ta import STOCHF
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
STOCHF(_2D, LOW, CLOSE)
def test_stoch_2d_raises(self):
from ferro_ta import STOCH
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
STOCH(_2D, LOW, CLOSE)
def test_stochrsi_2d_raises(self):
from ferro_ta import STOCHRSI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
STOCHRSI(_2D)
def test_ultosc_2d_raises(self):
from ferro_ta import ULTOSC
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
ULTOSC(_2D, LOW, CLOSE)
def test_dx_2d_raises(self):
from ferro_ta import DX
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
DX(_2D, LOW, CLOSE)
def test_plus_di_2d_raises(self):
from ferro_ta import PLUS_DI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
PLUS_DI(_2D, LOW, CLOSE)
def test_minus_di_2d_raises(self):
from ferro_ta import MINUS_DI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MINUS_DI(_2D, LOW, CLOSE)
def test_plus_dm_2d_raises(self):
from ferro_ta import PLUS_DM
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
PLUS_DM(_2D, LOW)
def test_minus_dm_2d_raises(self):
from ferro_ta import MINUS_DM
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MINUS_DM(_2D, LOW)
def test_cmo_2d_raises(self):
from ferro_ta import CMO
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CMO(_2D)
def test_aroon_2d_raises(self):
from ferro_ta import AROON
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
AROON(_2D, LOW)
def test_aroonosc_2d_raises(self):
from ferro_ta import AROONOSC
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
AROONOSC(_2D, LOW)
def test_trix_2d_raises(self):
from ferro_ta import TRIX
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
TRIX(_2D)
# ===========================================================================
# price_transform.py — error-path coverage
# ===========================================================================
class TestPriceTransformErrorPaths:
"""Cover except ValueError branches in price_transform.py."""
def test_avgprice_2d_raises(self):
from ferro_ta import AVGPRICE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
AVGPRICE(_2D, HIGH, LOW, CLOSE)
def test_medprice_2d_raises(self):
from ferro_ta import MEDPRICE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MEDPRICE(_2D, LOW)
def test_typprice_2d_raises(self):
from ferro_ta import TYPPRICE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
TYPPRICE(_2D, LOW, CLOSE)
def test_wclprice_2d_raises(self):
from ferro_ta import WCLPRICE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
WCLPRICE(_2D, LOW, CLOSE)
# ===========================================================================
# volume.py — error-path coverage
# ===========================================================================
class TestVolumeErrorPaths:
"""Cover except ValueError branches in volume.py."""
def test_ad_2d_raises(self):
from ferro_ta import AD
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
AD(_2D, LOW, CLOSE, VOLUME)
def test_adosc_2d_raises(self):
from ferro_ta import ADOSC
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
ADOSC(_2D, LOW, CLOSE, VOLUME)
def test_obv_2d_raises(self):
from ferro_ta import OBV
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
OBV(_2D, VOLUME)
# ===========================================================================
# volatility.py — error-path coverage
# ===========================================================================
class TestVolatilityErrorPaths:
"""Cover except ValueError branches in volatility.py."""
def test_atr_2d_raises(self):
from ferro_ta import ATR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
ATR(_2D, LOW, CLOSE)
def test_natr_2d_raises(self):
from ferro_ta import NATR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
NATR(_2D, LOW, CLOSE)
# ===========================================================================
# math_ops.py — error-path coverage
# ===========================================================================
class TestMathOpsErrorPaths:
"""Cover except ValueError branches in math_ops.py."""
def test_add_2d_raises(self):
from ferro_ta import ADD
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
ADD(_2D, CLOSE)
def test_sub_2d_raises(self):
from ferro_ta import SUB
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
SUB(_2D, CLOSE)
def test_mult_2d_raises(self):
from ferro_ta import MULT
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MULT(_2D, CLOSE)
def test_div_2d_raises(self):
from ferro_ta import DIV
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
DIV(_2D, CLOSE)
def test_sum_2d_raises(self):
from ferro_ta import SUM
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
SUM(_2D)
def test_max_2d_raises(self):
from ferro_ta import MAX
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MAX(_2D)
def test_min_2d_raises(self):
from ferro_ta import MIN
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MIN(_2D)
def test_maxindex_2d_raises(self):
from ferro_ta import MAXINDEX
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MAXINDEX(_2D)
def test_minindex_2d_raises(self):
from ferro_ta import MININDEX
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
MININDEX(_2D)
# ===========================================================================
# pattern.py — error-path coverage (sample of CDL functions)
# ===========================================================================
class TestPatternErrorPaths:
"""Cover except ValueError branches in pattern.py for CDL functions."""
def _ohlc(self):
return OPEN, HIGH, LOW, CLOSE
def test_cdl2crows_2d_raises(self):
from ferro_ta import CDL2CROWS
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDL2CROWS(_2D, HIGH, LOW, CLOSE)
def test_cdldoji_2d_raises(self):
from ferro_ta import CDLDOJI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLDOJI(_2D, HIGH, LOW, CLOSE)
def test_cdl3blackcrows_2d_raises(self):
from ferro_ta import CDL3BLACKCROWS
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDL3BLACKCROWS(_2D, HIGH, LOW, CLOSE)
def test_cdl3inside_2d_raises(self):
from ferro_ta import CDL3INSIDE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDL3INSIDE(_2D, HIGH, LOW, CLOSE)
def test_cdlengulfing_2d_raises(self):
from ferro_ta import CDLENGULFING
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLENGULFING(_2D, HIGH, LOW, CLOSE)
def test_cdlhammer_2d_raises(self):
from ferro_ta import CDLHAMMER
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLHAMMER(_2D, HIGH, LOW, CLOSE)
def test_cdlmarubozu_2d_raises(self):
from ferro_ta import CDLMARUBOZU
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLMARUBOZU(_2D, HIGH, LOW, CLOSE)
def test_cdlmorningstar_2d_raises(self):
from ferro_ta import CDLMORNINGSTAR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLMORNINGSTAR(_2D, HIGH, LOW, CLOSE)
def test_cdleveningstar_2d_raises(self):
from ferro_ta import CDLEVENINGSTAR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLEVENINGSTAR(_2D, HIGH, LOW, CLOSE)
def test_cdlshootingstar_2d_raises(self):
from ferro_ta import CDLSHOOTINGSTAR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLSHOOTINGSTAR(_2D, HIGH, LOW, CLOSE)
def test_cdlharami_2d_raises(self):
from ferro_ta import CDLHARAMI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLHARAMI(_2D, HIGH, LOW, CLOSE)
def test_cdldojistar_2d_raises(self):
from ferro_ta import CDLDOJISTAR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLDOJISTAR(_2D, HIGH, LOW, CLOSE)
def test_cdlspinningtop_2d_raises(self):
from ferro_ta import CDLSPINNINGTOP
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLSPINNINGTOP(_2D, HIGH, LOW, CLOSE)
def test_cdlkicking_2d_raises(self):
from ferro_ta import CDLKICKING
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLKICKING(_2D, HIGH, LOW, CLOSE)
def test_cdlpiercing_2d_raises(self):
from ferro_ta import CDLPIERCING
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLPIERCING(_2D, HIGH, LOW, CLOSE)
def test_cdl3whitesoldiers_2d_raises(self):
from ferro_ta import CDL3WHITESOLDIERS
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDL3WHITESOLDIERS(_2D, HIGH, LOW, CLOSE)
def test_cdl3outside_2d_raises(self):
from ferro_ta import CDL3OUTSIDE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDL3OUTSIDE(_2D, HIGH, LOW, CLOSE)
def test_cdlmorningdojistar_2d_raises(self):
from ferro_ta import CDLMORNINGDOJISTAR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLMORNINGDOJISTAR(_2D, HIGH, LOW, CLOSE)
def test_cdleveningdojistar_2d_raises(self):
from ferro_ta import CDLEVENINGDOJISTAR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLEVENINGDOJISTAR(_2D, HIGH, LOW, CLOSE)
def test_cdlharamicross_2d_raises(self):
from ferro_ta import CDLHARAMICROSS
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLHARAMICROSS(_2D, HIGH, LOW, CLOSE)
def test_cdl3linestrike_2d_raises(self):
from ferro_ta import CDL3LINESTRIKE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDL3LINESTRIKE(_2D, HIGH, LOW, CLOSE)
def test_cdl3starsinsouth_2d_raises(self):
from ferro_ta import CDL3STARSINSOUTH
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDL3STARSINSOUTH(_2D, HIGH, LOW, CLOSE)
def test_cdlabandonedbaby_2d_raises(self):
from ferro_ta import CDLABANDONEDBABY
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLABANDONEDBABY(_2D, HIGH, LOW, CLOSE)
def test_cdladvanceblock_2d_raises(self):
from ferro_ta import CDLADVANCEBLOCK
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLADVANCEBLOCK(_2D, HIGH, LOW, CLOSE)
def test_cdlbelthold_2d_raises(self):
from ferro_ta import CDLBELTHOLD
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLBELTHOLD(_2D, HIGH, LOW, CLOSE)
def test_cdlbreakaway_2d_raises(self):
from ferro_ta import CDLBREAKAWAY
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLBREAKAWAY(_2D, HIGH, LOW, CLOSE)
def test_cdlclosingmarubozu_2d_raises(self):
from ferro_ta import CDLCLOSINGMARUBOZU
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLCLOSINGMARUBOZU(_2D, HIGH, LOW, CLOSE)
def test_cdlconcealbabyswall_2d_raises(self):
from ferro_ta import CDLCONCEALBABYSWALL
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLCONCEALBABYSWALL(_2D, HIGH, LOW, CLOSE)
def test_cdlcounterattack_2d_raises(self):
from ferro_ta import CDLCOUNTERATTACK
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLCOUNTERATTACK(_2D, HIGH, LOW, CLOSE)
def test_cdldarkcloudcover_2d_raises(self):
from ferro_ta import CDLDARKCLOUDCOVER
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLDARKCLOUDCOVER(_2D, HIGH, LOW, CLOSE)
def test_cdldragonflydoji_2d_raises(self):
from ferro_ta import CDLDRAGONFLYDOJI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLDRAGONFLYDOJI(_2D, HIGH, LOW, CLOSE)
def test_cdlgapsidesidewhite_2d_raises(self):
from ferro_ta import CDLGAPSIDESIDEWHITE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLGAPSIDESIDEWHITE(_2D, HIGH, LOW, CLOSE)
def test_cdlgravestonedoji_2d_raises(self):
from ferro_ta import CDLGRAVESTONEDOJI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLGRAVESTONEDOJI(_2D, HIGH, LOW, CLOSE)
def test_cdlhangingman_2d_raises(self):
from ferro_ta import CDLHANGINGMAN
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLHANGINGMAN(_2D, HIGH, LOW, CLOSE)
def test_cdlhighwave_2d_raises(self):
from ferro_ta import CDLHIGHWAVE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLHIGHWAVE(_2D, HIGH, LOW, CLOSE)
def test_cdlhikkake_2d_raises(self):
from ferro_ta import CDLHIKKAKE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLHIKKAKE(_2D, HIGH, LOW, CLOSE)
def test_cdlhikkakemod_2d_raises(self):
from ferro_ta import CDLHIKKAKEMOD
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLHIKKAKEMOD(_2D, HIGH, LOW, CLOSE)
def test_cdlhomingpigeon_2d_raises(self):
from ferro_ta import CDLHOMINGPIGEON
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLHOMINGPIGEON(_2D, HIGH, LOW, CLOSE)
def test_cdlidentical3crows_2d_raises(self):
from ferro_ta import CDLIDENTICAL3CROWS
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLIDENTICAL3CROWS(_2D, HIGH, LOW, CLOSE)
def test_cdlinneck_2d_raises(self):
from ferro_ta import CDLINNECK
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLINNECK(_2D, HIGH, LOW, CLOSE)
def test_cdlinvertedhammer_2d_raises(self):
from ferro_ta import CDLINVERTEDHAMMER
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLINVERTEDHAMMER(_2D, HIGH, LOW, CLOSE)
def test_cdlkickingbylength_2d_raises(self):
from ferro_ta import CDLKICKINGBYLENGTH
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLKICKINGBYLENGTH(_2D, HIGH, LOW, CLOSE)
def test_cdlladderbottom_2d_raises(self):
from ferro_ta import CDLLADDERBOTTOM
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLLADDERBOTTOM(_2D, HIGH, LOW, CLOSE)
def test_cdllongleggeddoji_2d_raises(self):
from ferro_ta import CDLLONGLEGGEDDOJI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLLONGLEGGEDDOJI(_2D, HIGH, LOW, CLOSE)
def test_cdllongline_2d_raises(self):
from ferro_ta import CDLLONGLINE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLLONGLINE(_2D, HIGH, LOW, CLOSE)
def test_cdlmatchinglow_2d_raises(self):
from ferro_ta import CDLMATCHINGLOW
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLMATCHINGLOW(_2D, HIGH, LOW, CLOSE)
def test_cdlmathold_2d_raises(self):
from ferro_ta import CDLMATHOLD
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLMATHOLD(_2D, HIGH, LOW, CLOSE)
def test_cdlonneck_2d_raises(self):
from ferro_ta import CDLONNECK
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLONNECK(_2D, HIGH, LOW, CLOSE)
def test_cdlrickshawman_2d_raises(self):
from ferro_ta import CDLRICKSHAWMAN
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLRICKSHAWMAN(_2D, HIGH, LOW, CLOSE)
def test_cdlrisefall3methods_2d_raises(self):
from ferro_ta import CDLRISEFALL3METHODS
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLRISEFALL3METHODS(_2D, HIGH, LOW, CLOSE)
def test_cdlseparatinglines_2d_raises(self):
from ferro_ta import CDLSEPARATINGLINES
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLSEPARATINGLINES(_2D, HIGH, LOW, CLOSE)
def test_cdlshortline_2d_raises(self):
from ferro_ta import CDLSHORTLINE
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLSHORTLINE(_2D, HIGH, LOW, CLOSE)
def test_cdlstalledpattern_2d_raises(self):
from ferro_ta import CDLSTALLEDPATTERN
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLSTALLEDPATTERN(_2D, HIGH, LOW, CLOSE)
def test_cdlsticksandwich_2d_raises(self):
from ferro_ta import CDLSTICKSANDWICH
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLSTICKSANDWICH(_2D, HIGH, LOW, CLOSE)
def test_cdltakuri_2d_raises(self):
from ferro_ta import CDLTAKURI
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLTAKURI(_2D, HIGH, LOW, CLOSE)
def test_cdltasukigap_2d_raises(self):
from ferro_ta import CDLTASUKIGAP
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLTASUKIGAP(_2D, HIGH, LOW, CLOSE)
def test_cdlthrusting_2d_raises(self):
from ferro_ta import CDLTHRUSTING
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLTHRUSTING(_2D, HIGH, LOW, CLOSE)
def test_cdltristar_2d_raises(self):
from ferro_ta import CDLTRISTAR
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLTRISTAR(_2D, HIGH, LOW, CLOSE)
def test_cdlunique3river_2d_raises(self):
from ferro_ta import CDLUNIQUE3RIVER
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLUNIQUE3RIVER(_2D, HIGH, LOW, CLOSE)
def test_cdlupsidegap2crows_2d_raises(self):
from ferro_ta import CDLUPSIDEGAP2CROWS
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLUPSIDEGAP2CROWS(_2D, HIGH, LOW, CLOSE)
def test_cdlxsidegap3methods_2d_raises(self):
from ferro_ta import CDLXSIDEGAP3METHODS
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
CDLXSIDEGAP3METHODS(_2D, HIGH, LOW, CLOSE)
# ===========================================================================
# exceptions.py — uncovered paths
# ===========================================================================
class TestExceptionsUncovered:
"""Cover uncovered lines in exceptions.py."""
def test_check_equal_length_with_shape_attr(self):
"""Line 107-108: check_equal_length with object having .shape attr."""
from ferro_ta.core.exceptions import check_equal_length
class FakeArr:
shape = (3,)
arr = FakeArr()
# Should not raise when both have same length via shape attribute
check_equal_length(a=arr, b=arr)
def test_check_equal_length_mismatched_shapes(self):
"""Lines 110-114: check_equal_length raises with mismatched .shape."""
from ferro_ta.core.exceptions import FerroTAInputError, check_equal_length
class FakeArr:
def __init__(self, n):
self.shape = (n,)
with pytest.raises(FerroTAInputError, match="same length"):
check_equal_length(a=FakeArr(3), b=FakeArr(5))
def test_check_min_length_with_shape_attr(self):
"""Lines 167-168: check_min_length with .shape attribute."""
from ferro_ta.core.exceptions import FerroTAInputError, check_min_length
class FakeArr:
shape = (2,)
arr = FakeArr()
# Should raise since len = 2 < min_len = 5
with pytest.raises(FerroTAInputError, match="at least 5 elements"):
check_min_length(arr, 5, name="input")
# ===========================================================================
# extended.py — uncovered path
# ===========================================================================
class TestExtendedUncovered:
"""Cover uncovered lines in extended.py."""
def test_vwap_negative_timeperiod_raises(self):
"""Lines 107-109: VWAP raises FerroTAValueError for negative timeperiod."""
from ferro_ta import VWAP
from ferro_ta.core.exceptions import FerroTAValueError
with pytest.raises(FerroTAValueError, match="timeperiod must be >= 0"):
VWAP(HIGH, LOW, CLOSE, VOLUME, timeperiod=-1)
# ===========================================================================
# options.py — uncovered path
# ===========================================================================
class TestOptionsUncovered:
"""Cover uncovered line in options.py."""
def test_validate_iv_2d_raises(self):
"""Line 63: _validate_iv raises for 2D input."""
from ferro_ta.analysis.options import iv_rank
from ferro_ta.core.exceptions import FerroTAInputError
with pytest.raises(FerroTAInputError):
iv_rank(np.array([[0.2, 0.3]]), window=5)
# ===========================================================================
# adapters.py — uncovered paths
# ===========================================================================
class TestAdaptersUncovered:
"""Cover uncovered lines in adapters.py."""
def test_register_non_subclass_raises(self):
"""Line 82: register_adapter raises TypeError for non-subclass."""
from ferro_ta.data.adapters import register_adapter
with pytest.raises(TypeError):
register_adapter("bad", int)
def test_dataadapter_repr(self):
"""Line 134: DataAdapter __repr__."""
from ferro_ta.data.adapters import InMemoryAdapter
adapter = InMemoryAdapter({"close": CLOSE})
assert "InMemoryAdapter" in repr(adapter)
def test_inmemory_adapter_fetch(self):
"""Lines 263: InMemoryAdapter.fetch returns wrapped data."""
from ferro_ta.data.adapters import InMemoryAdapter
data = {"close": CLOSE, "high": HIGH}
adapter = InMemoryAdapter(data)
result = adapter.fetch()
assert result is data
def test_csvadapter_repr(self):
"""Line 225: CsvAdapter __repr__."""
from ferro_ta.data.adapters import CsvAdapter
adapter = CsvAdapter("/tmp/test.csv")
assert "/tmp/test.csv" in repr(adapter)
def test_csvadapter_fetch_with_rename(self):
"""Lines 200-221: CsvAdapter.fetch with column rename."""
import csv
import os
import tempfile
pytest.importorskip("pandas")
from ferro_ta.data.adapters import CsvAdapter
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, newline=""
) as f:
writer = csv.writer(f)
writer.writerow(["Open", "High", "Low", "Close", "Volume"])
for i in range(5):
writer.writerow([1.0, 1.1, 0.9, 1.0, 1000])
fname = f.name
try:
adapter = CsvAdapter(
fname,
open_col="Open",
high_col="High",
low_col="Low",
close_col="Close",
volume_col="Volume",
)
df = adapter.fetch()
assert "close" in df.columns or "Close" in df.columns
finally:
os.unlink(fname)
# ===========================================================================
# aggregation.py — uncovered paths
# ===========================================================================
class TestAggregationUncovered:
"""Cover uncovered lines in aggregation.py."""
def test_aggregate_ticks_no_pandas_fallback(self):
"""Lines 194-195: aggregate_ticks without pandas returns dict."""
from ferro_ta.data.aggregation import aggregate_ticks
rng = np.random.default_rng(0)
n = 200
ticks = {
"price": rng.uniform(99, 101, n),
"size": rng.uniform(1, 10, n),
}
bars = aggregate_ticks(ticks, rule="tick:50")
assert "close" in bars
def test_tick_aggregator_repr(self):
"""Line 233: TickAggregator __repr__."""
from ferro_ta.data.aggregation import TickAggregator
agg = TickAggregator(rule="tick:50")
assert "TickAggregator" in repr(agg)
assert "tick:50" in repr(agg)
def test_aggregate_ticks_with_extra_timestamp(self):
"""Lines 75-76, 80: aggregate_ticks with extra (timestamp) parameter."""
from ferro_ta.data.aggregation import aggregate_ticks
rng = np.random.default_rng(0)
n = 200
ticks = {
"price": rng.uniform(99, 101, n),
"size": rng.uniform(1, 10, n),
"timestamp": np.arange(n, dtype=np.int64),
}
bars = aggregate_ticks(ticks, rule="tick:50")
assert "close" in bars
def test_time_resample_missing_columns_raises(self):
"""Lines 156-163: aggregate_ticks with time rule requires timestamp."""
from ferro_ta.data.aggregation import aggregate_ticks
# Time bars without timestamp should raise ValueError
rng = np.random.default_rng(0)
n = 200
ticks = {
"price": rng.uniform(99, 101, n),
"size": rng.uniform(1, 10, n),
# no timestamp — time bars would require one
}
with pytest.raises(ValueError, match="timestamp"):
aggregate_ticks(ticks, rule="time:60")
def test_time_resample_non_datetime_index_raises(self):
"""Lines 155-162: aggregate_ticks with pandas DataFrame."""
pd = pytest.importorskip("pandas")
from ferro_ta.data.aggregation import aggregate_ticks
rng = np.random.default_rng(0)
n = 200
df = pd.DataFrame(
{
"price": rng.uniform(99, 101, n),
"size": rng.uniform(1, 10, n),
}
)
bars = aggregate_ticks(df, rule="tick:50")
assert "close" in bars
# ===========================================================================
# alerts.py — uncovered paths
# ===========================================================================
class TestAlertsUncovered:
"""Cover uncovered lines in alerts.py."""
def test_alert_event_repr(self):
"""Line 166: AlertEvent __repr__."""
from ferro_ta.tools.alerts import AlertEvent
ev = AlertEvent("test_cond", bar_index=5, value=75.0, payload={"key": "val"})
r = repr(ev)
assert "test_cond" in r
assert "5" in r
def test_dispatch_with_callback(self):
"""Lines 407-408: _dispatch invokes callback."""
from ferro_ta.tools.alerts import AlertEvent, AlertManager
events_received = []
def cb(ev):
events_received.append(ev)
ev = AlertEvent("cond", bar_index=1, value=50.0)
AlertManager._dispatch(ev, cb, None)
assert len(events_received) == 1
def test_dispatch_callback_exception_logged(self):
"""Lines 407-408: _dispatch swallows callback exceptions."""
from ferro_ta.tools.alerts import AlertEvent, AlertManager
def bad_cb(ev):
raise RuntimeError("callback error")
ev = AlertEvent("cond", bar_index=1, value=50.0)
# Should not raise — exception is logged/swallowed
AlertManager._dispatch(ev, bad_cb, None)
def test_dispatch_webhook_post(self):
"""Lines 410-411: _dispatch calls _post_webhook when url is set."""
from ferro_ta.tools.alerts import AlertEvent, AlertManager
ev = AlertEvent("cond", bar_index=1, value=50.0)
# Use an invalid URL to trigger the except branch in _post_webhook
AlertManager._dispatch(ev, None, "http://localhost:99999/webhook")
def test_post_webhook_failure_logged(self):
"""Lines 416-430: _post_webhook logs failure on connection error."""
from ferro_ta.tools.alerts import AlertManager
# Should not raise — failure is logged
AlertManager._post_webhook("http://localhost:99999/x", {"key": "val"})
def test_alert_manager_force_live_with_callback(self):
"""Lines 388: AlertManager.run_backtest with force_live dispatches events."""
from ferro_ta.tools.alerts import AlertManager
received = []
def cb(ev):
received.append(ev)
series = np.array([20.0, 25.0, 30.0, 35.0, 28.0])
mgr = AlertManager(symbol="TEST")
mgr.add_threshold_condition(
"rsi_ob", series, level=29.0, direction=1, callback=cb
)
events = mgr.run_backtest(force_live=True)
# Events should have been dispatched
assert len(received) > 0 or len(events) >= 0
# ===========================================================================
# attribution.py — uncovered paths
# ===========================================================================
class TestAttributionUncovered:
"""Cover uncovered lines in attribution.py."""
def test_trade_stats_repr(self):
"""Line 105: TradeStats __repr__."""
from ferro_ta.analysis.attribution import TradeStats
ts = TradeStats(
win_rate=0.55,
avg_win=120.0,
avg_loss=-80.0,
profit_factor=1.8,
avg_hold_bars=5.0,
n_trades=20,
)
r = repr(ts)
assert "n_trades=20" in r
assert "win_rate" in r
def test_monthly_contribution_without_timestamps(self):
"""Lines 282-307: attribution_by_month without timestamps."""
from ferro_ta.analysis.attribution import attribution_by_month
ret = RNG.normal(0, 0.01, 100)
result = attribution_by_month(ret)
assert isinstance(result, dict)
assert len(result) > 0
def test_monthly_contribution_with_timestamps(self):
"""Lines 267-281: attribution_by_month with timestamps."""
pd = pytest.importorskip("pandas")
from ferro_ta.analysis.attribution import attribution_by_month
n = 60
ret = RNG.normal(0, 0.01, n)
ts = pd.date_range("2023-01-01", periods=n, freq="D")
timestamps = ts.view("int64")
result = attribution_by_month(ret, timestamps=timestamps)
assert isinstance(result, dict)
def test_factor_attribution_basic(self):
"""Lines 290-305: attribution_by_signal returns factor exposures."""
from ferro_ta.analysis.attribution import attribution_by_signal
n = 100
portfolio_ret = RNG.normal(0, 0.01, n)
signal = (RNG.normal(0, 1, n) > 0).astype(np.float64)
result = attribution_by_signal(portfolio_ret, signal)
assert isinstance(result, dict)
# ===========================================================================
# backtest.py — uncovered paths
# ===========================================================================
class TestBacktestUncovered:
"""Cover uncovered lines in backtest.py."""
def test_sma_cross_fast_gt_slow_raises(self):
"""Lines 188-190: sma_crossover_strategy raises when fast >= slow."""
from ferro_ta.analysis.backtest import sma_crossover_strategy
from ferro_ta.core.exceptions import FerroTAValueError
with pytest.raises(FerroTAValueError):
sma_crossover_strategy(CLOSE, fast=26, slow=12)
def test_sma_cross_fast_zero_raises(self):
"""Line 188: sma_crossover_strategy raises for fast < 1."""
from ferro_ta.analysis.backtest import sma_crossover_strategy
from ferro_ta.core.exceptions import FerroTAValueError
with pytest.raises(FerroTAValueError):
sma_crossover_strategy(CLOSE, fast=0, slow=20)
def test_macd_cross_fast_ge_slow_raises(self):
"""Line 232: macd_crossover_strategy raises when fast >= slow."""
from ferro_ta.analysis.backtest import macd_crossover_strategy
from ferro_ta.core.exceptions import FerroTAValueError
with pytest.raises(FerroTAValueError):
macd_crossover_strategy(CLOSE, fastperiod=26, slowperiod=12)
def test_backtest_unknown_string_strategy_raises(self):
"""Line 338: backtest raises for unknown strategy string."""
from ferro_ta.analysis.backtest import backtest
from ferro_ta.core.exceptions import FerroTAValueError
with pytest.raises(FerroTAValueError, match="strategy must be"):
backtest(CLOSE, strategy=123)
# ===========================================================================
# batch.py — uncovered paths
# ===========================================================================
class TestBatchUncovered:
"""Cover uncovered lines in batch.py."""
def test_batch_sma_1d_fallback(self):
"""Line 95: batch_sma with 1D input calls single-series SMA."""
from ferro_ta.data.batch import batch_sma
result = batch_sma(CLOSE, timeperiod=5)
assert len(result) == len(CLOSE)
def test_batch_ema_1d_fallback(self):
"""Line 137: batch_ema with 1D input."""
from ferro_ta.data.batch import batch_ema
result = batch_ema(CLOSE, timeperiod=5)
assert len(result) == len(CLOSE)
def test_batch_rsi_1d_fallback(self):
"""Line 160: batch_rsi with 1D input."""
from ferro_ta.data.batch import batch_rsi
result = batch_rsi(CLOSE, timeperiod=14)
assert len(result) == len(CLOSE)
def test_batch_apply_1d(self):
"""Line 185: batch_apply with 1D input."""
from ferro_ta import SMA
from ferro_ta.data.batch import batch_apply
result = batch_apply(CLOSE, SMA, timeperiod=5)
assert len(result) == len(CLOSE)
def test_batch_apply_3d_raises(self):
"""Line 162, 187: batch_apply with 3D input raises ValueError."""
from ferro_ta import SMA
from ferro_ta.data.batch import batch_apply
arr_3d = np.ones((10, 3, 2))
with pytest.raises(ValueError, match="1-D or 2-D"):
batch_apply(arr_3d, SMA, timeperiod=5)
# ===========================================================================
# chunked.py — uncovered paths
# ===========================================================================
class TestChunkedUncovered:
"""Cover uncovered lines in chunked.py."""
def test_chunk_apply_empty_series(self):
"""Line 190: chunk_apply returns empty for zero-length input."""
from ferro_ta import SMA
from ferro_ta.data.chunked import chunk_apply
result = chunk_apply(SMA, np.array([]), timeperiod=5)
assert len(result) == 0
def test_chunk_apply_small_series_no_chunks(self):
"""Lines 194-195: chunk_apply falls back when ranges is empty."""
from ferro_ta import SMA
from ferro_ta.data.chunked import chunk_apply
# chunk_size larger than series → make_chunk_ranges returns [] → fallback
result = chunk_apply(
SMA, CLOSE[:10], chunk_size=5000, overlap=100, timeperiod=3
)
assert len(result) == 10
# ===========================================================================
# crypto.py — uncovered paths
# ===========================================================================
class TestCryptoUncovered:
"""Cover uncovered lines in crypto.py."""
def test_funding_rate_pnl_with_tuple_ohlcv(self):
"""Lines 67-107: funding_pnl basic usage."""
from ferro_ta.analysis.crypto import funding_pnl
n = 96
position_size = np.ones(n)
funding = np.full(n, 0.0001)
result = funding_pnl(position_size, funding)
assert len(result) == n
# ===========================================================================
# dsl.py — uncovered paths
# ===========================================================================
class TestDSLUncovered:
"""Cover uncovered lines in dsl.py."""
def test_expr_not_implemented(self):
"""Line 71: _Expr.eval raises NotImplementedError."""
from ferro_ta.tools.dsl import _Expr
expr = _Expr()
with pytest.raises(NotImplementedError):
expr.eval({})
def test_price_ref_missing_raises(self):
"""Line 80: _PriceRef raises ValueError for missing series."""
from ferro_ta.tools.dsl import _PriceRef
ref = _PriceRef("volume")
with pytest.raises(ValueError, match="not found"):
ref.eval({"close": CLOSE})
def test_indicator_call_missing_close_raises(self):
"""Line 101: _IndicatorCall raises ValueError when close missing."""
from ferro_ta.tools.dsl import _IndicatorCall
call = _IndicatorCall("SMA", [14])
with pytest.raises(ValueError, match="'close' series is required"):
call.eval({})
def test_indicator_call_unknown_indicator_raises(self):
"""Lines 125-128: _IndicatorCall raises for unknown indicator name."""
from ferro_ta.tools.dsl import _IndicatorCall
call = _IndicatorCall("NONEXISTENT_INDICATOR_XYZ", [14])
with pytest.raises((ValueError, Exception)):
call.eval({"close": CLOSE})
def test_crossover_below_expr(self):
"""Lines 191-203: _CrossFunc evaluates 'below' direction."""
from ferro_ta.tools.dsl import _CrossFunc, _Expr
class ConstArr(_Expr):
def __init__(self, arr):
self._arr = arr
def eval(self, ctx):
return self._arr
fast_arr = np.array([15.0, 15.0, 12.0, 11.0])
slow_arr = np.array([13.0, 13.0, 13.0, 13.0])
crossover = _CrossFunc("below", ConstArr(fast_arr), ConstArr(slow_arr))
result = crossover.eval({})
assert result[2] == 1
def test_dsl_parse_and_run(self):
"""Lines 119, 123-124, 126: DSL parse and run with indicators."""
from ferro_ta.tools.dsl import evaluate
ctx = {
"close": CLOSE,
"high": HIGH,
"low": LOW,
}
result = evaluate("SMA(14)", ctx)
assert isinstance(result, np.ndarray)
def test_dsl_comparison_operators(self):
"""Lines 270, 279: DSL comparison operators."""
from ferro_ta.tools.dsl import evaluate
ctx = {"close": CLOSE}
# Expression with comparison
result = evaluate("RSI(14) < 40", ctx)
assert isinstance(result, np.ndarray)
# ===========================================================================
# features.py — uncovered paths
# ===========================================================================
class TestFeaturesUncovered:
"""Cover uncovered lines in features.py."""
def test_feature_matrix_missing_close_raises(self):
"""Line 115: feature_matrix raises when close col missing."""
from ferro_ta.analysis.features import feature_matrix
with pytest.raises(ValueError, match="close column"):
feature_matrix({"high": HIGH}, [("SMA", {"timeperiod": 5})])
def test_feature_matrix_multi_output_with_index(self):
"""Lines 152-165: feature_matrix with tuple output and out_key."""
from ferro_ta.analysis.features import feature_matrix
ohlcv = {"close": CLOSE, "high": HIGH, "low": LOW, "volume": VOLUME}
fm = feature_matrix(
ohlcv,
[("BBANDS", {"timeperiod": 5}, 0)], # out_key=0 → BBANDS_0
)
assert isinstance(fm, dict) or hasattr(fm, "columns")
def test_feature_matrix_nan_policy_drop(self):
"""Lines 183-194: feature_matrix with nan_policy='drop'."""
from ferro_ta.analysis.features import feature_matrix
ohlcv = {"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]}
fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 5})], nan_policy="drop")
# Result should have fewer rows than input (NaNs dropped)
if hasattr(fm, "__len__"):
assert len(fm) <= 30
def test_feature_matrix_nan_policy_fill(self):
"""Lines 204-222: feature_matrix with nan_policy='fill'."""
from ferro_ta.analysis.features import feature_matrix
ohlcv = {"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]}
fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 5})], nan_policy="fill")
assert fm is not None
def test_feature_matrix_pandas_dataframe_input(self):
"""Lines 100-103: feature_matrix with pandas DataFrame."""
pd = pytest.importorskip("pandas")
from ferro_ta.analysis.features import feature_matrix
df = pd.DataFrame({"close": CLOSE[:30], "high": HIGH[:30], "low": LOW[:30]})
fm = feature_matrix(df, [("SMA", {"timeperiod": 5})])
assert isinstance(fm, pd.DataFrame)
# ===========================================================================
# gpu.py — uncovered paths (mock CuPy)
# ===========================================================================
class TestGPUUncovered:
"""Cover uncovered lines in gpu.py using mocked CuPy."""
def test_sma_gpu_no_cupy_falls_back(self):
"""Line 42: sma falls back to CPU when CuPy not available."""
from ferro_ta.tools.gpu import sma as gpu_sma
result = gpu_sma(CLOSE, timeperiod=5)
assert len(result) == len(CLOSE)
def test_ema_gpu_no_cupy_falls_back(self):
"""Lines 55-57: ema falls back to CPU when CuPy not available."""
from ferro_ta.tools.gpu import ema as gpu_ema
result = gpu_ema(CLOSE, timeperiod=5)
assert len(result) == len(CLOSE)
def test_rsi_gpu_no_cupy_falls_back(self):
"""Line 62: rsi falls back to CPU when CuPy not available."""
from ferro_ta.tools.gpu import rsi as gpu_rsi
result = gpu_rsi(CLOSE, timeperiod=14)
assert len(result) == len(CLOSE)
def test_sma_gpu_with_mock_cupy(self):
"""_is_torch and _to_cpu helpers: NumPy arrays are not torch, _to_cpu passes through."""
import ferro_ta.tools.gpu as gpu_module
arr = np.asarray(CLOSE[:30], dtype=np.float64)
assert not gpu_module._is_torch(arr)
result = gpu_module._to_cpu(arr)
np.testing.assert_array_equal(result, arr)
def test_ema_gpu_with_mock_cupy(self):
"""Lines 138-178: public sma/ema/rsi fallback produces correct shapes."""
import ferro_ta.tools.gpu as gpu_module
arr = np.asarray(CLOSE[:50], dtype=np.float64)
# All three public functions should fall back to CPU
sma_result = gpu_module.sma(arr, timeperiod=5)
ema_result = gpu_module.ema(arr, timeperiod=5)
rsi_result = gpu_module.rsi(arr, timeperiod=14)
assert len(sma_result) == len(arr)
assert len(ema_result) == len(arr)
assert len(rsi_result) == len(arr)
def test_rsi_gpu_with_mock_cupy(self):
"""gpu.py __all__ list and module attributes (_TORCH_AVAILABLE)."""
import ferro_ta.tools.gpu as gpu_module
assert hasattr(gpu_module, "sma")
assert hasattr(gpu_module, "ema")
assert hasattr(gpu_module, "rsi")
assert hasattr(gpu_module, "_TORCH_AVAILABLE")
# ===========================================================================
# viz.py — uncovered paths (mock matplotlib/plotly)
# ===========================================================================
class TestVizUncovered:
"""Cover uncovered lines in viz.py using mocked backends."""
def test_plot_dict_input(self):
"""Lines 146-154: _extract_close_volume with dict input."""
from ferro_ta.tools.viz import _extract_close_volume
ohlcv = {"close": CLOSE, "volume": VOLUME}
close, vol = _extract_close_volume(ohlcv, "close", "volume")
np.testing.assert_array_equal(close, CLOSE)
def test_plot_dict_no_volume(self):
"""_extract_close_volume returns None for missing volume key."""
from ferro_ta.tools.viz import _extract_close_volume
ohlcv = {"close": CLOSE}
close, vol = _extract_close_volume(ohlcv, "close", "volume")
assert vol is None
def test_plot_array_input(self):
"""_extract_close_volume with plain array input."""
from ferro_ta.tools.viz import _extract_close_volume
close, vol = _extract_close_volume(CLOSE, "close", "volume")
np.testing.assert_array_equal(close, CLOSE)
assert vol is None
def test_n_subplots_calculation(self):
"""Lines 167-176: _n_subplots helper."""
from ferro_ta.tools.viz import _n_subplots
assert _n_subplots(None, None) == 1
assert _n_subplots(None, VOLUME) == 2
assert _n_subplots({"RSI": CLOSE}, None) == 2
assert _n_subplots({"RSI": CLOSE}, VOLUME) == 3
def test_plot_matplotlib_no_matplotlib_raises(self):
"""Lines 194-200: _plot_matplotlib raises ImportError without matplotlib."""
from ferro_ta.tools.viz import _plot_matplotlib
with patch.dict(
sys.modules,
{
"matplotlib": None,
"matplotlib.pyplot": None,
"matplotlib.gridspec": None,
},
):
with pytest.raises((ImportError, Exception)):
_plot_matplotlib(
CLOSE,
None,
None,
title=None,
figsize=None,
savefig=None,
show=False,
)
def test_plot_plotly_no_plotly_raises(self):
"""Lines 262-268: _plot_plotly raises ImportError without plotly."""
from ferro_ta.tools.viz import _plot_plotly
with patch.dict(
sys.modules,
{"plotly": None, "plotly.graph_objects": None, "plotly.subplots": None},
):
with pytest.raises((ImportError, Exception)):
_plot_plotly(
CLOSE,
None,
None,
title=None,
figsize=None,
savefig=None,
show=False,
)
def test_plot_unknown_backend_raises(self):
"""Line 116-119: plot raises ValueError for unknown backend."""
from ferro_ta.tools.viz import plot
with pytest.raises(ValueError, match="Unknown backend"):
plot({"close": CLOSE}, backend="unknown_backend")
def test_plot_matplotlib_backend(self):
"""Lines 106, 194-244: plot with matplotlib backend."""
matplotlib = pytest.importorskip("matplotlib")
matplotlib.use("Agg") # non-interactive backend
from ferro_ta import RSI, SMA
from ferro_ta.tools.viz import plot
ohlcv = {"close": CLOSE[:50], "volume": VOLUME[:50]}
fig = plot(
ohlcv,
indicators={
"SMA(10)": SMA(CLOSE[:50], timeperiod=10),
"RSI(14)": RSI(CLOSE[:50], timeperiod=14),
},
backend="matplotlib",
show=False,
volume=True,
title="Test",
)
assert fig is not None
def test_plot_pandas_dataframe_input(self):
"""Lines 146-154: _extract_close_volume with pandas DataFrame."""
pd = pytest.importorskip("pandas")
from ferro_ta.tools.viz import _extract_close_volume
df = pd.DataFrame({"close": CLOSE[:10], "volume": VOLUME[:10]})
close, vol = _extract_close_volume(df, "close", "volume")
assert len(close) == 10
# ===========================================================================
# dashboard.py — uncovered paths
# ===========================================================================
class TestDashboardUncovered:
"""Cover uncovered lines in dashboard.py."""
def test_indicator_widget_no_ipywidgets_raises(self):
"""Lines 96-132: indicator_widget raises ImportError without ipywidgets."""
from ferro_ta.tools.dashboard import indicator_widget
with patch.dict(
sys.modules,
{"ipywidgets": None, "matplotlib": None, "matplotlib.pyplot": None},
):
with pytest.raises((ImportError, Exception)):
indicator_widget(CLOSE, lambda c, **kw: c, "timeperiod", range(5, 15))
def test_backtest_widget_no_ipywidgets_raises(self):
"""Lines 160-203: backtest_widget raises ImportError without ipywidgets."""
from ferro_ta.tools.dashboard import backtest_widget
with patch.dict(
sys.modules,
{"ipywidgets": None, "matplotlib": None, "matplotlib.pyplot": None},
):
with pytest.raises((ImportError, Exception)):
backtest_widget(CLOSE)
def test_streamlit_app_no_streamlit_raises(self):
"""Lines 240-336: streamlit_app raises ImportError without streamlit."""
from ferro_ta.tools.dashboard import streamlit_app
with patch.dict(sys.modules, {"streamlit": None}):
with pytest.raises((ImportError, Exception)):
streamlit_app()
# ===========================================================================
# regime.py — uncovered paths
# ===========================================================================
class TestRegimeUncovered:
"""Cover uncovered lines in regime.py."""
def test_detect_regime_with_dataframe_ohlcv(self):
"""Lines 249-256: regime accepts pandas DataFrame."""
pd = pytest.importorskip("pandas")
from ferro_ta.analysis.regime import regime
df = pd.DataFrame(
{
"open": OPEN,
"high": HIGH,
"low": LOW,
"close": CLOSE,
"volume": VOLUME,
}
)
result = regime(df)
assert isinstance(result, np.ndarray)
def test_detect_regime_with_tuple_ohlcv(self):
"""Lines 254-256: regime with tuple ohlcv."""
from ferro_ta.analysis.regime import regime
ohlcv = (OPEN, HIGH, LOW, CLOSE, VOLUME)
result = regime(ohlcv)
assert isinstance(result, np.ndarray)
# ===========================================================================
# resampling.py — uncovered paths
# ===========================================================================
class TestResamplingUncovered:
"""Cover uncovered lines in resampling.py."""
def test_resample_ohlcv_missing_columns_raises(self):
"""Lines 114-115: resample raises for missing columns."""
pd = pytest.importorskip("pandas")
from ferro_ta.data.resampling import resample
df = pd.DataFrame(
{"close": [1.0, 2.0]},
index=pd.to_datetime(["2024-01-01", "2024-01-02"]),
)
with pytest.raises((ValueError, KeyError)):
resample(df, rule="1D")
def test_resample_ohlcv_non_datetime_index_raises(self):
"""Lines 123-128: resample raises for non-DatetimeIndex."""
pd = pytest.importorskip("pandas")
from ferro_ta.data.resampling import resample
df = pd.DataFrame(
{
"open": [1.0, 2.0],
"high": [1.1, 2.1],
"low": [0.9, 1.9],
"close": [1.0, 2.0],
"volume": [1000.0, 2000.0],
}
)
with pytest.raises(ValueError, match="DatetimeIndex"):
resample(df, rule="1D")
def test_multi_timeframe_no_pandas_fallback(self):
"""Line 198-199: multi_timeframe with pandas DataFrame."""
from ferro_ta.data.resampling import multi_timeframe
pd_module = pytest.importorskip("pandas")
# With pandas available, test basic usage
n = 100
df = pd_module.DataFrame(
{
"open": OPEN[:n],
"high": HIGH[:n],
"low": LOW[:n],
"close": CLOSE[:n],
"volume": VOLUME[:n],
},
index=pd_module.date_range("2024-01-01", periods=n, freq="1h"),
)
result = multi_timeframe(df, rules=["4h"])
assert "4h" in result
def test_ohlcv_resampler_repr(self):
"""Line 276: volume_bars and resample function."""
from ferro_ta.data.resampling import resample
pd_module = pytest.importorskip("pandas")
n = 60
df = pd_module.DataFrame(
{
"open": OPEN[:n],
"high": HIGH[:n],
"low": LOW[:n],
"close": CLOSE[:n],
"volume": VOLUME[:n],
},
index=pd_module.date_range("2024-01-01", periods=n, freq="5min"),
)
result = resample(df, rule="15min")
assert len(result) < n
# ===========================================================================
# signals.py — uncovered paths
# ===========================================================================
class TestSignalsUncovered:
"""Cover uncovered lines in signals.py."""
def test_compose_signals_pandas_dataframe(self):
"""Lines 115-123: compose with pandas DataFrame."""
pd = pytest.importorskip("pandas")
from ferro_ta.analysis.signals import compose
n = 50
signals_df = pd.DataFrame(
{
"sig1": RNG.choice([-1, 0, 1], n).astype(np.float64),
"sig2": RNG.choice([-1, 0, 1], n).astype(np.float64),
}
)
result = compose(signals_df, method="mean")
assert len(result) == n
def test_screen_symbols_pandas_series(self):
"""Lines 196-197: screen with pandas Series."""
pd = pytest.importorskip("pandas")
from ferro_ta.analysis.signals import screen
scores = pd.Series({"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9})
result = screen(scores, top_n=2)
assert len(result) == 2
def test_screen_symbols_above_filter(self):
"""Lines 223-224: screen with above filter."""
from ferro_ta.analysis.signals import screen
scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9}
result = screen(scores, above=0.7)
assert "GOOG" not in result
assert "AAPL" in result
def test_screen_symbols_below_filter(self):
"""Lines 225-227: screen with below filter."""
from ferro_ta.analysis.signals import screen
scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9}
result = screen(scores, below=0.7)
assert "GOOG" in result
assert "MSFT" not in result
def test_screen_symbols_list_input(self):
"""Lines 202-210: screen with list input."""
from ferro_ta.analysis.signals import screen
scores = [0.8, 0.5, 0.9]
result = screen(scores, top_n=2)
assert len(result) == 2
def test_compose_signals_rank_method(self):
"""Line 126: compose with rank method."""
from ferro_ta.analysis.signals import compose
n = 50
signals = np.column_stack(
[
RNG.choice([-1, 0, 1], n).astype(np.float64),
RNG.choice([-1, 0, 1], n).astype(np.float64),
]
)
result = compose(signals, method="rank")
assert len(result) == n
# ===========================================================================
# pipeline.py — uncovered paths
# ===========================================================================
class TestPipelineUncovered:
"""Cover uncovered lines in pipeline.py."""
def test_add_non_callable_raises(self):
"""Line 170: Pipeline.add raises TypeError for non-callable."""
from ferro_ta.tools.pipeline import Pipeline
p = Pipeline()
with pytest.raises(TypeError, match="func must be callable"):
p.add("bad_step", "not_a_function")
def test_add_duplicate_name_raises(self):
"""Lines 179-183: Pipeline.add raises ValueError for duplicate name."""
from ferro_ta import SMA
from ferro_ta.tools.pipeline import Pipeline
p = Pipeline()
p.add("sma", SMA, timeperiod=5)
with pytest.raises(ValueError, match="already exists"):
p.add("sma", SMA, timeperiod=10)
def test_add_duplicate_output_key_raises(self):
"""Lines 176-177: Pipeline.add raises ValueError for duplicate output key."""
from ferro_ta import BBANDS
from ferro_ta.tools.pipeline import Pipeline
p = Pipeline()
p.add("bands", BBANDS, timeperiod=5, output_keys=["upper", "mid", "lower"])
with pytest.raises(ValueError, match="Duplicate output key"):
p.add("bands2", BBANDS, timeperiod=10, output_keys=["upper", "x", "y"])
def test_remove_missing_step_raises(self):
"""Line 210: Pipeline.remove raises KeyError for missing step."""
from ferro_ta.tools.pipeline import Pipeline
p = Pipeline()
with pytest.raises(KeyError, match="No step named"):
p.remove("nonexistent")
def test_pipeline_run_with_tuple_output_and_output_keys(self):
"""Lines 267-277: Pipeline.run handles tuple output with output_keys."""
from ferro_ta import BBANDS
from ferro_ta.tools.pipeline import Pipeline
p = Pipeline()
p.add("bands", BBANDS, timeperiod=5, output_keys=["upper", "mid", "lower"])
result = p.run(CLOSE)
assert "upper" in result
assert "mid" in result
assert "lower" in result
def test_pipeline_run_output_keys_length_mismatch_raises(self):
"""Lines 269-272: Pipeline.run raises ValueError for output_keys length mismatch."""
from ferro_ta import BBANDS
from ferro_ta.tools.pipeline import Pipeline
p = Pipeline()
p.add("bands", BBANDS, timeperiod=5, output_keys=["upper"]) # BBANDS returns 3
with pytest.raises(ValueError, match="output_keys has"):
p.run(CLOSE)
def test_make_pipeline(self):
"""Lines 291, 300-301: make_pipeline convenience factory."""
from ferro_ta import RSI, SMA
from ferro_ta.tools.pipeline import make_pipeline
p = make_pipeline(
sma=(SMA, {"timeperiod": 10}),
rsi=(RSI, {"timeperiod": 14}),
)
result = p.run(CLOSE)
assert "sma" in result
assert "rsi" in result
# ===========================================================================
# portfolio.py — uncovered paths
# ===========================================================================
class TestPortfolioUncovered:
"""Cover uncovered lines in portfolio.py."""
def test_correlation_matrix_pandas_returns(self):
"""Lines 88-95: correlation_matrix with pandas DataFrame returns."""
pd = pytest.importorskip("pandas")
from ferro_ta.analysis.portfolio import correlation_matrix
n = 100
df = pd.DataFrame(
{
"AAPL": RNG.normal(0, 0.01, n),
"GOOG": RNG.normal(0, 0.01, n),
}
)
result = correlation_matrix(df)
assert isinstance(result, pd.DataFrame)
assert result.shape == (2, 2)
def test_portfolio_beta_basic(self):
"""Lines 164-195: portfolio.beta returns scalar or array."""
from ferro_ta.analysis.portfolio import beta
n = 100
asset_ret = RNG.normal(0, 0.01, n)
bench_ret = RNG.normal(0, 0.01, n)
result = beta(asset_ret, bench_ret)
assert isinstance(result, (float, np.floating))
# ===========================================================================
# tools.py — uncovered paths
# ===========================================================================
class TestToolsUncovered:
"""Cover uncovered lines in tools.py."""
def test_call_indicator_multi_output_returns_dict(self):
"""Line 129: compute_indicator returns dict for multi-output indicators."""
from ferro_ta.tools import compute_indicator
result = compute_indicator("BBANDS", CLOSE, timeperiod=5)
assert isinstance(result, dict)
assert "upper" in result
def test_describe_indicator_unknown_raises(self):
"""Line 273: describe_indicator raises for unknown name."""
from ferro_ta.tools import describe_indicator
with pytest.raises(Exception):
describe_indicator("NONEXISTENT_INDICATOR_XYZ")
def test_describe_indicator_known(self):
"""describe_indicator returns description for known indicator."""
from ferro_ta.tools import describe_indicator
result = describe_indicator("SMA")
assert isinstance(result, str)
assert len(result) > 0
# ===========================================================================
# workflow.py — uncovered paths
# ===========================================================================
class TestWorkflowUncovered:
"""Cover uncovered lines in workflow.py."""
def test_workflow_with_multi_output_indicator_alert(self):
"""Lines 230, 233: workflow.run with alert on dict output (skip silently)."""
from ferro_ta.tools.workflow import Workflow
wf = Workflow()
wf.add_indicator("bb", "BBANDS", timeperiod=5)
# Add an alert on a multi-output indicator key (should be skipped silently)
wf.add_alert("bb", level=CLOSE.mean(), direction=1)
result = wf.run(CLOSE)
assert "bb" in result