mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-01 11:17:46 +00:00
docs: add license rationale, Python/PineScript guides, API updates
- Add docs/license.md with Apache 2.0 rationale and patent protection analysis - Add docs/python.md and docs/pinescript.md platform guides - Expand README license section with disclosure and link to rationale - Update docs/api.md and docs/architecture.md - Update Python bindings: helpers, all indicator modules, pyproject.toml - Add Python tests for Arrow and Polars integration - Update TValue core type and documentation - Add fix_length_to_period tooling script
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""Round-trip tests for PyArrow Array / ChunkedArray input → output.
|
||||
|
||||
Requires: ``pip install quantalib[pyarrow]``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pa = pytest.importorskip("pyarrow", minversion="14.0")
|
||||
|
||||
import quantalib as qtl # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture()
|
||||
def close_array() -> pa.Array:
|
||||
"""100-bar random close prices as a PyArrow float64 Array."""
|
||||
rng = np.random.default_rng(42)
|
||||
return pa.array(rng.random(100) * 100 + 50, type=pa.float64())
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def close_chunked() -> pa.ChunkedArray:
|
||||
"""100-bar random close prices as a PyArrow ChunkedArray (2 chunks)."""
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.random(100) * 100 + 50
|
||||
chunk1 = pa.array(data[:50], type=pa.float64())
|
||||
chunk2 = pa.array(data[50:], type=pa.float64())
|
||||
return pa.chunked_array([chunk1, chunk2])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-output: pa.Array in → pa.Array out
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSingleOutput:
|
||||
def test_sma_returns_arrow_array(self, close_array: pa.Array) -> None:
|
||||
result = qtl.sma(close_array, length=14)
|
||||
assert isinstance(result, pa.Array)
|
||||
assert len(result) == len(close_array)
|
||||
assert result.type == pa.float64()
|
||||
|
||||
def test_ema_returns_arrow_array(self, close_array: pa.Array) -> None:
|
||||
result = qtl.ema(close_array, length=14)
|
||||
assert isinstance(result, pa.Array)
|
||||
assert len(result) == len(close_array)
|
||||
|
||||
def test_rsi_returns_arrow_array(self, close_array: pa.Array) -> None:
|
||||
result = qtl.rsi(close_array, length=14)
|
||||
assert isinstance(result, pa.Array)
|
||||
assert len(result) == len(close_array)
|
||||
|
||||
def test_stddev_returns_arrow_array(self, close_array: pa.Array) -> None:
|
||||
result = qtl.stddev(close_array, length=14)
|
||||
assert isinstance(result, pa.Array)
|
||||
assert len(result) == len(close_array)
|
||||
|
||||
def test_mom_returns_arrow_array(self, close_array: pa.Array) -> None:
|
||||
result = qtl.mom(close_array, length=10)
|
||||
assert isinstance(result, pa.Array)
|
||||
assert len(result) == len(close_array)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChunkedArray input
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestChunkedArray:
|
||||
def test_chunked_array_accepted(self, close_chunked: pa.ChunkedArray) -> None:
|
||||
result = qtl.sma(close_chunked, length=14)
|
||||
assert isinstance(result, pa.Array)
|
||||
assert len(result) == len(close_chunked)
|
||||
|
||||
def test_chunked_values_match_flat(self, close_chunked: pa.ChunkedArray) -> None:
|
||||
flat = close_chunked.combine_chunks()
|
||||
result_chunked = qtl.sma(close_chunked, length=14)
|
||||
result_flat = qtl.sma(flat, length=14)
|
||||
np.testing.assert_allclose(
|
||||
result_chunked.to_numpy(zero_copy_only=False),
|
||||
result_flat.to_numpy(zero_copy_only=False),
|
||||
rtol=1e-12,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-output: pa.Array in → dict[str, pa.Array] out
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMultiOutput:
|
||||
def test_bbands_returns_dict_of_arrays(self, close_array: pa.Array) -> None:
|
||||
result = qtl.bbands(close_array, length=20, std=2.0)
|
||||
assert isinstance(result, dict)
|
||||
assert all(isinstance(v, pa.Array) for v in result.values())
|
||||
assert len(result) == 3 # upper, mid, lower
|
||||
for v in result.values():
|
||||
assert len(v) == len(close_array)
|
||||
assert v.type == pa.float64()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Numerical equivalence: Arrow vs numpy should produce identical values
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestNumericalEquivalence:
|
||||
def test_sma_values_match_numpy(self, close_array: pa.Array) -> None:
|
||||
np_arr = close_array.to_numpy(zero_copy_only=False)
|
||||
result_pa = qtl.sma(close_array, length=14)
|
||||
result_np = qtl.sma(np_arr, length=14)
|
||||
np.testing.assert_allclose(
|
||||
result_pa.to_numpy(zero_copy_only=False),
|
||||
result_np,
|
||||
rtol=1e-12,
|
||||
)
|
||||
|
||||
def test_rsi_values_match_numpy(self, close_array: pa.Array) -> None:
|
||||
np_arr = close_array.to_numpy(zero_copy_only=False)
|
||||
result_pa = qtl.rsi(close_array, length=14)
|
||||
result_np = qtl.rsi(np_arr, length=14)
|
||||
np.testing.assert_allclose(
|
||||
result_pa.to_numpy(zero_copy_only=False),
|
||||
result_np,
|
||||
rtol=1e-12,
|
||||
equal_nan=True,
|
||||
)
|
||||
|
||||
def test_ema_values_match_numpy(self, close_array: pa.Array) -> None:
|
||||
np_arr = close_array.to_numpy(zero_copy_only=False)
|
||||
result_pa = qtl.ema(close_array, length=14)
|
||||
result_np = qtl.ema(np_arr, length=14)
|
||||
np.testing.assert_allclose(
|
||||
result_pa.to_numpy(zero_copy_only=False),
|
||||
result_np,
|
||||
rtol=1e-12,
|
||||
equal_nan=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type coercion
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestTypeCoercion:
|
||||
def test_int32_array_coerced(self) -> None:
|
||||
arr = pa.array(list(range(1, 101)), type=pa.int32())
|
||||
result = qtl.sma(arr, length=5)
|
||||
assert isinstance(result, pa.Array)
|
||||
assert result.type == pa.float64()
|
||||
assert len(result) == 100
|
||||
|
||||
def test_float32_array_coerced(self) -> None:
|
||||
rng = np.random.default_rng(42)
|
||||
arr = pa.array(rng.random(100).astype(np.float32), type=pa.float32())
|
||||
result = qtl.sma(arr, length=5)
|
||||
assert isinstance(result, pa.Array)
|
||||
assert result.type == pa.float64()
|
||||
assert len(result) == 100
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestEdgeCases:
|
||||
def test_empty_array_raises(self) -> None:
|
||||
empty = pa.array([], type=pa.float64())
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
qtl.sma(empty, length=14)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Round-trip tests for Polars Series / DataFrame input → output.
|
||||
|
||||
Requires: ``pip install quantalib[polars]``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pl = pytest.importorskip("polars", minversion="0.20")
|
||||
|
||||
import quantalib as qtl # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture()
|
||||
def close_series() -> pl.Series:
|
||||
"""100-bar random close prices as a Polars Series."""
|
||||
rng = np.random.default_rng(42)
|
||||
return pl.Series(name="close", values=rng.random(100) * 100 + 50)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ohlcv_df() -> pl.DataFrame:
|
||||
"""100-bar OHLCV DataFrame."""
|
||||
rng = np.random.default_rng(42)
|
||||
c = rng.random(100) * 100 + 50
|
||||
return pl.DataFrame({
|
||||
"open": c + rng.uniform(-2, 2, 100),
|
||||
"high": c + rng.uniform(0, 5, 100),
|
||||
"low": c - rng.uniform(0, 5, 100),
|
||||
"close": c,
|
||||
"volume": rng.uniform(1e4, 1e6, 100),
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-output: Polars Series in → Polars Series out
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSingleOutput:
|
||||
def test_sma_returns_polars_series(self, close_series: pl.Series) -> None:
|
||||
result = qtl.sma(close_series, length=14)
|
||||
assert isinstance(result, pl.Series)
|
||||
assert len(result) == len(close_series)
|
||||
|
||||
def test_ema_returns_polars_series(self, close_series: pl.Series) -> None:
|
||||
result = qtl.ema(close_series, length=14)
|
||||
assert isinstance(result, pl.Series)
|
||||
assert len(result) == len(close_series)
|
||||
|
||||
def test_rsi_returns_polars_series(self, close_series: pl.Series) -> None:
|
||||
result = qtl.rsi(close_series, length=14)
|
||||
assert isinstance(result, pl.Series)
|
||||
assert len(result) == len(close_series)
|
||||
|
||||
def test_series_name_follows_convention(self, close_series: pl.Series) -> None:
|
||||
result = qtl.sma(close_series, length=20)
|
||||
assert isinstance(result, pl.Series)
|
||||
assert result.name == "SMA_20"
|
||||
|
||||
def test_stddev_returns_polars_series(self, close_series: pl.Series) -> None:
|
||||
result = qtl.stddev(close_series, length=14)
|
||||
assert isinstance(result, pl.Series)
|
||||
assert len(result) == len(close_series)
|
||||
|
||||
def test_mom_returns_polars_series(self, close_series: pl.Series) -> None:
|
||||
result = qtl.mom(close_series, length=10)
|
||||
assert isinstance(result, pl.Series)
|
||||
assert len(result) == len(close_series)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DataFrame input: first column extracted
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestDataFrameInput:
|
||||
def test_dataframe_first_col_used(self, ohlcv_df: pl.DataFrame) -> None:
|
||||
close_col = ohlcv_df.select("close")
|
||||
result = qtl.sma(close_col, length=14)
|
||||
assert isinstance(result, pl.Series)
|
||||
assert len(result) == len(ohlcv_df)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-output: Polars Series in → Polars DataFrame out
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestMultiOutput:
|
||||
def test_bbands_returns_polars_dataframe(self, close_series: pl.Series) -> None:
|
||||
result = qtl.bbands(close_series, length=20, std=2.0)
|
||||
assert isinstance(result, pl.DataFrame)
|
||||
assert result.shape[0] == len(close_series)
|
||||
assert result.shape[1] == 3 # upper, mid, lower
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Numerical equivalence: Polars vs numpy should produce identical values
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestNumericalEquivalence:
|
||||
def test_sma_values_match_numpy(self, close_series: pl.Series) -> None:
|
||||
np_arr = close_series.to_numpy()
|
||||
result_pl = qtl.sma(close_series, length=14)
|
||||
result_np = qtl.sma(np_arr, length=14)
|
||||
np.testing.assert_allclose(
|
||||
result_pl.to_numpy(), result_np, rtol=1e-12
|
||||
)
|
||||
|
||||
def test_rsi_values_match_numpy(self, close_series: pl.Series) -> None:
|
||||
np_arr = close_series.to_numpy()
|
||||
result_pl = qtl.rsi(close_series, length=14)
|
||||
result_np = qtl.rsi(np_arr, length=14)
|
||||
np.testing.assert_allclose(
|
||||
result_pl.to_numpy(), result_np, rtol=1e-12, equal_nan=True
|
||||
)
|
||||
|
||||
def test_ema_values_match_numpy(self, close_series: pl.Series) -> None:
|
||||
np_arr = close_series.to_numpy()
|
||||
result_pl = qtl.ema(close_series, length=14)
|
||||
result_np = qtl.ema(np_arr, length=14)
|
||||
np.testing.assert_allclose(
|
||||
result_pl.to_numpy(), result_np, rtol=1e-12, equal_nan=True
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestEdgeCases:
|
||||
def test_none_input_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="must not be None"):
|
||||
qtl.sma(None, length=14)
|
||||
|
||||
def test_empty_series_raises(self) -> None:
|
||||
empty = pl.Series(name="empty", values=[], dtype=pl.Float64)
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
qtl.sma(empty, length=14)
|
||||
|
||||
def test_int_series_coerced_to_float(self) -> None:
|
||||
int_series = pl.Series(name="ints", values=list(range(1, 101)))
|
||||
result = qtl.sma(int_series, length=5)
|
||||
assert isinstance(result, pl.Series)
|
||||
assert len(result) == 100
|
||||
Reference in New Issue
Block a user