mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-05 12:37:43 +00:00
fix(python): critical bug fixes across Python wrapper
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"""test_category_labels.py — Verify consistent category labels across all modules."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import re
|
||||
import pytest
|
||||
|
||||
CATEGORY_MODULES = {
|
||||
"quantalib.channels": "channels",
|
||||
"quantalib.core": "core",
|
||||
"quantalib.cycles": "cycles",
|
||||
"quantalib.dynamics": "dynamics",
|
||||
"quantalib.errors": "errors",
|
||||
"quantalib.filters": "filters",
|
||||
"quantalib.momentum": "momentum",
|
||||
"quantalib.numerics": "numerics",
|
||||
"quantalib.oscillators": "oscillators",
|
||||
"quantalib.reversals": "reversals",
|
||||
"quantalib.statistics": "statistics",
|
||||
"quantalib.trends_fir": "trends_fir",
|
||||
"quantalib.trends_iir": "trends_iir",
|
||||
"quantalib.volatility": "volatility",
|
||||
"quantalib.volume": "volume",
|
||||
}
|
||||
|
||||
# Regex to match _wrap(..., "CATEGORY", ...) or _wrap_multi(..., "CATEGORY", ...)
|
||||
LABEL_PATTERN = re.compile(r'_wrap(?:_multi)?\(.*?,\s*"([^"]+)",\s*(?:offset|[\w]+)\)')
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modname,expected", CATEGORY_MODULES.items(), ids=lambda x: x.split(".")[-1] if "." in x else x)
|
||||
def test_category_labels_lowercase(modname: str, expected: str) -> None:
|
||||
"""Category labels passed to _wrap/_wrap_multi must be lowercase."""
|
||||
mod = importlib.import_module(modname)
|
||||
source_file = inspect.getfile(mod)
|
||||
with open(source_file, "r", encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
|
||||
# Find all category labels in source
|
||||
labels = LABEL_PATTERN.findall(source)
|
||||
bad = [lbl for lbl in labels if lbl != expected]
|
||||
assert bad == [], (
|
||||
f"{modname}: expected category label '{expected}', found non-matching: {set(bad)}"
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""test_compat.py — pandas-ta compatibility alias tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from quantalib._compat import ALIASES, get_compat
|
||||
|
||||
|
||||
class TestAliases:
|
||||
"""Validate ALIASES mapping and get_compat resolution."""
|
||||
|
||||
def test_aliases_is_dict(self) -> None:
|
||||
assert isinstance(ALIASES, dict)
|
||||
assert len(ALIASES) > 0
|
||||
|
||||
def test_all_aliases_are_strings(self) -> None:
|
||||
for key, val in ALIASES.items():
|
||||
assert isinstance(key, str), f"Key {key!r} is not str"
|
||||
assert isinstance(val, str), f"Value {val!r} for key {key!r} is not str"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alias,target",
|
||||
[
|
||||
("midprice", "medprice"),
|
||||
("momentum", "mom"),
|
||||
("simple_moving_average", "sma"),
|
||||
("true_range", "tr"),
|
||||
("on_balance_volume", "obv"),
|
||||
("bollinger_bands", "bbands"),
|
||||
("z_score", "zscore"),
|
||||
],
|
||||
)
|
||||
def test_known_aliases(self, alias: str, target: str) -> None:
|
||||
assert ALIASES[alias] == target
|
||||
|
||||
def test_get_compat_unknown_returns_none(self) -> None:
|
||||
result = get_compat("nonexistent_indicator_xyz")
|
||||
assert result is None
|
||||
|
||||
def test_get_compat_known_returns_callable(self) -> None:
|
||||
fn = get_compat("simple_moving_average")
|
||||
# May be None if native lib not available, but function itself resolves
|
||||
# We just test the lookup mechanism works
|
||||
if fn is not None:
|
||||
assert callable(fn)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""test_helpers.py — Unit tests for quantalib._helpers (no native lib needed)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def _has_pandas() -> bool:
|
||||
try:
|
||||
import pandas # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _arr
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestArr:
|
||||
"""Tests for _arr() input coercion and validation."""
|
||||
|
||||
def test_list_to_float64(self) -> None:
|
||||
from quantalib._helpers import _arr
|
||||
arr, idx = _arr([1.0, 2.0, 3.0])
|
||||
assert arr.dtype == np.float64
|
||||
assert idx is None
|
||||
np.testing.assert_array_equal(arr, [1.0, 2.0, 3.0])
|
||||
|
||||
def test_int_array_coerced(self) -> None:
|
||||
from quantalib._helpers import _arr
|
||||
arr, _ = _arr(np.array([1, 2, 3]))
|
||||
assert arr.dtype == np.float64
|
||||
|
||||
def test_contiguous_no_copy(self) -> None:
|
||||
from quantalib._helpers import _arr
|
||||
src = np.array([1.0, 2.0, 3.0], dtype=np.float64)
|
||||
arr, _ = _arr(src)
|
||||
# Already contiguous float64 — should share memory
|
||||
assert np.shares_memory(arr, src)
|
||||
|
||||
def test_non_contiguous_made_contiguous(self) -> None:
|
||||
from quantalib._helpers import _arr
|
||||
src = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)[::2]
|
||||
assert not src.flags["C_CONTIGUOUS"]
|
||||
arr, _ = _arr(src)
|
||||
assert arr.flags["C_CONTIGUOUS"]
|
||||
|
||||
def test_none_raises(self) -> None:
|
||||
from quantalib._helpers import _arr
|
||||
with pytest.raises(ValueError, match="must not be None"):
|
||||
_arr(None)
|
||||
|
||||
def test_empty_raises(self) -> None:
|
||||
from quantalib._helpers import _arr
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
_arr(np.array([], dtype=np.float64))
|
||||
|
||||
def test_scalar_raises(self) -> None:
|
||||
from quantalib._helpers import _arr
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
_arr(np.float64(42.0))
|
||||
|
||||
@pytest.mark.skipif(not _has_pandas(), reason="pandas not installed")
|
||||
def test_pandas_series_preserves_index(self) -> None:
|
||||
import pandas as pd
|
||||
from quantalib._helpers import _arr
|
||||
idx = pd.date_range("2020-01-01", periods=5)
|
||||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0], index=idx)
|
||||
arr, ridx = _arr(s)
|
||||
assert arr.dtype == np.float64
|
||||
assert ridx is idx
|
||||
|
||||
@pytest.mark.skipif(not _has_pandas(), reason="pandas not installed")
|
||||
def test_pandas_dataframe_uses_first_col(self) -> None:
|
||||
import pandas as pd
|
||||
from quantalib._helpers import _arr
|
||||
df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0]})
|
||||
arr, idx = _arr(df)
|
||||
np.testing.assert_array_equal(arr, [1.0, 2.0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _offset
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestOffset:
|
||||
"""Tests for _offset() roll + NaN fill."""
|
||||
|
||||
def test_zero_offset_noop(self) -> None:
|
||||
from quantalib._helpers import _offset
|
||||
arr = np.array([1.0, 2.0, 3.0])
|
||||
result = _offset(arr, 0)
|
||||
np.testing.assert_array_equal(result, arr)
|
||||
|
||||
def test_positive_offset(self) -> None:
|
||||
from quantalib._helpers import _offset
|
||||
arr = np.array([1.0, 2.0, 3.0, 4.0])
|
||||
result = _offset(arr, 2)
|
||||
assert np.isnan(result[0])
|
||||
assert np.isnan(result[1])
|
||||
assert result[2] == 1.0
|
||||
assert result[3] == 2.0
|
||||
|
||||
def test_negative_offset(self) -> None:
|
||||
from quantalib._helpers import _offset
|
||||
arr = np.array([1.0, 2.0, 3.0, 4.0])
|
||||
result = _offset(arr, -1)
|
||||
assert result[0] == 2.0
|
||||
assert result[1] == 3.0
|
||||
assert result[2] == 4.0
|
||||
assert np.isnan(result[3])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _wrap and _wrap_multi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestWrap:
|
||||
"""Tests for _wrap() and _wrap_multi()."""
|
||||
|
||||
def test_wrap_numpy_no_offset(self) -> None:
|
||||
from quantalib._helpers import _wrap
|
||||
arr = np.array([10.0, 20.0, 30.0])
|
||||
result = _wrap(arr, None, "TEST", "cat", 0)
|
||||
assert isinstance(result, np.ndarray)
|
||||
np.testing.assert_array_equal(result, arr)
|
||||
|
||||
def test_wrap_numpy_with_offset(self) -> None:
|
||||
from quantalib._helpers import _wrap
|
||||
arr = np.array([10.0, 20.0, 30.0])
|
||||
result = _wrap(arr, None, "TEST", "cat", 1)
|
||||
assert np.isnan(result[0])
|
||||
assert result[1] == 10.0
|
||||
|
||||
@pytest.mark.skipif(not _has_pandas(), reason="pandas not installed")
|
||||
def test_wrap_pandas_series_category_in_attrs(self) -> None:
|
||||
import pandas as pd
|
||||
from quantalib._helpers import _wrap
|
||||
idx = pd.RangeIndex(3)
|
||||
arr = np.array([10.0, 20.0, 30.0])
|
||||
result = _wrap(arr, idx, "SMA_10", "trends_fir", 0)
|
||||
assert isinstance(result, pd.Series)
|
||||
assert result.name == "SMA_10"
|
||||
assert result.attrs["category"] == "trends_fir"
|
||||
|
||||
def test_wrap_multi_numpy(self) -> None:
|
||||
from quantalib._helpers import _wrap_multi
|
||||
arrays = {
|
||||
"upper": np.array([1.0, 2.0]),
|
||||
"lower": np.array([0.5, 1.0]),
|
||||
}
|
||||
result = _wrap_multi(arrays, None, "cat", 0)
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.skipif(not _has_pandas(), reason="pandas not installed")
|
||||
def test_wrap_multi_pandas_attrs(self) -> None:
|
||||
import pandas as pd
|
||||
from quantalib._helpers import _wrap_multi
|
||||
idx = pd.RangeIndex(2)
|
||||
arrays = {
|
||||
"upper": np.array([1.0, 2.0]),
|
||||
"lower": np.array([0.5, 1.0]),
|
||||
}
|
||||
result = _wrap_multi(arrays, idx, "channels", 0)
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert result.attrs["category"] == "channels"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _out
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestOut:
|
||||
"""Tests for _out() allocation."""
|
||||
|
||||
def test_out_shape_and_dtype(self) -> None:
|
||||
from quantalib._helpers import _out
|
||||
arr = _out(100)
|
||||
assert arr.shape == (100,)
|
||||
assert arr.dtype == np.float64
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ptr
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestPtr:
|
||||
"""Tests for _ptr() ctypes pointer extraction."""
|
||||
|
||||
def test_ptr_not_none(self) -> None:
|
||||
from quantalib._helpers import _ptr
|
||||
arr = np.array([1.0, 2.0, 3.0], dtype=np.float64)
|
||||
p = _ptr(arr)
|
||||
assert p is not None
|
||||
@@ -0,0 +1,41 @@
|
||||
"""test_module_imports.py — Verify all category modules import without SyntaxError."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pytest
|
||||
|
||||
# Every module that must be importable (no native lib required at import time
|
||||
# because all native calls are deferred to function invocation).
|
||||
MODULES = [
|
||||
"quantalib._helpers",
|
||||
"quantalib._compat",
|
||||
"quantalib._loader",
|
||||
"quantalib._bridge",
|
||||
"quantalib.channels",
|
||||
"quantalib.core",
|
||||
"quantalib.cycles",
|
||||
"quantalib.dynamics",
|
||||
"quantalib.errors",
|
||||
"quantalib.filters",
|
||||
"quantalib.momentum",
|
||||
"quantalib.numerics",
|
||||
"quantalib.oscillators",
|
||||
"quantalib.reversals",
|
||||
"quantalib.statistics",
|
||||
"quantalib.trends_fir",
|
||||
"quantalib.trends_iir",
|
||||
"quantalib.volatility",
|
||||
"quantalib.volume",
|
||||
"quantalib.indicators",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modname", MODULES, ids=lambda m: m.split(".")[-1])
|
||||
def test_module_imports(modname: str) -> None:
|
||||
"""Each module must import without SyntaxError or ImportError.
|
||||
|
||||
This catches reserved-keyword parameter names (lambda), duplicate
|
||||
parameter names, and broken imports.
|
||||
"""
|
||||
mod = importlib.import_module(modname)
|
||||
assert mod is not None
|
||||
@@ -0,0 +1,117 @@
|
||||
"""test_signatures.py — Verify function signatures have no reserved keywords or duplicates."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import keyword
|
||||
import pytest
|
||||
|
||||
# All category modules with public indicator functions
|
||||
CATEGORY_MODULES = [
|
||||
"quantalib.channels",
|
||||
"quantalib.core",
|
||||
"quantalib.cycles",
|
||||
"quantalib.dynamics",
|
||||
"quantalib.errors",
|
||||
"quantalib.filters",
|
||||
"quantalib.momentum",
|
||||
"quantalib.numerics",
|
||||
"quantalib.oscillators",
|
||||
"quantalib.reversals",
|
||||
"quantalib.statistics",
|
||||
"quantalib.trends_fir",
|
||||
"quantalib.trends_iir",
|
||||
"quantalib.volatility",
|
||||
"quantalib.volume",
|
||||
]
|
||||
|
||||
|
||||
def _get_public_functions():
|
||||
"""Yield (module_name, func_name, func) for all public functions."""
|
||||
for modname in CATEGORY_MODULES:
|
||||
mod = importlib.import_module(modname)
|
||||
all_names = getattr(mod, "__all__", [])
|
||||
for name in all_names:
|
||||
fn = getattr(mod, name, None)
|
||||
if fn is not None and callable(fn):
|
||||
yield modname, name, fn
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def all_functions():
|
||||
return list(_get_public_functions())
|
||||
|
||||
|
||||
class TestNoReservedKeywords:
|
||||
"""No function parameter should use a Python reserved keyword."""
|
||||
|
||||
def test_no_reserved_keyword_params(self, all_functions) -> None:
|
||||
violations = []
|
||||
for modname, fname, fn in all_functions:
|
||||
sig = inspect.signature(fn)
|
||||
for pname in sig.parameters:
|
||||
if keyword.iskeyword(pname):
|
||||
violations.append(f"{modname}.{fname}(... {pname} ...)")
|
||||
assert violations == [], (
|
||||
f"Reserved keyword used as parameter name:\n"
|
||||
+ "\n".join(f" - {v}" for v in violations)
|
||||
)
|
||||
|
||||
|
||||
class TestNoDuplicateParams:
|
||||
"""No function should have duplicate parameter names (caught at parse time,
|
||||
but this validates post-fix)."""
|
||||
|
||||
def test_no_duplicate_params(self, all_functions) -> None:
|
||||
violations = []
|
||||
for modname, fname, fn in all_functions:
|
||||
sig = inspect.signature(fn)
|
||||
params = list(sig.parameters.keys())
|
||||
if len(params) != len(set(params)):
|
||||
seen = set()
|
||||
dupes = [p for p in params if p in seen or seen.add(p)] # type: ignore[func-returns-value]
|
||||
violations.append(f"{modname}.{fname}: duplicates={dupes}")
|
||||
assert violations == [], (
|
||||
f"Duplicate parameter names found:\n"
|
||||
+ "\n".join(f" - {v}" for v in violations)
|
||||
)
|
||||
|
||||
|
||||
class TestNoBuiltinShadowing:
|
||||
"""Public function names should not shadow critical Python builtins."""
|
||||
|
||||
CRITICAL_BUILTINS = {"super", "type", "id", "input", "print", "open", "list", "dict", "set", "map", "filter"}
|
||||
|
||||
def test_no_builtin_function_names(self, all_functions) -> None:
|
||||
violations = []
|
||||
for modname, fname, fn in all_functions:
|
||||
if fname in self.CRITICAL_BUILTINS:
|
||||
violations.append(f"{modname}.{fname}")
|
||||
assert violations == [], (
|
||||
f"Function names shadow Python builtins:\n"
|
||||
+ "\n".join(f" - {v}" for v in violations)
|
||||
)
|
||||
|
||||
|
||||
class TestVolumeIndicatorsHaveVolumeParam:
|
||||
"""Volume indicators that use _ptr(volume) must have volume in their signature."""
|
||||
|
||||
VOLUME_REQUIRED = [
|
||||
"adl", "adosc", "iii", "kvo", "va", "vwad", "vwap", "wad",
|
||||
"obv", "pvt", "pvr", "vf", "nvi", "pvi", "tvi", "pvd",
|
||||
"vwma", "evwma", "efi", "aobv", "mfi", "cmf", "eom", "pvo",
|
||||
]
|
||||
|
||||
def test_volume_funcs_have_volume_param(self) -> None:
|
||||
import quantalib.volume as vol
|
||||
violations = []
|
||||
for fname in self.VOLUME_REQUIRED:
|
||||
fn = getattr(vol, fname, None)
|
||||
if fn is None:
|
||||
continue
|
||||
sig = inspect.signature(fn)
|
||||
if "volume" not in sig.parameters:
|
||||
violations.append(fname)
|
||||
assert violations == [], (
|
||||
f"Volume functions missing 'volume' parameter: {violations}"
|
||||
)
|
||||
Reference in New Issue
Block a user