From 41d52ec5bee132d874545d8e34253f27ffd11fe9 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Fri, 22 May 2026 04:14:11 +0200 Subject: [PATCH] B9: raise ValueError instead of panicking on non-contiguous arrays Every Python batch() did prices.as_slice().expect("contiguous"), so a non-contiguous NumPy input (e.g. a strided view) aborted with a Rust panic instead of a catchable exception. as_slice() failures now map to a PyValueError pointing at np.ascontiguousarray; the scalar / MACD / Bollinger batch methods that returned a bare array were lifted to PyResult so the error can propagate. Adds input-validation tests (non-contiguous arrays, unequal-length candle batches, ROC/TRIX defaults). All 60 Python tests pass against the freshly built wheel. --- bindings/python/src/lib.rs | 251 ++++++++++++------ .../python/tests/test_input_validation.py | 41 +++ 2 files changed, 217 insertions(+), 75 deletions(-) create mode 100644 bindings/python/tests/test_input_validation.py diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 4c306bd6..5a3d0453 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -31,6 +31,9 @@ fn flatten(values: Vec>) -> Vec { values.into_iter().map(opt_to_nan).collect() } +/// Raised instead of panicking when a NumPy input is not C-contiguous. +const NON_CONTIGUOUS: &str = "array must be C-contiguous; pass np.ascontiguousarray(arr)"; + // ============================== SMA ============================== #[pyclass(name = "SMA", module = "wickra._wickra")] @@ -54,9 +57,11 @@ impl PySma { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray1> { - let slice = prices.as_slice().expect("contiguous numpy array"); - flatten(self.inner.batch(slice)).into_pyarray_bound(py) + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py)) } #[getter] fn period(&self) -> usize { @@ -103,9 +108,11 @@ impl PyEma { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray1> { - let slice = prices.as_slice().expect("contiguous numpy array"); - flatten(self.inner.batch(slice)).into_pyarray_bound(py) + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py)) } #[getter] fn period(&self) -> usize { @@ -156,9 +163,11 @@ impl PyWma { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray1> { - let slice = prices.as_slice().expect("contiguous numpy array"); - flatten(self.inner.batch(slice)).into_pyarray_bound(py) + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py)) } #[getter] fn period(&self) -> usize { @@ -206,9 +215,11 @@ impl PyRsi { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray1> { - let slice = prices.as_slice().expect("contiguous numpy array"); - flatten(self.inner.batch(slice)).into_pyarray_bound(py) + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(slice)).into_pyarray_bound(py)) } #[getter] fn period(&self) -> usize { @@ -261,8 +272,10 @@ impl PyMacd { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray2> { - let slice = prices.as_slice().expect("contiguous numpy array"); + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; let n = slice.len(); let mut out = vec![f64::NAN; n * 3]; for (i, p) in slice.iter().enumerate() { @@ -272,9 +285,9 @@ impl PyMacd { out[i * 3 + 2] = o.histogram; } } - numpy::ndarray::Array2::from_shape_vec((n, 3), out) + Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out) .expect("shape consistent") - .into_pyarray_bound(py) + .into_pyarray_bound(py)) } #[getter] fn periods(&self) -> (usize, usize, usize) { @@ -323,8 +336,10 @@ impl PyBb { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray2> { - let slice = prices.as_slice().expect("contiguous numpy array"); + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; let n = slice.len(); let mut out = vec![f64::NAN; n * 4]; for (i, p) in slice.iter().enumerate() { @@ -335,9 +350,9 @@ impl PyBb { out[i * 4 + 3] = o.stddev; } } - numpy::ndarray::Array2::from_shape_vec((n, 4), out) + Ok(numpy::ndarray::Array2::from_shape_vec((n, 4), out) .expect("shape consistent") - .into_pyarray_bound(py) + .into_pyarray_bound(py)) } #[getter] fn period(&self) -> usize { @@ -426,9 +441,15 @@ impl PyAtr { low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); - let c = close.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() || l.len() != c.len() { return Err(PyValueError::new_err( "high, low, close must be equal length", @@ -488,9 +509,15 @@ impl PyStoch { low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); - let c = close.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() || l.len() != c.len() { return Err(PyValueError::new_err( "high, low, close must be equal length", @@ -555,8 +582,12 @@ impl PyObv { close: PyReadonlyArray1<'py, f64>, volume: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let c = close.as_slice().expect("contiguous"); - let v = volume.as_slice().expect("contiguous"); + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if c.len() != v.len() { return Err(PyValueError::new_err( "close and volume must be equal length", @@ -610,9 +641,11 @@ impl PyDema { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray1> { - let s = prices.as_slice().expect("contiguous"); - flatten(self.inner.batch(s)).into_pyarray_bound(py) + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray_bound(py)) } #[getter] fn period(&self) -> usize { @@ -655,9 +688,11 @@ impl PyTema { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray1> { - let s = prices.as_slice().expect("contiguous"); - flatten(self.inner.batch(s)).into_pyarray_bound(py) + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray_bound(py)) } #[getter] fn period(&self) -> usize { @@ -700,9 +735,11 @@ impl PyHma { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray1> { - let s = prices.as_slice().expect("contiguous"); - flatten(self.inner.batch(s)).into_pyarray_bound(py) + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray_bound(py)) } #[getter] fn period(&self) -> usize { @@ -746,9 +783,11 @@ impl PyKama { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray1> { - let s = prices.as_slice().expect("contiguous"); - flatten(self.inner.batch(s)).into_pyarray_bound(py) + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray_bound(py)) } fn reset(&mut self) { self.inner.reset(); @@ -792,9 +831,15 @@ impl PyCci { low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); - let c = close.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() || l.len() != c.len() { return Err(PyValueError::new_err( "high, low, close must be equal length", @@ -849,9 +894,11 @@ impl PyRoc { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray1> { - let s = prices.as_slice().expect("contiguous"); - flatten(self.inner.batch(s)).into_pyarray_bound(py) + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray_bound(py)) } #[getter] fn period(&self) -> usize { @@ -899,9 +946,15 @@ impl PyWilliamsR { low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); - let c = close.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() || l.len() != c.len() { return Err(PyValueError::new_err( "high, low, close must be equal length", @@ -955,9 +1008,15 @@ impl PyAdx { low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); - let c = close.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() || l.len() != c.len() { return Err(PyValueError::new_err( "high, low, close must be equal length", @@ -1017,10 +1076,18 @@ impl PyMfi { close: PyReadonlyArray1<'py, f64>, volume: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); - let c = close.as_slice().expect("contiguous"); - let v = volume.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() { return Err(PyValueError::new_err( "high, low, close, volume must be equal length", @@ -1068,9 +1135,11 @@ impl PyTrix { &mut self, py: Python<'py>, prices: PyReadonlyArray1<'py, f64>, - ) -> Bound<'py, PyArray1> { - let s = prices.as_slice().expect("contiguous"); - flatten(self.inner.batch(s)).into_pyarray_bound(py) + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray_bound(py)) } fn reset(&mut self) { self.inner.reset(); @@ -1111,9 +1180,15 @@ impl PyPsar { low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); - let c = close.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() || l.len() != c.len() { return Err(PyValueError::new_err( "high, low, close must be equal length", @@ -1167,9 +1242,15 @@ impl PyKeltner { low: PyReadonlyArray1<'py, f64>, close: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); - let c = close.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() || l.len() != c.len() { return Err(PyValueError::new_err( "high, low, close must be equal length", @@ -1227,8 +1308,12 @@ impl PyDonchian { high: PyReadonlyArray1<'py, f64>, low: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() { return Err(PyValueError::new_err("high and low must be equal length")); } @@ -1285,10 +1370,18 @@ impl PyVwap { close: PyReadonlyArray1<'py, f64>, volume: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); - let c = close.as_slice().expect("contiguous"); - let v = volume.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() { return Err(PyValueError::new_err( "high, low, close, volume must be equal length", @@ -1339,8 +1432,12 @@ impl PyAo { high: PyReadonlyArray1<'py, f64>, low: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() { return Err(PyValueError::new_err("high and low must be equal length")); } @@ -1389,8 +1486,12 @@ impl PyAroon { high: PyReadonlyArray1<'py, f64>, low: PyReadonlyArray1<'py, f64>, ) -> PyResult>> { - let h = high.as_slice().expect("contiguous"); - let l = low.as_slice().expect("contiguous"); + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; if h.len() != l.len() { return Err(PyValueError::new_err("high and low must be equal length")); } diff --git a/bindings/python/tests/test_input_validation.py b/bindings/python/tests/test_input_validation.py new file mode 100644 index 00000000..0615ccb5 --- /dev/null +++ b/bindings/python/tests/test_input_validation.py @@ -0,0 +1,41 @@ +"""Input-validation tests: malformed NumPy inputs raise ValueError, not panics.""" + +from __future__ import annotations + +import numpy as np +import pytest + +import wickra as ta + + +def test_non_contiguous_array_raises_value_error(): + # A strided view is not C-contiguous; batch() must reject it cleanly. + base = np.linspace(1.0, 100.0, 60) + non_contiguous = base[::2] + assert not non_contiguous.flags["C_CONTIGUOUS"] + with pytest.raises(ValueError): + ta.SMA(5).batch(non_contiguous) + + +def test_ascontiguousarray_recovers(): + base = np.linspace(1.0, 100.0, 60) + fixed = np.ascontiguousarray(base[::2]) + out = ta.SMA(5).batch(fixed) + assert out.shape == fixed.shape + + +def test_unequal_length_candle_batch_raises(ohlc_series): + high, low, close = ohlc_series + short = low[:-1] + with pytest.raises(ValueError): + ta.ATR(14).batch(high, short, close) + with pytest.raises(ValueError): + ta.WilliamsR(14).batch(high, short, close) + with pytest.raises(ValueError): + ta.Aroon(14).batch(high, short) + + +def test_roc_and_trix_have_default_periods(): + # ROC/TRIX gained constructor defaults matching the TA-Lib convention. + assert ta.ROC().period == 10 + assert ta.TRIX() is not None