python: drop the NumPy runtime dependency (zero third-party deps) (#317)

Makes the Python binding truly dependency-free (Task 5b). `pip install wickra` now pulls **zero** third-party packages, and `import wickra` never imports NumPy (verified: `"numpy" not in sys.modules` after a batch call).

## What changed
- **Inputs** accept any sequence or buffer of numbers — `array.array`, `memoryview`, a NumPy `ndarray`, or a plain `list` — via `Vec` extraction (newtypes `Buf1`/`BufI64`). `PyBuffer` is unavailable under `abi3-py39`, and `unsafe_code = forbid` rules out a zero-copy slice, so inputs are copied once (negligible vs. compute).
- **Single-output `batch(...)`** returns a stdlib `array.array('d')` (native buffer protocol → `numpy.asarray` zero-copy).
- **Multi-output `batch(...)`** returns a buffer-protocol `Matrix` preserving `.shape`, integer-row and `[i, j]` access, and `.tolist()`.
- **NumPy** moves to an optional extra (`pip install wickra[numpy]`); it is never required.
- Streaming `update(...)` is unchanged; results are numerically identical.

## Implementation
- Trait `IntoPyData` + a `matrix()`/`f64_array()` helper collapse the ~400 batch call sites; `array.array` is built via `bytemuck` (Zlib/MIT/Apache — `cargo deny check licenses` ok).
- Tests migrated to `array.array`/`Matrix` (1-D `.shape`→`len()`, `.dtype`→`.typecode`; numeric comparisons normalize through a `_to_np` helper). The non-contiguous-input test now asserts acceptance instead of rejection.

## Verification (local)
- `pytest bindings/python/tests` — **1991 passed**.
- `cargo fmt --all`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace --all-features`, `cargo deny check licenses` — all green.

**BREAKING for Python**: batch return types change from NumPy arrays to `array.array`/`Matrix`. Documented in CHANGELOG; ships with the data-layer release bundle (no separate tag).
This commit is contained in:
kingchenc
2026-06-17 03:19:50 +02:00
committed by GitHub
parent 677ea37402
commit 8228be7069
12 changed files with 1910 additions and 2713 deletions
+123 -110
View File
@@ -16,10 +16,23 @@ import pytest
import wickra as ta
def _eq_nan(a: np.ndarray, b: np.ndarray, tol: float = 1e-9) -> bool:
def _to_np(x) -> np.ndarray:
"""Normalize a Wickra batch result to a float64 ndarray.
Scalar batches return a stdlib ``array.array`` (1-D, wrapped zero-copy);
multi-output batches return a ``Matrix`` exposing ``.shape``/``.tolist()``.
"""
if isinstance(x, np.ndarray):
return x.astype(np.float64)
if hasattr(x, "tolist") and hasattr(x, "shape"):
return np.asarray(x.tolist(), dtype=np.float64)
return np.asarray(x, dtype=np.float64)
def _eq_nan(a, b, tol: float = 1e-9) -> bool:
"""Compare two float arrays treating NaN and matching-sign inf positions as equal."""
a = np.asarray(a, dtype=np.float64)
b = np.asarray(b, dtype=np.float64)
a = _to_np(a)
b = _to_np(b)
if a.shape != b.shape:
return False
both_nan = np.isnan(a) & np.isnan(b)
@@ -212,8 +225,8 @@ SCALAR_MULTI = {
@pytest.mark.parametrize("cls, args", SCALAR, ids=[c.__name__ for c, _ in SCALAR])
def test_scalar_streaming_matches_batch(cls, args, sine_prices):
batch = cls(*args).batch(sine_prices)
assert batch.shape == sine_prices.shape
assert batch.dtype == np.float64
assert len(batch) == len(sine_prices)
assert batch.typecode == "d"
streamer = cls(*args)
streamed = []
@@ -250,8 +263,8 @@ def test_pair_streaming_matches_batch(cls, args, sine_prices):
asset = np.ascontiguousarray(sine_prices.astype(np.float64))
bench = np.ascontiguousarray((sine_prices * 0.7 + 0.001).astype(np.float64))
batch = cls(*args).batch(asset, bench)
assert batch.shape == asset.shape
assert batch.dtype == np.float64
assert len(batch) == len(asset)
assert batch.typecode == "d"
streamer = cls(*args)
streamed = []
@@ -270,7 +283,7 @@ def test_lead_lag_detects_lead():
a = np.array([_ll_signal(t) for t in range(n)])
# b is a delayed by 3 ⇒ a leads b ⇒ lag = +3, correlation ≈ 1.
b = np.array([_ll_signal(t - 3) for t in range(n)])
out = ta.LeadLagCrossCorrelation(12, 5).batch(a, b)
out = _to_np(ta.LeadLagCrossCorrelation(12, 5).batch(a, b))
assert out.shape == (n, 2)
assert int(out[-1, 0]) == 3
assert out[-1, 1] > 0.99
@@ -298,7 +311,7 @@ def test_cointegration_detects_mean_reverting_pair():
b = np.array([50.0 + 0.5 * t for t in range(n)])
# a tracks 2*b with a small mean-reverting wobble ⇒ cointegrated.
a = 2.0 * b + 1.0 + 0.5 * np.sin(np.arange(n) * 0.6)
out = ta.Cointegration(40, 1).batch(a, b)
out = _to_np(ta.Cointegration(40, 1).batch(a, b))
assert out.shape == (n, 3)
assert abs(out[-1, 0] - 2.0) < 0.1 # hedge ratio
assert out[-1, 2] < -2.0 # ADF statistic: strongly mean-reverting
@@ -361,7 +374,7 @@ def test_relative_strength_constant_ratio():
n = 30
a = np.full(n, 200.0)
b = np.full(n, 100.0) # ratio is a constant 2
out = ta.RelativeStrengthAB(5, 5).batch(a, b)
out = _to_np(ta.RelativeStrengthAB(5, 5).batch(a, b))
assert out.shape == (n, 3)
assert math.isclose(out[-1, 0], 2.0, abs_tol=1e-12) # ratio
assert math.isclose(out[-1, 1], 2.0, abs_tol=1e-12) # ratio MA
@@ -1009,7 +1022,7 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
make, batch_call = CANDLE_SCALAR[name]
batch = batch_call(make(), high, low, close, volume)
assert batch.shape == close.shape
assert len(batch) == len(close)
streamer = make()
streamed = []
@@ -1527,7 +1540,7 @@ def test_td_pressure_streaming_matches_batch(ohlcv):
high, low, close, volume = ohlcv
open_ = close.copy() # TD Pressure needs open; reuse close as the open column.
batch = ta.TDPressure(5).batch(open_, high, low, close, volume)
assert batch.shape == close.shape
assert len(batch) == len(close)
streamer = ta.TDPressure(5)
streamed = []
@@ -1629,7 +1642,7 @@ def test_plus_dm_reference():
high = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
low = np.array([9.0, 9.5, 10.0, 10.5, 11.0])
close = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
out = ta.PLUS_DM(3).batch(high, low, close)
out = _to_np(ta.PLUS_DM(3).batch(high, low, close))
assert math.isnan(out[0]) and math.isnan(out[2])
assert out[3] == pytest.approx(3.0)
assert out[4] == pytest.approx(3.0)
@@ -1641,7 +1654,7 @@ def test_minus_dm_reference():
high = np.array([20.0, 19.5, 19.0, 18.5, 18.0])
low = np.array([18.0, 17.0, 16.0, 15.0, 14.0])
close = np.array([19.0, 18.0, 17.0, 16.0, 15.0])
out = ta.MINUS_DM(3).batch(high, low, close)
out = _to_np(ta.MINUS_DM(3).batch(high, low, close))
assert math.isnan(out[0]) and math.isnan(out[2])
assert out[3] == pytest.approx(3.0)
assert out[4] == pytest.approx(3.0)
@@ -1652,7 +1665,7 @@ def test_plus_di_reference():
high = np.array([101.0, 103.0, 105.0, 107.0, 109.0, 111.0])
low = np.array([99.5, 101.5, 103.5, 105.5, 107.5, 109.5])
close = np.array([100.5, 102.5, 104.5, 106.5, 108.5, 110.5])
out = ta.PLUS_DI(3).batch(high, low, close)
out = _to_np(ta.PLUS_DI(3).batch(high, low, close))
assert 0.0 < out[-1] <= 100.0
@@ -1661,7 +1674,7 @@ def test_minus_di_reference():
high = np.array([111.0, 109.0, 107.0, 105.0, 103.0, 101.0])
low = np.array([109.5, 107.5, 105.5, 103.5, 101.5, 99.5])
close = np.array([110.5, 108.5, 106.5, 104.5, 102.5, 100.5])
out = ta.MINUS_DI(3).batch(high, low, close)
out = _to_np(ta.MINUS_DI(3).batch(high, low, close))
assert 0.0 < out[-1] <= 100.0
@@ -1670,7 +1683,7 @@ def test_dx_reference():
high = np.array([101.0, 103.0, 105.0, 107.0, 109.0, 111.0])
low = np.array([99.5, 101.5, 103.5, 105.5, 107.5, 109.5])
close = np.array([100.5, 102.5, 104.5, 106.5, 108.5, 110.5])
out = ta.DX(3).batch(high, low, close)
out = _to_np(ta.DX(3).batch(high, low, close))
assert 50.0 < out[-1] <= 100.0
@@ -1679,13 +1692,13 @@ def test_mid_price_reference():
high = np.array([12.0, 14.0, 16.0])
low = np.array([8.0, 9.0, 10.0])
close = np.array([10.0, 11.0, 12.0])
out = ta.MIDPRICE(3).batch(high, low, close)
out = _to_np(ta.MIDPRICE(3).batch(high, low, close))
assert out[-1] == pytest.approx(12.0)
def test_mid_point_reference():
# Window {8, 12, 10}: (12 + 8) / 2 = 10.
out = ta.MIDPOINT(3).batch(np.array([8.0, 12.0, 10.0]))
out = _to_np(ta.MIDPOINT(3).batch(np.array([8.0, 12.0, 10.0])))
assert out[-1] == pytest.approx(10.0)
@@ -1711,8 +1724,8 @@ def test_linreg_intercept_and_tsf_reference():
def test_macdfix_matches_macd():
# MACDFIX(signal) is exactly MACD(12, 26, signal).
prices = 100.0 + np.sin(np.arange(80) * 0.3) * 5.0
fix = ta.MACDFIX(9).batch(prices)
classic = ta.MACD(12, 26, 9).batch(prices)
fix = _to_np(ta.MACDFIX(9).batch(prices))
classic = _to_np(ta.MACD(12, 26, 9).batch(prices))
np.testing.assert_allclose(fix, classic, equal_nan=True)
@@ -1720,7 +1733,7 @@ def test_nvi_reference():
# closes [10, 11], volumes [200, 100]: volume contracts -> NVI absorbs +10%.
# 1000 * (1 + 0.1) = 1100.
nvi = ta.NVI()
out = nvi.batch(np.array([10.0, 11.0]), np.array([200.0, 100.0]))
out = _to_np(nvi.batch(np.array([10.0, 11.0]), np.array([200.0, 100.0])))
assert out[0] == pytest.approx(1000.0)
assert out[1] == pytest.approx(1100.0)
@@ -1728,7 +1741,7 @@ def test_nvi_reference():
def test_pvi_reference():
# closes [10, 11], volumes [100, 200]: volume expands -> PVI absorbs +10%.
pvi = ta.PVI()
out = pvi.batch(np.array([10.0, 11.0]), np.array([100.0, 200.0]))
out = _to_np(pvi.batch(np.array([10.0, 11.0]), np.array([100.0, 200.0])))
assert out[0] == pytest.approx(1000.0)
assert out[1] == pytest.approx(1100.0)
@@ -1737,7 +1750,7 @@ def test_volume_oscillator_reference():
# fast=2, slow=4 over volumes [10, 20, 30, 40, 50]:
# bar 4 -> fast=(30+40)/2=35, slow=(10+20+30+40)/4=25 -> VO = 100*(35-25)/25 = 40.
vo = ta.VolumeOscillator(2, 4)
out = vo.batch(np.array([10.0, 20.0, 30.0, 40.0, 50.0]))
out = _to_np(vo.batch(np.array([10.0, 20.0, 30.0, 40.0, 50.0])))
assert math.isnan(out[2])
assert out[3] == pytest.approx(40.0)
assert out[4] == pytest.approx(1000.0 / 35.0)
@@ -1751,7 +1764,7 @@ def test_kvo_constant_series_is_zero():
low = np.full(60, 10.0)
close = np.full(60, 10.0)
volume = np.full(60, 100.0)
out = kvo.batch(high, low, close, volume)
out = _to_np(kvo.batch(high, low, close, volume))
for v in out[~np.isnan(out)]:
assert v == pytest.approx(0.0, abs=1e-12)
@@ -1766,7 +1779,7 @@ def test_wad_reference():
high = np.array([11.0, 13.0, 11.0])
low = np.array([9.0, 8.0, 7.0])
close = np.array([10.0, 12.0, 7.0])
out = ad.batch(high, low, close)
out = _to_np(ad.batch(high, low, close))
assert math.isnan(out[0])
assert out[1] == pytest.approx(4.0)
assert out[2] == pytest.approx(-1.0)
@@ -1780,7 +1793,7 @@ def test_anchored_vwap_reference():
low = np.array([10.0, 20.0, 30.0])
close = np.array([10.0, 20.0, 30.0])
volume = np.array([1.0, 1.0, 1.0])
out = avwap.batch(high, low, close, volume)
out = _to_np(avwap.batch(high, low, close, volume))
assert out[2] == pytest.approx(20.0)
@@ -1801,7 +1814,7 @@ def test_anchored_rsi_reference():
# bar2: sum_gain=1, sum_loss=2 -> rs=0.5 -> 100 - 100/1.5 = 33.3333
# bar3: sum_gain=4, sum_loss=2 -> rs=2.0 -> 100 - 100/3 = 66.6667
rsi = ta.AnchoredRSI()
out = rsi.batch(np.array([10.0, 11.0, 9.0, 12.0]))
out = _to_np(rsi.batch(np.array([10.0, 11.0, 9.0, 12.0])))
assert np.isnan(out[0])
assert out[1] == pytest.approx(100.0)
assert out[2] == pytest.approx(33.333333, abs=1e-4)
@@ -1843,7 +1856,7 @@ def test_tsv_reference():
tsv = ta.TSV(3)
close = np.array([10.0, 11.0, 13.0, 12.0, 14.0, 15.0])
volume = np.array([50.0, 100.0, 200.0, 150.0, 50.0, 200.0])
out = tsv.batch(close, volume)
out = _to_np(tsv.batch(close, volume))
assert math.isnan(out[0]) and math.isnan(out[1]) and math.isnan(out[2])
assert out[3] == pytest.approx(350.0)
assert out[4] == pytest.approx(350.0)
@@ -1857,7 +1870,7 @@ def test_vzo_strictly_rising_saturates_to_plus_100():
vzo = ta.VZO(5)
close = np.array([10.0 + i for i in range(60)])
volume = np.full(60, 100.0)
out = vzo.batch(close, volume)
out = _to_np(vzo.batch(close, volume))
last = out[~np.isnan(out)][-1]
assert last == pytest.approx(100.0)
@@ -1868,7 +1881,7 @@ def test_market_facilitation_index_reference():
high = np.array([12.0])
low = np.array([8.0])
volume = np.array([200.0])
out = mfi_bw.batch(high, low, volume)
out = _to_np(mfi_bw.batch(high, low, volume))
assert out[0] == pytest.approx(0.02)
@@ -1879,7 +1892,7 @@ def test_demand_index_constant_series_is_zero():
low = np.full(60, 10.0)
close = np.full(60, 10.0)
volume = np.full(60, 100.0)
out = di.batch(high, low, close, volume)
out = _to_np(di.batch(high, low, close, volume))
for v in out[~np.isnan(out)]:
assert v == pytest.approx(0.0, abs=1e-12)
@@ -1891,13 +1904,13 @@ def test_chaikin_money_flow_reference():
def test_linear_regression_reference():
out = ta.LinearRegression(3).batch(np.array([1.0, 2.0, 9.0]))
out = _to_np(ta.LinearRegression(3).batch(np.array([1.0, 2.0, 9.0])))
assert math.isnan(out[0]) and math.isnan(out[1])
assert out[2] == pytest.approx(8.0)
def test_linreg_slope_reference():
out = ta.LinRegSlope(3).batch(np.array([1.0, 2.0, 9.0]))
out = _to_np(ta.LinRegSlope(3).batch(np.array([1.0, 2.0, 9.0])))
assert math.isnan(out[0]) and math.isnan(out[1])
assert out[2] == pytest.approx(4.0)
@@ -1916,7 +1929,7 @@ def test_true_range_reference():
def test_linreg_angle_reference():
# A series rising by 1 per step has slope 1, and atan(1) = 45 degrees.
out = ta.LinRegAngle(5).batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]))
out = _to_np(ta.LinRegAngle(5).batch(np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])))
assert out[4] == pytest.approx(45.0)
@@ -1924,16 +1937,16 @@ def test_wave_trend_flat_market_yields_zero():
# On a perfectly flat market the flat-tolerance guard keeps both lines
# at exactly zero (otherwise the ratio ci = (ap - esa) / (0.015 * d)
# would explode on the first esa ULP).
out = ta.WaveTrend.classic().batch(
out = _to_np(ta.WaveTrend.classic().batch(
np.full(80, 10.0), np.full(80, 10.0), np.full(80, 10.0)
)
))
last = out[~np.isnan(out[:, 0])][-1]
assert last[0] == 0.0
assert last[1] == 0.0
def test_kst_classic_constants_yield_zero():
out = ta.KST.classic().batch(np.full(120, 100.0))
out = _to_np(ta.KST.classic().batch(np.full(120, 100.0)))
last_row = out[~np.isnan(out[:, 0])][-1]
assert last_row[0] == pytest.approx(0.0)
assert last_row[1] == pytest.approx(0.0)
@@ -1943,13 +1956,13 @@ def test_tii_pure_uptrend_saturates_at_100():
# On a strictly increasing series every close sits above the lagging
# SMA, so every deviation is positive and TII reaches 100.
prices = np.arange(80, dtype=np.float64) + 100.0
out = ta.TII(10, 5).batch(prices)
out = _to_np(ta.TII(10, 5).batch(prices))
last = out[~np.isnan(out)][-1]
assert last == pytest.approx(100.0)
def test_tii_flat_market_yields_50():
out = ta.TII(5, 4).batch(np.full(30, 10.0))
out = _to_np(ta.TII(5, 4).batch(np.full(30, 10.0)))
last = out[~np.isnan(out)][-1]
assert last == 50.0
@@ -1961,7 +1974,7 @@ def test_rwi_reference_uptrend_dominates_low_line():
high = base + 1.0
low = base - 0.5
close = base + 0.5
out = ta.RWI(14).batch(high, low, close)
out = _to_np(ta.RWI(14).batch(high, low, close))
last_row = out[~np.isnan(out[:, 0])][-1]
assert last_row[0] > last_row[1], f"RWI_High {last_row[0]} must dominate RWI_Low {last_row[1]}"
assert last_row[0] > 1.0
@@ -1975,14 +1988,14 @@ def test_adxr_reference_on_pure_uptrend():
high = base + 1.0
low = base - 0.5
close = base + 0.5
out = ta.ADXR(5).batch(high, low, close)
out = _to_np(ta.ADXR(5).batch(high, low, close))
last = out[~np.isnan(out)][-1]
assert last == pytest.approx(100.0)
def test_z_score_reference():
# Window [1, 3]: mean 2, population stddev 1; latest 3 -> z = 1.
out = ta.ZScore(2).batch(np.array([1.0, 3.0]))
out = _to_np(ta.ZScore(2).batch(np.array([1.0, 3.0])))
assert math.isnan(out[0])
assert out[1] == pytest.approx(1.0)
@@ -1992,44 +2005,44 @@ def test_z_score_reference():
def test_variance_reference():
# Variance(3) of [2, 4, 6]: mean 4, variance (4 + 0 + 4) / 3 = 8/3.
out = ta.Variance(3).batch(np.array([2.0, 4.0, 6.0]))
out = _to_np(ta.Variance(3).batch(np.array([2.0, 4.0, 6.0])))
assert math.isnan(out[1])
assert out[2] == pytest.approx(8.0 / 3.0)
def test_coefficient_of_variation_reference():
# CV(3) of [2, 4, 6]: sd / mean = sqrt(8/3) / 4.
out = ta.CoefficientOfVariation(3).batch(np.array([2.0, 4.0, 6.0]))
out = _to_np(ta.CoefficientOfVariation(3).batch(np.array([2.0, 4.0, 6.0])))
assert out[2] == pytest.approx(math.sqrt(8.0 / 3.0) / 4.0)
def test_skewness_symmetric_window_is_zero():
# Symmetric window has zero Pearson skewness.
out = ta.Skewness(5).batch(np.array([-2.0, -1.0, 0.0, 1.0, 2.0]))
out = _to_np(ta.Skewness(5).batch(np.array([-2.0, -1.0, 0.0, 1.0, 2.0])))
assert out[4] == pytest.approx(0.0, abs=1e-9)
def test_kurtosis_two_point_distribution_minimum():
# Alternating {-1, 1} has m4/m2² = 1, so excess kurtosis = -2.
out = ta.Kurtosis(4).batch(np.array([-1.0, 1.0, -1.0, 1.0]))
out = _to_np(ta.Kurtosis(4).batch(np.array([-1.0, 1.0, -1.0, 1.0])))
assert out[3] == pytest.approx(-2.0, abs=1e-9)
def test_standard_error_perfect_line_is_zero():
# Residuals are zero on a perfectly linear series.
out = ta.StandardError(5).batch(np.linspace(1.0, 20.0, num=20, dtype=np.float64))
out = _to_np(ta.StandardError(5).batch(np.linspace(1.0, 20.0, num=20, dtype=np.float64)))
finite = out[~np.isnan(out)]
assert np.allclose(finite, 0.0, atol=1e-9)
def test_detrended_std_dev_perfect_line_is_zero():
out = ta.DetrendedStdDev(5).batch(np.linspace(1.0, 20.0, num=20, dtype=np.float64))
out = _to_np(ta.DetrendedStdDev(5).batch(np.linspace(1.0, 20.0, num=20, dtype=np.float64)))
finite = out[~np.isnan(out)]
assert np.allclose(finite, 0.0, atol=1e-9)
def test_r_squared_perfect_line_is_one():
out = ta.RSquared(5).batch(np.linspace(1.0, 20.0, num=20, dtype=np.float64))
out = _to_np(ta.RSquared(5).batch(np.linspace(1.0, 20.0, num=20, dtype=np.float64)))
finite = out[~np.isnan(out)]
assert np.allclose(finite, 1.0, atol=1e-9)
@@ -2037,35 +2050,35 @@ def test_r_squared_perfect_line_is_one():
def test_median_absolute_deviation_ignores_single_outlier():
# 9 equal values + 1 huge outlier: MAD is still 0 (more than half agree).
prices = np.array([5.0] * 9 + [1000.0], dtype=np.float64)
out = ta.MedianAbsoluteDeviation(10).batch(prices)
out = _to_np(ta.MedianAbsoluteDeviation(10).batch(prices))
assert out[9] == pytest.approx(0.0, abs=1e-12)
def test_autocorrelation_alternating_series_negative():
# ±1 alternating: lag-1 ACF must be strongly negative.
prices = np.array([-1.0 if i % 2 == 0 else 1.0 for i in range(20)], dtype=np.float64)
out = ta.Autocorrelation(10, 1).batch(prices)
out = _to_np(ta.Autocorrelation(10, 1).batch(prices))
assert out[-1] < -0.5
def test_hurst_exponent_trending_above_half():
# A clean monotone ramp is the textbook persistent series.
prices = np.arange(200, dtype=np.float64)
out = ta.HurstExponent(100, 4).batch(prices)
out = _to_np(ta.HurstExponent(100, 4).batch(prices))
assert out[-1] > 0.5
def test_pearson_correlation_perfect_positive_is_one():
x = np.arange(10, dtype=np.float64)
y = 2.0 * x + 3.0
out = ta.PearsonCorrelation(5).batch(x, y)
out = _to_np(ta.PearsonCorrelation(5).batch(x, y))
assert out[-1] == pytest.approx(1.0, abs=1e-9)
def test_beta_perfect_two_to_one():
benchmark = np.arange(10, dtype=np.float64)
asset = 2.0 * benchmark
out = ta.Beta(5).batch(asset, benchmark)
out = _to_np(ta.Beta(5).batch(asset, benchmark))
assert out[-1] == pytest.approx(2.0, abs=1e-9)
@@ -2073,7 +2086,7 @@ def test_spearman_correlation_monotone_nonlinear_is_one():
# y = x^3 is monotone non-linear; Spearman = 1 (Pearson would not be).
x = np.arange(1.0, 11.0, dtype=np.float64)
y = x**3
out = ta.SpearmanCorrelation(5).batch(x, y)
out = _to_np(ta.SpearmanCorrelation(5).batch(x, y))
assert out[-1] == pytest.approx(1.0, abs=1e-9)
@@ -2109,32 +2122,32 @@ def test_mama_batch_shape_and_streaming_equivalence(sine_prices):
def test_inverse_fisher_transform_zero_input_yields_zero():
out = ta.InverseFisherTransform(1.0).batch(np.array([0.0, 0.0, 0.0]))
out = _to_np(ta.InverseFisherTransform(1.0).batch(np.array([0.0, 0.0, 0.0])))
np.testing.assert_allclose(out, [0.0, 0.0, 0.0], atol=1e-12)
def test_fisher_transform_flat_series_is_zero():
# Zero range -> the normaliser yields 0, and tanh(0) chain stays at 0.
out = ta.FisherTransform(5).batch(np.full(20, 42.0))
out = _to_np(ta.FisherTransform(5).batch(np.full(20, 42.0)))
ready = out[~np.isnan(out)]
assert np.all(np.abs(ready) < 1e-6)
def test_decycler_flat_series_passes_through():
# High-pass of a flat input is zero, so the decycler equals the input.
out = ta.Decycler(20).batch(np.full(30, 100.0))
out = _to_np(ta.Decycler(20).batch(np.full(30, 100.0)))
ready = out[~np.isnan(out)]
np.testing.assert_allclose(ready, 100.0, atol=1e-9)
def test_center_of_gravity_flat_series_is_zero():
out = ta.CenterOfGravity(5).batch(np.full(20, 7.0))
out = _to_np(ta.CenterOfGravity(5).batch(np.full(20, 7.0)))
ready = out[~np.isnan(out)]
np.testing.assert_allclose(ready, 0.0, atol=1e-12)
def test_super_smoother_first_two_outputs_equal_inputs():
out = ta.SuperSmoother(10).batch(np.array([100.0, 101.0, 102.0]))
out = _to_np(ta.SuperSmoother(10).batch(np.array([100.0, 101.0, 102.0])))
# The 2-pole filter is seeded with raw values for the first two bars.
assert out[0] == pytest.approx(100.0)
assert out[1] == pytest.approx(101.0)
@@ -2145,7 +2158,7 @@ def test_td_setup_pure_uptrend_reaches_minus_9():
h = np.arange(2.0, 22.0)
l = h - 1.0
c = h - 0.5
out = ta.TDSetup(4, 9).batch(h, l, c)
out = _to_np(ta.TDSetup(4, 9).batch(h, l, c))
# Setup completes at index 12 (warmup is 5 -> first emit at index 4 with
# value -1, increments to -9 at index 12).
assert out[12] == pytest.approx(-9.0)
@@ -2156,7 +2169,7 @@ def test_td_demarker_uptrend_pegs_at_one():
# -> indicator == 1 after warmup.
h = np.arange(11.0, 31.0)
l = h - 2.0
out = ta.TDDeMarker(5).batch(h, l)
out = _to_np(ta.TDDeMarker(5).batch(h, l))
assert out[-1] == pytest.approx(1.0)
@@ -2164,7 +2177,7 @@ def test_td_demarker_flat_market_emits_05():
# All highs and lows equal -> denominator is zero -> neutral fallback 0.5.
h = np.full(20, 11.0)
l = np.full(20, 9.0)
out = ta.TDDeMarker(5).batch(h, l)
out = _to_np(ta.TDDeMarker(5).batch(h, l))
assert out[-1] == pytest.approx(0.5)
@@ -2177,7 +2190,7 @@ def test_td_pressure_pure_bullish_yields_100():
low = np.full(n, 9.0)
close = np.full(n, 11.0)
volume = np.full(n, 100.0)
out = ta.TDPressure(5).batch(open_, high, low, close, volume)
out = _to_np(ta.TDPressure(5).batch(open_, high, low, close, volume))
assert out[-1] == pytest.approx(100.0)
@@ -2189,7 +2202,7 @@ def test_td_combo_uptrend_saturates_at_minus_13():
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDCombo().batch(high, low, close)
out = _to_np(ta.TDCombo().batch(high, low, close))
assert out[-1] == pytest.approx(-13.0)
@@ -2198,7 +2211,7 @@ def test_td_countdown_uptrend_saturates_at_minus_13():
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDCountdown().batch(high, low, close)
out = _to_np(ta.TDCountdown().batch(high, low, close))
assert out[-1] == pytest.approx(-13.0)
@@ -2209,7 +2222,7 @@ def test_td_lines_uptrend_sets_support_at_first_run_low():
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDLines().batch(high, low, close)
out = _to_np(ta.TDLines().batch(high, low, close))
# support is column 1; resistance is NaN at -1.
assert math.isnan(out[-1, 0])
# low at idx 4 = 5 + 0.5 - 1.0 = 4.5.
@@ -2220,9 +2233,9 @@ def test_td_range_projection_bullish_bar_reference():
# open=10, high=12, low=9, close=11 (close > open) ->
# pivot_sum = 2*12 + 9 + 11 = 44; half = 22.
# projHigh = 22 - 9 = 13; projLow = 22 - 12 = 10.
out = ta.TDRangeProjection().batch(
out = _to_np(ta.TDRangeProjection().batch(
np.array([10.0]), np.array([12.0]), np.array([9.0]), np.array([11.0])
)
))
assert out[0, 0] == pytest.approx(13.0)
assert out[0, 1] == pytest.approx(10.0)
@@ -2252,7 +2265,7 @@ def test_td_risk_level_uptrend_sets_sell_risk():
high = np.arange(1.0, 1.0 + n) + 0.5
low = high - 1.0
close = high - 0.5
out = ta.TDRiskLevel().batch(high, low, close)
out = _to_np(ta.TDRiskLevel().batch(high, low, close))
# buy_risk is column 0; sell_risk is column 1.
assert math.isnan(out[12, 0])
assert out[12, 1] == pytest.approx(15.0)
@@ -2346,7 +2359,7 @@ def test_zigzag_confirms_after_threshold_reversal():
def test_ma_envelope_reference():
# SMA([10, 20, 30]) = 20; with percent = 0.10: upper = 22, lower = 18.
out = ta.MaEnvelope(3, 0.10).batch(np.array([10.0, 20.0, 30.0]))
out = _to_np(ta.MaEnvelope(3, 0.10).batch(np.array([10.0, 20.0, 30.0])))
assert math.isnan(out[0, 0]) and math.isnan(out[1, 0])
assert out[2, 0] == pytest.approx(22.0) # upper
assert out[2, 1] == pytest.approx(20.0) # middle
@@ -2363,9 +2376,9 @@ def test_acceleration_bands_reference():
def test_atr_bands_reference():
# Five identical bars (h=11, l=9, c=10) → ATR=2, close=10, mult=3:
# upper=16, middle=10, lower=4.
out = ta.AtrBands(5, 3.0).batch(
out = _to_np(ta.AtrBands(5, 3.0).batch(
np.array([11.0] * 5), np.array([9.0] * 5), np.array([10.0] * 5)
)
))
assert math.isnan(out[3, 0])
assert out[4, 0] == pytest.approx(16.0)
assert out[4, 1] == pytest.approx(10.0)
@@ -2374,9 +2387,9 @@ def test_atr_bands_reference():
def test_hurst_channel_reference():
# Five identical (h=12, l=8, c=10): SMA(close)=10, range=4, mult=0.5.
out = ta.HurstChannel(5, 0.5).batch(
out = _to_np(ta.HurstChannel(5, 0.5).batch(
np.array([12.0] * 5), np.array([8.0] * 5), np.array([10.0] * 5)
)
))
assert out[4, 0] == pytest.approx(12.0)
assert out[4, 1] == pytest.approx(10.0)
assert out[4, 2] == pytest.approx(8.0)
@@ -2385,7 +2398,7 @@ def test_hurst_channel_reference():
def test_linreg_channel_reference():
# period 3 over [1, 2, 9]: line y=4x, endpoint=8, residuals=[1, -2, 1],
# population sigma=sqrt(2); mult=2 → upper=8+2√2, lower=8-2√2.
out = ta.LinRegChannel(3, 2.0).batch(np.array([1.0, 2.0, 9.0]))
out = _to_np(ta.LinRegChannel(3, 2.0).batch(np.array([1.0, 2.0, 9.0])))
s = math.sqrt(2.0)
assert out[2, 0] == pytest.approx(8.0 + 2.0 * s)
assert out[2, 1] == pytest.approx(8.0)
@@ -2395,7 +2408,7 @@ def test_linreg_channel_reference():
def test_standard_error_bands_reference():
# Same [1, 2, 9] with n=3: SSE=6, n-2=1, stderr=sqrt(6); mult=2 →
# upper=8+2√6, lower=8-2√6.
out = ta.StandardErrorBands(3, 2.0).batch(np.array([1.0, 2.0, 9.0]))
out = _to_np(ta.StandardErrorBands(3, 2.0).batch(np.array([1.0, 2.0, 9.0])))
s = math.sqrt(6.0)
assert out[2, 0] == pytest.approx(8.0 + 2.0 * s)
assert out[2, 1] == pytest.approx(8.0)
@@ -2404,9 +2417,9 @@ def test_standard_error_bands_reference():
def test_double_bollinger_orders_bands():
# On a non-trivial dispersion, outer >= inner >= middle >= -inner >= -outer.
out = ta.DoubleBollinger(5, 1.0, 2.0).batch(
out = _to_np(ta.DoubleBollinger(5, 1.0, 2.0).batch(
np.array([1.0, 5.0, 2.0, 4.0, 3.0, 6.0])
)
))
v = out[5]
assert v[0] >= v[1] >= v[2] >= v[3] >= v[4]
@@ -2427,7 +2440,7 @@ def test_ttm_squeeze_flat_market():
# Zero volatility: BB and KC both collapse to a point → squeeze=1.0,
# momentum=0.0.
candles_h = np.array([10.0] * 25)
out = ta.TtmSqueeze(20, 2.0, 1.5).batch(candles_h, candles_h, candles_h)
out = _to_np(ta.TtmSqueeze(20, 2.0, 1.5).batch(candles_h, candles_h, candles_h))
assert out[24, 0] == pytest.approx(1.0)
assert out[24, 1] == pytest.approx(0.0)
@@ -2436,7 +2449,7 @@ def test_fractal_chaos_bands_detects_peak_and_trough():
# Sequence that creates one fractal high (i=2) and one low (i=3).
h = np.array([1.0, 2.0, 5.0, 3.0, 2.0, 1.0, 2.0])
l = np.array([1.0, 2.0, 3.0, 0.5, 2.0, 1.0, 2.0])
out = ta.FractalChaosBands(2).batch(h, l)
out = _to_np(ta.FractalChaosBands(2).batch(h, l))
# First bar with both bands set is index 5.
assert math.isnan(out[4, 0])
assert out[5, 0] == pytest.approx(5.0)
@@ -2989,7 +3002,7 @@ def test_spread_ar1_coefficient_reference():
# Spread a - b grows by exactly 1 each bar (unit root) => rho == 1.
a = np.array([2.0 * i for i in range(40)])
b = np.array([float(i) for i in range(40)])
out = ta.SpreadAr1Coefficient(20).batch(a, b)
out = _to_np(ta.SpreadAr1Coefficient(20).batch(a, b))
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
@@ -2998,7 +3011,7 @@ def test_elder_ray_reference():
high = np.array([11.0, 13.0, 16.0])
low = np.array([9.0, 11.0, 13.0])
close = np.array([10.0, 12.0, 14.0])
out = er.batch(high, low, close)
out = _to_np(er.batch(high, low, close))
# EMA(3) seeds at the third bar with mean close 12; bar high 16 -> bull 4,
# low 13 -> bear 1.
assert out[2][0] == pytest.approx(4.0)
@@ -3011,7 +3024,7 @@ def test_imi_reference():
high = np.array([12.0, 12.0, 13.0])
low = np.array([9.0, 9.0, 9.0])
close = np.array([11.0, 10.0, 12.0])
out = imi.batch(open_, high, low, close)
out = _to_np(imi.batch(open_, high, low, close))
# bodies +1, -1, +2 -> gain 3, loss 1 -> 100 * 3 / 4 = 75.
assert math.isnan(out[0])
assert math.isnan(out[1])
@@ -3022,7 +3035,7 @@ def test_qstick_reference():
q = ta.Qstick(3)
open_ = np.array([10.0, 10.0, 10.0])
close = np.array([11.0, 11.0, 11.0])
out = q.batch(open_, close)
out = _to_np(q.batch(open_, close))
# Each body is close - open = 1; SMA(3) of [1, 1, 1] = 1.
assert math.isnan(out[0])
assert math.isnan(out[1])
@@ -3034,7 +3047,7 @@ def test_ttm_trend_reference():
high = np.array([13.0, 13.0, 13.0])
low = np.array([9.0, 9.0, 9.0])
close = np.array([12.0, 12.0, 12.0])
out = t.batch(high, low, close)
out = _to_np(t.batch(high, low, close))
# Median (13 + 9) / 2 = 11; close 12 is above the SMA(3) reference -> +1.
assert math.isnan(out[0])
assert out[2] == pytest.approx(1.0)
@@ -3043,7 +3056,7 @@ def test_ttm_trend_reference():
def test_trend_strength_index_reference():
tsi = ta.TREND_STRENGTH_INDEX(10)
closes = np.arange(10, dtype=float)
out = tsi.batch(closes)
out = _to_np(tsi.batch(closes))
# A clean ramp is a perfect uptrend -> signed r^2 = +1.
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
@@ -3051,7 +3064,7 @@ def test_trend_strength_index_reference():
def test_polarized_fractal_efficiency_reference():
pfe = ta.POLARIZED_FRACTAL_EFFICIENCY(5, 3)
closes = np.arange(20, dtype=float)
out = pfe.batch(closes)
out = _to_np(pfe.batch(closes))
# On a straight ramp the path equals the diagonal -> efficiency 1 -> +100.
assert math.isclose(out[-1], 100.0, abs_tol=1e-9)
@@ -3059,7 +3072,7 @@ def test_polarized_fractal_efficiency_reference():
def test_wave_pm_reference():
wpm = ta.WAVE_PM(10, 3)
closes = np.arange(60, dtype=float) * 5.0
out = wpm.batch(closes)
out = _to_np(wpm.batch(closes))
# Constant-slope ramp: momentum equals its energy -> 100 * (1 - e^-0.5).
baseline = 100.0 * (1.0 - math.exp(-0.5))
assert math.isclose(out[-1], baseline, abs_tol=1e-9)
@@ -3071,7 +3084,7 @@ def test_gator_oscillator_reference():
high = np.full(n, 11.0)
low = np.full(n, 9.0)
close = np.full(n, 10.0)
out = g.batch(high, low, close)
out = _to_np(g.batch(high, low, close))
# Constant median collapses all three Alligator lines -> both bars zero.
assert out[-1][0] == pytest.approx(0.0)
assert out[-1][1] == pytest.approx(0.0)
@@ -3081,7 +3094,7 @@ def test_kase_permission_stochastic_reference():
k = ta.KasePermissionStochastic(4, 2)
n = 20
flat = np.full(n, 10.0)
out = k.batch(flat, flat, flat)
out = _to_np(k.batch(flat, flat, flat))
# HH == LL -> raw %K defaults to the neutral 50 -> both lines at 50.
assert out[-1][0] == pytest.approx(50.0)
assert out[-1][1] == pytest.approx(50.0)
@@ -3416,7 +3429,7 @@ def test_new_indicators_expose_lifecycle():
def test_ichimoku_batch_shape_and_warmup(ohlcv):
high, low, close, _ = ohlcv
ichi = ta.Ichimoku()
out = ichi.batch(high, low, close)
out = _to_np(ichi.batch(high, low, close))
assert out.shape == (close.size, 5)
# Warmup is 77 for the classic (9, 26, 52, 26) configuration.
assert ichi.warmup_period() == 77
@@ -3454,7 +3467,7 @@ def test_ichimoku_chikou_is_close_displacement_back():
close = np.full(n, 100.0)
high = close + 1.0
low = close - 1.0
out = ta.Ichimoku().batch(high, low, close)
out = _to_np(ta.Ichimoku().batch(high, low, close))
# Displacement = 26, so chikou is defined from bar 25 onwards.
for i in range(25, n):
assert out[i, 4] == pytest.approx(100.0)
@@ -3706,7 +3719,7 @@ def test_orderbook_indicators_streaming_equals_batch():
streamed = np.array(
[streamer.update(*snap) for snap in snaps], dtype=np.float64
)
assert batch.shape == (len(snaps),)
assert len(batch) == len(snaps)
assert _eq_nan(batch, streamed)
@@ -3731,7 +3744,7 @@ def test_tradeflow_indicators_streaming_equals_batch():
[streamer.update(price[i], size[i], is_buy[i]) for i in range(n)],
dtype=np.float64,
)
assert batch.shape == (n,)
assert len(batch) == n
assert _eq_nan(batch, streamed)
@@ -3779,7 +3792,7 @@ def test_price_impact_indicators_streaming_equals_batch():
[streamer.update(price[i], size[i], is_buy[i], mid[i]) for i in range(n)],
dtype=np.float64,
)
assert batch.shape == (n,)
assert len(batch) == n
assert _eq_nan(batch, streamed)
@@ -3793,7 +3806,7 @@ def test_footprint_streaming_equals_batch():
assert len(batch) == n
for i in range(n):
streamed = streamer.update(price[i], size[i], is_buy[i])
assert np.array_equal(streamed, batch[i])
assert np.array_equal(_to_np(streamed), _to_np(batch[i]))
def test_funding_indicators_streaming_equals_batch():
@@ -3809,7 +3822,7 @@ def test_funding_indicators_streaming_equals_batch():
streamed = np.array(
[streamer.update(rate[i]) for i in range(n)], dtype=np.float64
)
assert batch.shape == (n,)
assert len(batch) == n
assert _eq_nan(batch, streamed)
@@ -3833,7 +3846,7 @@ def test_advance_decline_streaming_equals_batch():
],
dtype=np.float64,
)
assert batch.shape == (3,)
assert len(batch) == 3
assert _eq_nan(batch, streamed)
# Cumulative line: +2 -> 0 -> 0.
assert list(batch) == [2.0, 0.0, 0.0]
@@ -3856,7 +3869,7 @@ def _breadth_streaming_equals_batch(indicator, change, volume, new_high, new_low
],
dtype=np.float64,
)
assert batch.shape == (len(change),)
assert len(batch) == len(change)
assert _eq_nan(batch, streamed)
return batch
@@ -4040,7 +4053,7 @@ def test_funding_basis_streaming_equals_batch():
streamed = np.array(
[streamer.update(mark[i], index[i]) for i in range(n)], dtype=np.float64
)
assert batch.shape == (n,)
assert len(batch) == n
assert _eq_nan(batch, streamed)
@@ -4050,7 +4063,7 @@ def test_open_interest_delta_streaming_equals_batch():
batch = ta.OpenInterestDelta().batch(oi)
streamer = ta.OpenInterestDelta()
streamed = np.array([streamer.update(oi[i]) for i in range(n)], dtype=np.float64)
assert batch.shape == (n,)
assert len(batch) == n
assert _eq_nan(batch, streamed)
@@ -4067,7 +4080,7 @@ def test_oi_flow_indicators_streaming_equals_batch():
streamed = np.array(
[streamer.update(oi[i], mark[i]) for i in range(n)], dtype=np.float64
)
assert batch.shape == (n,)
assert len(batch) == n
assert _eq_nan(batch, streamed)
# OIWeighted; update(mark_price, open_interest).
@@ -4121,7 +4134,7 @@ def test_basis_indicators_streaming_equals_batch():
streamed = np.array(
[streamer.update(futures[i], index[i]) for i in range(n)], dtype=np.float64
)
assert batch.shape == (n,)
assert len(batch) == n
assert _eq_nan(batch, streamed)
# CalendarSpread; update(futures_price, mark_price).
@@ -4166,7 +4179,7 @@ def test_b16_derivatives_streaming_equals_batch():
streamed = np.array(
[streamer.update(oi[i], long_sz[i], short_sz[i]) for i in range(n)], dtype=np.float64
)
assert batch.shape == (n,)
assert len(batch) == n
assert _eq_nan(batch, streamed)
# OiToVolumeRatio; update(open_interest, taker_buy_volume, taker_sell_volume).
@@ -4210,7 +4223,7 @@ def test_renko_bars_reference():
def test_renko_bars_batch_shape():
r = ta.RenkoBars(1.0)
out = r.batch(np.array([10.0, 11.0, 12.0, 13.0]))
out = _to_np(r.batch(np.array([10.0, 11.0, 12.0, 13.0])))
assert out.shape == (3, 3)
np.testing.assert_allclose(out[:, 2], [1.0, 1.0, 1.0])
@@ -4247,7 +4260,7 @@ def test_range_bars_reference():
def test_range_bars_batch_shape():
rb = ta.RangeBars(1.0)
out = rb.batch(np.array([10.0, 11.0, 12.0, 13.0]))
out = _to_np(rb.batch(np.array([10.0, 11.0, 12.0, 13.0])))
assert out.shape == (3, 3)
np.testing.assert_allclose(out[:, 2], [1.0, 1.0, 1.0])
@@ -4264,7 +4277,7 @@ def test_tick_bars_batch_shape():
tb = ta.TickBars(2)
col = np.array([10.0, 10.0, 10.0, 10.0])
vol = np.array([1.0, 1.0, 1.0, 1.0])
out = tb.batch(col, col, col, col, vol)
out = _to_np(tb.batch(col, col, col, col, vol))
assert out.shape == (2, 5)
@@ -4315,5 +4328,5 @@ def test_three_line_break_bars_reference():
def test_three_line_break_bars_batch_shape():
tlb = ta.ThreeLineBreakBars(3)
out = tlb.batch(np.array([10.0, 11.0, 12.0, 13.0]))
out = _to_np(tlb.batch(np.array([10.0, 11.0, 12.0, 13.0])))
assert out.shape[1] == 3