fix(python): critical bug fixes across Python wrapper

This commit is contained in:
Miha Kralj
2026-03-01 21:35:19 -08:00
parent 9c03a5bbbe
commit ce4416d388
40 changed files with 9592 additions and 1397 deletions
+44
View File
@@ -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)}"
)
+45
View File
@@ -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)
+194
View File
@@ -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
+41
View File
@@ -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
+117
View File
@@ -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}"
)