307beeca02
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
2514 lines
85 KiB
Python
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
|