Compare commits

..
Author SHA1 Message Date
Pratik Bhadane 2080e4673d chore: update ferro-ta version to 1.1.3
- Bumped version numbers across Cargo.toml, Cargo.lock, pyproject.toml, and conda/meta.yaml to 1.1.3.
- Added new features including American option pricing, digital options, extended Greeks, and historical volatility estimators.
- Enhanced documentation and tests for new functionalities.
- Updated CHANGELOG.md to reflect changes for version 1.1.3.
2026-04-02 16:30:45 +05:30
25 changed files with 337 additions and 1076 deletions
+2 -2
View File
@@ -381,11 +381,11 @@ jobs:
run: cargo cyclonedx --format json --override-filename ferro-ta-rust-sbom.cdx run: cargo cyclonedx --format json --override-filename ferro-ta-rust-sbom.cdx
- name: Upload Python SBOM to release - name: Upload Python SBOM to release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
files: ferro-ta-python-sbom.spdx.json files: ferro-ta-python-sbom.spdx.json
- name: Upload Rust SBOM to release - name: Upload Rust SBOM to release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
files: ferro-ta-rust-sbom.cdx.json files: ferro-ta-rust-sbom.cdx.json
+90 -92
View File
@@ -7,157 +7,151 @@ permissions:
contents: read contents: read
jobs: jobs:
# -------------------------------------------------------------------------
# Lint — no Rust, no wheel build, very fast
# -------------------------------------------------------------------------
lint: lint:
name: Lint (ruff) name: Lint (ruff)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- run: pip install uv
- run: uv run --with ruff ruff check python/ tests/
- run: uv run --with ruff ruff format --check python/ tests/
# ------------------------------------------------------------------------- - name: Install uv
# Type checking — needs wheel; build once with cache run: pip install uv
# -------------------------------------------------------------------------
- name: Run ruff check via uv
run: uv run --with ruff ruff check python/ tests/
- name: Run ruff format check via uv
run: uv run --with ruff ruff format --check python/ tests/
typecheck: typecheck:
name: Type checking (mypy + pyright) name: Type checking (mypy + pyright)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
- run: pip install maturin numpy
- run: maturin build --release --out dist && pip install dist/*.whl
- run: pip install uv
- run: uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
- run: uv run --with pyright python -m pyright python/ferro_ta
# ------------------------------------------------------------------------- - name: Install uv
# Build wheel artifact (once per run, reused by all test matrix entries) run: pip install uv
# -------------------------------------------------------------------------
build-wheel: - name: Run mypy on ferro_ta via uv
name: Build wheel (ubuntu / Python ${{ matrix.python-version }}) run: uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
runs-on: ubuntu-latest
env: - name: Run pyright on ferro_ta via uv
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml run: uv run --with pyright python -m pyright python/ferro_ta
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: "wheel-${{ matrix.python-version }}"
- run: pip install maturin numpy
- run: maturin build --release --out dist
- uses: actions/upload-artifact@v7
with:
name: wheel-${{ matrix.python-version }}
path: dist/*.whl
# -------------------------------------------------------------------------
# Test matrix — downloads pre-built wheel, no Rust compilation
# -------------------------------------------------------------------------
test: test:
name: Test (Python ${{ matrix.python-version }}) name: Test (ubuntu-latest / Python ${{ matrix.python-version }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-wheel
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"] python-version: ["3.10", "3.11", "3.12", "3.13"]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- uses: actions/download-artifact@v8
with: - name: Install maturin and test dependencies
name: wheel-${{ matrix.python-version }}
path: dist
- name: Install wheel + test deps
run: | run: |
pip install maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml mcp
- name: Build and install ferro_ta (dev mode)
run: |
maturin build --release --out dist
pip install dist/*.whl pip install dist/*.whl
pip install pytest pytest-cov pandas polars hypothesis pyyaml mcp scipy
- name: Run tests with coverage - name: Run unit tests with coverage
run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65 run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65
- name: Check API manifest is current - name: Check API manifest is current
if: matrix.python-version == '3.12' if: matrix.python-version == '3.12'
run: python scripts/check_api_manifest.py run: python scripts/check_api_manifest.py
- uses: actions/upload-artifact@v7
- name: Upload coverage report
uses: actions/upload-artifact@v7
if: matrix.python-version == '3.12' if: matrix.python-version == '3.12'
with: with:
name: coverage-python-${{ matrix.python-version }} name: coverage-python-${{ matrix.python-version }}
path: coverage.xml path: coverage.xml
# -------------------------------------------------------------------------
# Benchmark vs TA-Lib (downloads wheel from build-wheel job)
# -------------------------------------------------------------------------
benchmark-vs-talib: benchmark-vs-talib:
name: Benchmark vs TA-Lib name: Benchmark vs TA-Lib
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-wheel
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- uses: actions/download-artifact@v8
with: - name: Install TA-Lib C library (Ubuntu)
name: wheel-3.12
path: dist
- name: Install TA-Lib C library
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential curl sudo apt-get install -y build-essential curl
curl -sL https://sourceforge.net/projects/ta-lib/files/ta-lib/0.4.0/ta-lib-0.4.0-src.tar.gz/download -o ta-lib-0.4.0-src.tar.gz curl -sL https://sourceforge.net/projects/ta-lib/files/ta-lib/0.4.0/ta-lib-0.4.0-src.tar.gz/download -o ta-lib-0.4.0-src.tar.gz
tar -xzf ta-lib-0.4.0-src.tar.gz tar -xzf ta-lib-0.4.0-src.tar.gz
cd ta-lib && ./configure --prefix=/usr && make && sudo make install && sudo ldconfig cd ta-lib
- run: pip install dist/*.whl numpy ta-lib ./configure --prefix=/usr
- run: python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json make
- run: python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json sudo make install
- uses: actions/upload-artifact@v7 sudo ldconfig
- name: Install maturin and ta-lib Python package
run: |
pip install maturin numpy
pip install ta-lib
- name: Build and install ferro_ta
run: |
maturin build --release --out dist
pip install dist/*.whl
- name: Run benchmark comparison
run: |
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
- name: Enforce benchmark regression policy
run: python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
- name: Upload benchmark results
uses: actions/upload-artifact@v7
with: with:
name: benchmark-vs-talib name: benchmark-vs-talib
path: benchmark_vs_talib.json path: benchmark_vs_talib.json
# -------------------------------------------------------------------------
# Performance smoke (downloads wheel from build-wheel job)
# -------------------------------------------------------------------------
perf-smoke: perf-smoke:
name: Performance smoke and contracts name: Performance smoke and contracts
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-wheel
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- uses: actions/download-artifact@v8
with: - name: Install maturin and perf dependencies
name: wheel-3.12 run: |
path: dist pip install maturin numpy pytest
- run: pip install dist/*.whl numpy pytest
- run: | - name: Build and install ferro_ta
run: |
maturin build --release --out dist
pip install dist/*.whl
- name: Generate reproducible perf artifacts
run: |
python benchmarks/run_perf_contract.py \ python benchmarks/run_perf_contract.py \
--output-dir perf-contract \ --output-dir perf-contract \
--skip-talib \ --skip-talib \
@@ -167,8 +161,12 @@ jobs:
--streaming-bars 20000 \ --streaming-bars 20000 \
--price-bars 20000 \ --price-bars 20000 \
--iv-bars 50000 --iv-bars 50000
- run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
- uses: actions/upload-artifact@v7 - name: Enforce hotspot regression policy
run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
- name: Upload perf artifacts
uses: actions/upload-artifact@v7
with: with:
name: perf-contract name: perf-contract
path: perf-contract/ path: perf-contract/
+45 -62
View File
@@ -7,119 +7,102 @@ permissions:
contents: read contents: read
jobs: jobs:
# ------------------------------------------------------------------------- audit:
# cargo-deny — uses the official pre-built action (no cargo install needed) name: Dependency audit (cargo + pip)
# -------------------------------------------------------------------------
cargo-deny:
name: cargo deny check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: EmbarkStudios/cargo-deny-action@v2
# ------------------------------------------------------------------------- - name: Install cargo-audit and run cargo audit
# pip-audit + uv.lock freshness check (lightweight, no Rust needed) run: |
# ------------------------------------------------------------------------- cargo install cargo-audit
pip-audit: cargo audit
name: pip-audit + uv.lock check
runs-on: ubuntu-latest - name: Install cargo-deny and run cargo deny check
steps: run: |
- uses: actions/checkout@v6 cargo install cargo-deny --locked
- uses: actions/setup-python@v6 cargo deny check
- name: Set up Python 3.12
uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- run: pip install uv
- run: uv run --with pip-audit pip-audit --skip-editable
- run: uv lock --check
# ------------------------------------------------------------------------- - name: Install uv
# fmt + clippy (with Rust cache so subsequent runs skip recompilation) run: pip install uv
# -------------------------------------------------------------------------
rust-lint: - name: Install pip-audit via uv and run pip-audit
name: Rust fmt + clippy run: uv run --with pip-audit pip-audit --skip-editable
- name: Verify uv.lock is up-to-date
run: uv lock --check
rust:
name: Rust (fmt + clippy)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1 - uses: dtolnay/rust-toolchain@v1
with: with:
toolchain: stable toolchain: stable
components: rustfmt, clippy components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check - run: cargo fmt --all -- --check
- run: cargo clippy --release -- -D warnings - run: cargo clippy --release -- -D warnings
- name: Verify benchmarks compile - name: Verify benchmarks compile (ferro_ta_core)
run: cargo bench -p ferro_ta_core --no-run run: cargo bench -p ferro_ta_core --no-run
# -------------------------------------------------------------------------
# Build + test ferro_ta_core (with Rust cache)
# -------------------------------------------------------------------------
rust-core: rust-core:
name: Rust core (build + test) name: Rust core library (ferro_ta_core)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1 - uses: dtolnay/rust-toolchain@v1
with: with:
toolchain: stable toolchain: stable
- uses: Swatinem/rust-cache@v2 - name: Build core crate
- run: cargo build -p ferro_ta_core run: cargo build -p ferro_ta_core
- run: cargo test -p ferro_ta_core - name: Test core crate
run: cargo test -p ferro_ta_core
# -------------------------------------------------------------------------
# Coverage (optional, cached)
# -------------------------------------------------------------------------
rust-coverage: rust-coverage:
name: Rust coverage (tarpaulin) name: Rust coverage (tarpaulin, optional)
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1 - uses: dtolnay/rust-toolchain@v1
with: with:
toolchain: stable toolchain: stable
- uses: Swatinem/rust-cache@v2 - name: Install cargo-tarpaulin
- uses: taiki-e/install-action@v2 run: cargo install cargo-tarpaulin --locked
with: - name: Collect Rust coverage (ferro_ta_core)
tool: cargo-tarpaulin run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/
- run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/ - name: Upload Rust coverage artifact
- uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: rust-coverage name: rust-coverage
path: coverage/ path: coverage/
if-no-files-found: ignore if-no-files-found: ignore
# -------------------------------------------------------------------------
# Fuzz (optional, nightly, cached)
# -------------------------------------------------------------------------
fuzz: fuzz:
name: Fuzz targets (short CI run) name: Fuzz targets (short CI run, optional)
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1 - uses: dtolnay/rust-toolchain@v1
with: with:
toolchain: nightly toolchain: nightly
targets: x86_64-unknown-linux-gnu - name: Install cargo-fuzz
- uses: Swatinem/rust-cache@v2 run: cargo install cargo-fuzz --locked
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
- name: Run fuzz_sma (10000 iterations) - name: Run fuzz_sma (10000 iterations)
working-directory: fuzz working-directory: fuzz
run: cargo fuzz run fuzz_sma --target x86_64-unknown-linux-gnu -- -runs=10000 -max_len=512 run: cargo fuzz run fuzz_sma -- -runs=10000 -max_len=512
- name: Run fuzz_rsi (10000 iterations) - name: Run fuzz_rsi (10000 iterations)
working-directory: fuzz working-directory: fuzz
run: cargo fuzz run fuzz_rsi --target x86_64-unknown-linux-gnu -- -runs=10000 -max_len=512 run: cargo fuzz run fuzz_rsi -- -runs=10000 -max_len=512
- uses: actions/upload-artifact@v7 - name: Upload fuzz artifacts on crash
uses: actions/upload-artifact@v7
if: always() if: always()
with: with:
name: fuzz-artifacts name: fuzz-artifacts
+26 -15
View File
@@ -8,43 +8,54 @@ permissions:
jobs: jobs:
wasm: wasm:
name: WASM (test + build + bench) name: WASM binding (wasm-pack test --node)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6
- name: Set up Node.js
uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "20"
- uses: dtolnay/rust-toolchain@v1
- name: Install Rust (stable)
uses: dtolnay/rust-toolchain@v1
with: with:
toolchain: stable toolchain: stable
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2 - name: Install wasm-pack
with: run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
tool: wasm-pack
- name: Test WASM binding - name: Build and test WASM binding
working-directory: wasm working-directory: wasm
run: wasm-pack test --node run: wasm-pack test --node
- name: Build WASM packages (node + web)
- name: Build WASM package (both targets)
working-directory: wasm working-directory: wasm
run: npm run build run: npm run build
- name: Check API manifest is current - name: Check API manifest is current
run: python3 scripts/check_api_manifest.py run: python3 scripts/check_api_manifest.py
- name: Benchmark WASM
- name: Benchmark WASM package
working-directory: wasm working-directory: wasm
run: node bench.js --json ../wasm_benchmark.json run: node bench.js --json ../wasm_benchmark.json
- uses: actions/upload-artifact@v7
- name: Upload WASM node package artifact
uses: actions/upload-artifact@v7
with: with:
name: wasm-pkg-node name: wasm-pkg-node
path: wasm/node/ path: wasm/node/
- uses: actions/upload-artifact@v7
- name: Upload WASM web package artifact
uses: actions/upload-artifact@v7
with: with:
name: wasm-pkg-web name: wasm-pkg-web
path: wasm/web/ path: wasm/web/
- uses: actions/upload-artifact@v7
- name: Upload WASM benchmark artifact
uses: actions/upload-artifact@v7
with: with:
name: wasm-benchmark name: wasm-benchmark
path: wasm_benchmark.json path: wasm_benchmark.json
+1 -3
View File
@@ -20,7 +20,6 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GH_PAT }}
- name: Extract version from tag - name: Extract version from tag
id: version id: version
@@ -52,11 +51,10 @@ jobs:
PY PY
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: v${{ steps.version.outputs.version }} name: v${{ steps.version.outputs.version }}
body: ${{ steps.changelog.outputs.body }} body: ${{ steps.changelog.outputs.body }}
draft: false draft: false
prerelease: ${{ contains(github.ref_name, '-') }} prerelease: ${{ contains(github.ref_name, '-') }}
token: ${{ secrets.GH_PAT }}
Generated
+26 -26
View File
@@ -34,9 +34,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.1" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
dependencies = [ dependencies = [
"rustversion", "rustversion",
] ]
@@ -67,9 +67,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.59" version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "ferro_ta" name = "ferro_ta"
version = "1.1.4" version = "1.1.3"
dependencies = [ dependencies = [
"criterion", "criterion",
"ferro_ta_core", "ferro_ta_core",
@@ -222,7 +222,7 @@ dependencies = [
[[package]] [[package]]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.1.4" version = "1.1.3"
dependencies = [ dependencies = [
"criterion", "criterion",
"serde", "serde",
@@ -279,9 +279,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.94" version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
@@ -289,9 +289,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.184" version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]] [[package]]
name = "log" name = "log"
@@ -595,9 +595,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]] [[package]]
name = "rustversion" name = "rustversion"
@@ -729,9 +729,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.117" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -742,9 +742,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.117" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -752,9 +752,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.117" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -765,18 +765,18 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.117" version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.94" version = "0.3.91"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
@@ -840,18 +840,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.48" version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.48" version = "0.8.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+2 -2
View File
@@ -5,7 +5,7 @@ resolver = "2"
[package] [package]
name = "ferro_ta" name = "ferro_ta"
version = "1.1.4" version = "1.1.3"
edition = "2021" edition = "2021"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
license = "MIT" license = "MIT"
@@ -30,7 +30,7 @@ ndarray = "0.16"
rayon = "1.10" rayon = "1.10"
log = "0.4" log = "0.4"
pyo3-log = "0.12" pyo3-log = "0.12"
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.4", features = ["serde"] } ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.3", features = ["serde"] }
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] } criterion = { version = "0.8", features = ["html_reports"] }
+1 -1
View File
@@ -1,5 +1,5 @@
{% set name = "ferro-ta" %} {% set name = "ferro-ta" %}
{% set version = "1.1.4" %} {% set version = "1.1.3" %}
package: package:
name: {{ name|lower }} name: {{ name|lower }}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.1.4" version = "1.1.3"
edition = "2021" edition = "2021"
description = "Pure Rust core indicator library — no PyO3, no numpy dependency" description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
```toml ```toml
[dependencies] [dependencies]
ferro_ta_core = "1.1.4" ferro_ta_core = "1.1.3"
``` ```
## Design ## Design
-200
View File
@@ -247,118 +247,6 @@ pub fn correl(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
result result
} }
// ---------------------------------------------------------------------------
// Dynamic Time Warping (DTW)
// ---------------------------------------------------------------------------
/// Internal helper: build the full DTW accumulated-cost matrix.
///
/// Local cost: `|s1[i] - s2[j]|` (Euclidean / L1 for 1-D series).
/// This matches the convention used by `dtaidistance.dtw.distance()`.
///
/// Out-of-band cells (Sakoe-Chiba constraint) are set to `f64::INFINITY`.
fn dtw_matrix(s1: &[f64], s2: &[f64], window: Option<usize>) -> Vec<Vec<f64>> {
let n = s1.len();
let m = s2.len();
let mut dp = vec![vec![f64::INFINITY; m]; n];
for i in 0..n {
// Window convention matches dtaidistance: window=w means |i-j| < w.
// None = unconstrained (full matrix).
let (j_lo, j_hi) = match window {
None => (0, m),
Some(w) => {
let lo = i.saturating_sub(w.saturating_sub(1));
let hi = i.saturating_add(w).min(m);
(lo, hi)
}
};
for j in j_lo..j_hi {
// Squared Euclidean local cost — matches dtaidistance convention.
// The final sqrt is applied only once at the top level (not per-step).
let cost = (s1[i] - s2[j]).powi(2);
let prev = if i == 0 && j == 0 {
0.0
} else if i == 0 {
dp[0][j - 1]
} else if j == 0 {
dp[i - 1][0]
} else {
dp[i - 1][j - 1].min(dp[i - 1][j]).min(dp[i][j - 1])
};
dp[i][j] = cost + prev;
}
}
dp
}
/// Compute the Dynamic Time Warping distance between two 1-D series.
///
/// Returns the accumulated Euclidean cost along the optimal warping path.
/// Uses `|s1[i] - s2[j]|` as the local cost, matching `dtaidistance` convention.
///
/// # Arguments
/// * `s1` - First time series.
/// * `s2` - Second time series.
/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained.
///
/// Returns `f64::NAN` if either input is empty.
pub fn dtw_distance(s1: &[f64], s2: &[f64], window: Option<usize>) -> f64 {
if s1.is_empty() || s2.is_empty() {
return f64::NAN;
}
let dp = dtw_matrix(s1, s2, window);
// sqrt applied once at the end — matches dtaidistance.dtw.distance() convention.
dp[s1.len() - 1][s2.len() - 1].sqrt()
}
/// Compute the DTW distance and the optimal warping path between two 1-D series.
///
/// The warping path is a `Vec<(usize, usize)>` of `(i, j)` index pairs,
/// starting at `(0, 0)` and ending at `(n-1, m-1)`, monotonically non-decreasing.
///
/// # Arguments
/// * `s1` - First time series.
/// * `s2` - Second time series.
/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained.
///
/// Returns `(f64::NAN, vec![])` if either input is empty.
pub fn dtw_path(s1: &[f64], s2: &[f64], window: Option<usize>) -> (f64, Vec<(usize, usize)>) {
if s1.is_empty() || s2.is_empty() {
return (f64::NAN, vec![]);
}
let dp = dtw_matrix(s1, s2, window);
let dist = dp[s1.len() - 1][s2.len() - 1].sqrt();
// Backtrace from (n-1, m-1) to (0, 0)
let mut path = Vec::new();
let (mut i, mut j) = (s1.len() - 1, s2.len() - 1);
path.push((i, j));
while i > 0 || j > 0 {
let (ni, nj) = match (i, j) {
(0, _) => (0, j - 1),
(_, 0) => (i - 1, 0),
_ => {
let diag = dp[i - 1][j - 1];
let up = dp[i - 1][j];
let left = dp[i][j - 1];
let best = diag.min(up).min(left);
if best == diag {
(i - 1, j - 1)
} else if best == up {
(i - 1, j)
} else {
(i, j - 1)
}
}
};
i = ni;
j = nj;
path.push((i, j));
}
path.reverse();
(dist, path)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -371,92 +259,4 @@ mod tests {
assert!(v.abs() < 1e-10); assert!(v.abs() < 1e-10);
} }
} }
#[test]
fn dtw_identical_series_is_zero() {
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(dtw_distance(&a, &a, None), 0.0);
}
#[test]
fn dtw_known_shifted_series() {
// [0,1,2] vs [1,2,3]: DTW uses squared Euclidean local cost + final sqrt.
// Optimal path (0,0)→(1,0)→(2,1)→(2,2), accumulated cost = 1+0+0+1 = 2, sqrt(2).
// Matches dtaidistance.dtw.distance([0,1,2],[1,2,3]) = 1.4142...
let a = vec![0.0, 1.0, 2.0];
let b = vec![1.0, 2.0, 3.0];
let expected = 2.0_f64.sqrt();
let result = dtw_distance(&a, &b, None);
assert!(
(result - expected).abs() < 1e-12,
"got {result}, expected {expected}"
);
}
#[test]
fn dtw_known_even_shift() {
// [0,2,4] vs [1,3,5]: diagonal path, squared costs 1+1+1=3, sqrt(3).
// Matches dtaidistance.dtw.distance([0,2,4],[1,3,5]) = 1.7320...
let a = vec![0.0, 2.0, 4.0];
let b = vec![1.0, 3.0, 5.0];
let expected = 3.0_f64.sqrt();
let result = dtw_distance(&a, &b, None);
assert!(
(result - expected).abs() < 1e-12,
"got {result}, expected {expected}"
);
}
#[test]
fn dtw_single_element() {
let a = vec![3.0];
let b = vec![7.0];
assert_eq!(dtw_distance(&a, &b, None), 4.0);
}
#[test]
fn dtw_empty_returns_nan() {
assert!(dtw_distance(&[], &[1.0, 2.0], None).is_nan());
assert!(dtw_distance(&[1.0, 2.0], &[], None).is_nan());
}
#[test]
fn dtw_path_endpoints() {
let a = vec![1.0, 2.0, 3.0, 4.0];
let b = vec![1.5, 2.5, 3.5, 4.5];
let (_, path) = dtw_path(&a, &b, None);
assert_eq!(path.first(), Some(&(0, 0)));
assert_eq!(path.last(), Some(&(3, 3)));
}
#[test]
fn dtw_path_is_monotone() {
let a = vec![1.0, 3.0, 2.0, 5.0, 4.0];
let b = vec![2.0, 1.0, 4.0, 3.0, 6.0];
let (_, path) = dtw_path(&a, &b, None);
for k in 1..path.len() {
assert!(path[k].0 >= path[k - 1].0);
assert!(path[k].1 >= path[k - 1].1);
}
}
#[test]
fn dtw_path_distance_matches_distance_only() {
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0];
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0];
let d1 = dtw_distance(&a, &b, None);
let (d2, _) = dtw_path(&a, &b, None);
assert!((d1 - d2).abs() < 1e-12);
}
#[test]
fn dtw_window_constrained_ge_unconstrained() {
// window convention matches dtaidistance: Some(w) means |i-j| < w.
// A narrow window restricts warping, so constrained distance >= unconstrained.
let a: Vec<f64> = (0..20).map(|x| x as f64).collect();
let b: Vec<f64> = (0..20).map(|x| x as f64 + 3.0).collect();
let d_full = dtw_distance(&a, &b, None);
let d_narrow = dtw_distance(&a, &b, Some(3));
assert!(d_narrow >= d_full - 1e-12);
}
} }
+7 -63
View File
@@ -1,8 +1,8 @@
{ {
"surfaces": { "surfaces": {
"python": { "python": {
"indicator_count": 211, "indicator_count": 208,
"method_count": 467, "method_count": 464,
"categories": [ "categories": [
"aggregation", "aggregation",
"alerts", "alerts",
@@ -131,13 +131,6 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "BATCH_DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "BBANDS", "name": "BBANDS",
"category": "overlap", "category": "overlap",
@@ -663,20 +656,6 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{
"name": "DTW_DISTANCE",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "DX", "name": "DX",
"category": "momentum", "category": "momentum",
@@ -3304,13 +3283,6 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "BATCH_DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "BETA", "name": "BETA",
"category": "statistic", "category": "statistic",
@@ -3325,20 +3297,6 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{
"name": "DTW_DISTANCE",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "LINEARREG", "name": "LINEARREG",
"category": "statistic", "category": "statistic",
@@ -4777,7 +4735,7 @@
] ]
}, },
"rust_core": { "rust_core": {
"public_function_count": 351, "public_function_count": 349,
"functions": [ "functions": [
{ {
"module": "aggregation", "module": "aggregation",
@@ -6229,16 +6187,6 @@
"function": "correl", "function": "correl",
"file": "statistic.rs" "file": "statistic.rs"
}, },
{
"module": "statistic",
"function": "dtw_distance",
"file": "statistic.rs"
},
{
"module": "statistic",
"function": "dtw_path",
"file": "statistic.rs"
},
{ {
"module": "statistic", "module": "statistic",
"function": "linearreg", "function": "linearreg",
@@ -6537,7 +6485,7 @@
] ]
}, },
"wasm_node": { "wasm_node": {
"export_count": 222, "export_count": 221,
"exports": [ "exports": [
"ad", "ad",
"adosc", "adosc",
@@ -6598,7 +6546,6 @@
"digital_price", "digital_price",
"donchian", "donchian",
"drawdown_series", "drawdown_series",
"dtw_distance",
"dx", "dx",
"early_exercise_premium", "early_exercise_premium",
"ema", "ema",
@@ -6765,9 +6712,9 @@
} }
}, },
"parity_summary": { "parity_summary": {
"python_indicator_count": 210, "python_indicator_count": 207,
"wasm_export_count": 222, "wasm_export_count": 221,
"common_python_wasm_count": 92, "common_python_wasm_count": 91,
"common_python_wasm": [ "common_python_wasm": [
"ad", "ad",
"adosc", "adosc",
@@ -6796,7 +6743,6 @@
"dema", "dema",
"detect_breaks_cusum", "detect_breaks_cusum",
"donchian", "donchian",
"dtw_distance",
"dx", "dx",
"ema", "ema",
"ht_dcperiod", "ht_dcperiod",
@@ -6871,7 +6817,6 @@
"asin", "asin",
"atan", "atan",
"batch_apply", "batch_apply",
"batch_dtw",
"beta", "beta",
"cdl2crows", "cdl2crows",
"cdl3blackcrows", "cdl3blackcrows",
@@ -6941,7 +6886,6 @@
"cosh", "cosh",
"div", "div",
"drawdown", "drawdown",
"dtw",
"exp", "exp",
"feature_matrix", "feature_matrix",
"floor", "floor",
+1 -1
View File
@@ -1,7 +1,7 @@
Release Notes Release Notes
============= =============
These docs track package version ``1.1.4``. These docs track package version ``1.1.3``.
1.1.0-audit (2026-03-28) 1.1.0-audit (2026-03-28)
------------------------ ------------------------
+1 -1
View File
@@ -180,7 +180,7 @@ For source builds, packaging details, and platform notes, see
Release status Release status
-------------- --------------
These docs track package version ``1.1.4``. These docs track package version ``1.1.3``.
- Release notes by version: :doc:`changelog` - Release notes by version: :doc:`changelog`
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_ - Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "ferro-ta" name = "ferro-ta"
version = "1.1.4" version = "1.1.3"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
-109
View File
@@ -12,33 +12,19 @@ LINEARREG_ANGLE — Linear Regression Angle (degrees)
TSF Time Series Forecast TSF Time Series Forecast
BETA Beta BETA Beta
CORREL Pearson's Correlation Coefficient (r) CORREL Pearson's Correlation Coefficient (r)
DTW Dynamic Time Warping (distance + warping path)
DTW_DISTANCE Dynamic Time Warping distance only (faster)
BATCH_DTW Batch DTW: N series vs 1 reference, in parallel
""" """
from __future__ import annotations from __future__ import annotations
from typing import Optional
import numpy as np import numpy as np
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
batch_dtw as _batch_dtw,
)
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
beta as _beta, beta as _beta,
) )
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
correl as _correl, correl as _correl,
) )
from ferro_ta._ferro_ta import (
dtw as _dtw,
)
from ferro_ta._ferro_ta import (
dtw_distance as _dtw_distance,
)
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
linearreg as _linearreg, linearreg as _linearreg,
) )
@@ -261,98 +247,6 @@ def CORREL(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 30) -> np.ndarr
_normalize_rust_error(e) _normalize_rust_error(e)
def DTW(
series1: ArrayLike,
series2: ArrayLike,
window: Optional[int] = None,
) -> tuple[float, np.ndarray]:
"""Dynamic Time Warping — distance and optimal warping path.
Parameters
----------
series1 : array-like
First time series.
series2 : array-like
Second time series (may differ in length from series1).
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
distance : float
DTW distance (accumulated Euclidean cost along the optimal path).
path : numpy.ndarray, shape (N, 2)
Warping path as ``(i, j)`` index pairs from ``(0, 0)`` to
``(len(series1)-1, len(series2)-1)``.
"""
try:
return _dtw(_to_f64(series1), _to_f64(series2), window)
except ValueError as e:
_normalize_rust_error(e)
def DTW_DISTANCE(
series1: ArrayLike,
series2: ArrayLike,
window: Optional[int] = None,
) -> float:
"""Dynamic Time Warping distance only (faster — no path reconstruction).
Parameters
----------
series1 : array-like
First time series.
series2 : array-like
Second time series (may differ in length from series1).
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
float
DTW distance (accumulated Euclidean cost along the optimal path).
"""
try:
return _dtw_distance(_to_f64(series1), _to_f64(series2), window)
except ValueError as e:
_normalize_rust_error(e)
def BATCH_DTW(
matrix: ArrayLike,
reference: ArrayLike,
window: Optional[int] = None,
) -> np.ndarray:
"""Batch Dynamic Time Warping — N series vs 1 reference, computed in parallel.
Parameters
----------
matrix : array-like, shape (N, L)
N time series of length L. Each row is compared against ``reference``.
reference : array-like, shape (L,)
The reference series.
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
numpy.ndarray, shape (N,)
DTW distance from each row of ``matrix`` to ``reference``.
"""
try:
mat = np.ascontiguousarray(matrix, dtype=np.float64)
if mat.ndim != 2:
from ferro_ta.core.exceptions import FerroTAInputError
raise FerroTAInputError(
f"matrix must be a 2-D array, got {mat.ndim}-D.",
suggestion="Pass a 2-D NumPy array of shape (N, L).",
)
return _batch_dtw(mat, _to_f64(reference), window)
except ValueError as e:
_normalize_rust_error(e)
__all__ = [ __all__ = [
"STDDEV", "STDDEV",
"VAR", "VAR",
@@ -363,7 +257,4 @@ __all__ = [
"TSF", "TSF",
"BETA", "BETA",
"CORREL", "CORREL",
"DTW",
"DTW_DISTANCE",
"BATCH_DTW",
] ]
+127 -199
View File
@@ -1,40 +1,27 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Pre-push CI gate — runs checks in parallel to minimise wall-clock time.
#
# Usage:
# scripts/pre_push_checks.sh # all checks
# scripts/pre_push_checks.sh rust_clippy wasm # selected checks
# scripts/pre_push_checks.sh --list
# FERRO_FAST=1 scripts/pre_push_checks.sh # skip docs + wasm bench
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
AVAILABLE_CHECKS=( AVAILABLE_CHECKS=(
version changelog manifest version
rust_fmt rust_clippy rust_core rust_bench changelog
python_lint python_typecheck python_test rust_fmt
docs wasm rust_clippy
rust_core
rust_bench
python_lint
python_typecheck
python_test
docs
wasm
manifest
) )
DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}") DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}")
# --------------------------------------------------------------------------- python_env_ready=0
# Helpers
# ---------------------------------------------------------------------------
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2; exit 1
fi
}
run_cmd() {
printf ' +'
printf ' %q' "$@"
printf '\n'
"$@"
}
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -43,60 +30,93 @@ Usage:
scripts/pre_push_checks.sh <check> [<check> ...] scripts/pre_push_checks.sh <check> [<check> ...]
scripts/pre_push_checks.sh --list scripts/pre_push_checks.sh --list
Environment: Runs the repo's basic local CI gate before push. By default it covers:
FERRO_FAST=1 Skip docs and wasm (fastest local feedback loop) version changelog rust_fmt rust_clippy rust_core rust_bench
python_lint python_typecheck python_test docs wasm manifest
Notes:
- This mirrors the required CI categories we can run locally.
- It intentionally skips the multi-Python test matrix, audit jobs, perf smoke,
and benchmark-regression jobs.
EOF EOF
} }
# --------------------------------------------------------------------------- list_checks() {
# Individual check functions printf '%s\n' "${AVAILABLE_CHECKS[@]}"
# --------------------------------------------------------------------------- }
need_cmd() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Missing required command: $command_name" >&2
exit 1
fi
}
run_cmd() {
printf ' +'
printf ' %q' "$@"
printf '\n'
"$@"
}
ensure_python_env() {
if [[ "$python_env_ready" -eq 1 ]]; then
return
fi
need_cmd uv
run_cmd uv sync --extra dev --extra docs --extra mcp
run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release
python_env_ready=1
}
run_version() {
need_cmd python3
run_cmd python3 scripts/bump_version.py --check
}
run_changelog() {
need_cmd python3
run_cmd python3 scripts/check_changelog.py
}
run_rust_fmt() {
need_cmd cargo
run_cmd cargo fmt --all -- --check
}
run_rust_clippy() {
need_cmd cargo
run_cmd cargo clippy --release -- -D warnings
}
run_rust_core() {
need_cmd cargo
run_cmd cargo build -p ferro_ta_core
run_cmd cargo test -p ferro_ta_core
}
run_rust_bench() {
need_cmd cargo
run_cmd cargo bench -p ferro_ta_core --no-run
}
run_version() { need_cmd python3; run_cmd python3 scripts/bump_version.py --check; }
run_changelog() { need_cmd python3; run_cmd python3 scripts/check_changelog.py; }
run_manifest() { need_cmd python3; run_cmd python3 scripts/check_api_manifest.py; }
run_rust_fmt() { need_cmd cargo; run_cmd cargo fmt --all -- --check; }
run_python_lint() { run_python_lint() {
need_cmd uv need_cmd uv
run_cmd uv run --with ruff ruff check python/ tests/ run_cmd uv run --with ruff ruff check python/ tests/
run_cmd uv run --with ruff ruff format --check python/ tests/ run_cmd uv run --with ruff ruff format --check python/ tests/
} }
run_rust_clippy() { need_cmd cargo; run_cmd cargo clippy --release -- -D warnings; }
run_rust_core() { need_cmd cargo; run_cmd cargo build -p ferro_ta_core && run_cmd cargo test -p ferro_ta_core; }
run_rust_bench() { need_cmd cargo; run_cmd cargo bench -p ferro_ta_core --no-run; }
run_python_typecheck() { run_python_typecheck() {
need_cmd uv need_cmd uv
run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta \ run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
--ignore-missing-imports --no-error-summary
run_cmd uv run --with pyright python -m pyright python/ferro_ta run_cmd uv run --with pyright python -m pyright python/ferro_ta
} }
# python_test and docs both need a compiled extension.
# Use a flag file so only the first concurrent caller runs maturin develop;
# subsequent callers (in parallel background jobs) wait and reuse it.
_MATURIN_LOCK="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.lock"
_MATURIN_FLAG="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.done"
ensure_python_env() {
[[ -f "$_MATURIN_FLAG" ]] && return
(
flock 9
if [[ ! -f "$_MATURIN_FLAG" ]]; then
need_cmd uv
run_cmd uv sync --extra dev --extra docs --extra mcp
run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release
touch "$_MATURIN_FLAG"
fi
) 9>"$_MATURIN_LOCK"
}
run_python_test() { run_python_test() {
ensure_python_env ensure_python_env
run_cmd uv run --extra dev --extra mcp --with pytest-cov \ run_cmd uv run --extra dev --extra mcp --with pytest-cov pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65
pytest tests/unit/ tests/integration/ \
-v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65
} }
run_docs() { run_docs() {
@@ -105,161 +125,69 @@ run_docs() {
} }
run_wasm() { run_wasm() {
need_cmd node; need_cmd wasm-pack need_cmd node
need_cmd wasm-pack
local benchmark_json="../.wasm_benchmark.prepush.json"
( (
cd wasm cd wasm
trap 'rm -f "$benchmark_json"' EXIT
run_cmd wasm-pack test --node run_cmd wasm-pack test --node
run_cmd npm run build run_cmd npm run build
if [[ "${FERRO_FAST:-0}" != "1" ]]; then run_cmd node bench.js --json "$benchmark_json"
local bj="../.wasm_benchmark.prepush.json"
run_cmd node bench.js --json "$bj"
rm -f "$bj"
fi
) )
} }
run_manifest() {
need_cmd python3
run_cmd python3 scripts/check_api_manifest.py
}
run_check() { run_check() {
case "$1" in local check_name="$1"
version) run_version ;; case "$check_name" in
changelog) run_changelog ;; version) run_version ;;
manifest) run_manifest ;; changelog) run_changelog ;;
rust_fmt) run_rust_fmt ;; rust_fmt) run_rust_fmt ;;
rust_clippy) run_rust_clippy ;; rust_clippy) run_rust_clippy ;;
rust_core) run_rust_core ;; rust_core) run_rust_core ;;
rust_bench) run_rust_bench ;; rust_bench) run_rust_bench ;;
python_lint) run_python_lint ;; python_lint) run_python_lint ;;
python_typecheck) run_python_typecheck ;; python_typecheck) run_python_typecheck ;;
python_test) run_python_test ;; python_test) run_python_test ;;
docs) run_docs ;; docs) run_docs ;;
wasm) run_wasm ;; wasm) run_wasm ;;
*) echo "Unknown check: $1 — use --list" >&2; exit 1 ;; manifest) run_manifest ;;
*)
echo "Unknown check: $check_name" >&2
echo "Use --list to see supported checks." >&2
exit 1
;;
esac esac
} }
# --------------------------------------------------------------------------- if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
# Parallel runner — starts all checks concurrently, collects results usage
# --------------------------------------------------------------------------- exit 0
fi
run_parallel() { if [[ "${1:-}" == "--list" ]]; then
local -a checks=("$@") list_checks
[[ "${#checks[@]}" -eq 0 ]] && return 0 exit 0
fi
local -a pids logs names
local start
start=$(date +%s)
printf '\nStarting %d checks in parallel: %s\n' "${#checks[@]}" "${checks[*]}"
for check in "${checks[@]}"; do
local log
log=$(mktemp /tmp/ferro_prepush_XXXXXX)
logs+=("$log")
names+=("$check")
run_check "$check" >"$log" 2>&1 &
pids+=($!)
done
local failed=0
local -a failed_names
printf '\n'
for i in "${!pids[@]}"; do
if wait "${pids[$i]}" 2>/dev/null; then
printf ' ✓ %s\n' "${names[$i]}"
else
printf ' ✗ %s\n' "${names[$i]}"
failed_names+=("${names[$i]}")
failed=1
fi
done
# Print logs for failed checks only
if [[ "$failed" -eq 1 ]]; then
for i in "${!names[@]}"; do
local name="${names[$i]}"
if [[ " ${failed_names[*]:-} " == *" $name "* ]]; then
printf '\n'; printf '━%.0s' {1..60}; printf '\nFAILED: %s\n' "$name"; printf '━%.0s' {1..60}; printf '\n'
cat "${logs[$i]}"
fi
done
fi
for log in "${logs[@]}"; do rm -f "$log"; done
rm -f "$_MATURIN_LOCK" "$_MATURIN_FLAG"
printf '\nElapsed: %ds\n' "$(( $(date +%s) - start ))"
return "$failed"
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
[[ "${1:-}" == "--list" ]] && { printf '%s\n' "${AVAILABLE_CHECKS[@]}"; exit 0; }
selected_checks=() selected_checks=()
if [[ "$#" -gt 0 ]]; then if [[ "$#" -gt 0 ]]; then
selected_checks=("$@") selected_checks=("$@")
else else
selected_checks=("${DEFAULT_CHECKS[@]}") selected_checks=("${DEFAULT_CHECKS[@]}")
if [[ "${FERRO_FAST:-0}" == "1" ]]; then
selected_checks=()
for c in "${DEFAULT_CHECKS[@]}"; do
[[ "$c" == "docs" || "$c" == "wasm" ]] && continue
selected_checks+=("$c")
done
printf 'FERRO_FAST=1: skipping docs + wasm\n'
fi
fi fi
# --------------------------------------------------------------------------- total_checks="${#selected_checks[@]}"
# Execution strategy: index=0
# Phase 1 — instant gate (sequential, fail-fast): for check_name in "${selected_checks[@]}"; do
# version, changelog, manifest, python_lint, rust_fmt index=$((index + 1))
# These are trivial to run and catch the most common mistakes early. printf '\n[%d/%d] %s\n' "$index" "$total_checks" "$check_name"
# If any fail here we abort immediately without waiting for slow checks. run_check "$check_name"
#
# Phase 2 — everything else in parallel:
# rust_clippy, rust_core, rust_bench, python_typecheck,
# python_test, docs, wasm
# ---------------------------------------------------------------------------
FAST_CHECKS=(version changelog manifest python_lint rust_fmt)
phase1=()
phase2=()
for c in "${selected_checks[@]}"; do
is_fast=0
for f in "${FAST_CHECKS[@]}"; do [[ "$c" == "$f" ]] && is_fast=1 && break; done
if [[ "$is_fast" -eq 1 ]]; then phase1+=("$c"); else phase2+=("$c"); fi
done done
# Phase 1: fast gate printf '\nAll selected pre-push checks passed.\n'
if [[ "${#phase1[@]}" -gt 0 ]]; then
printf 'Phase 1 — fast gate (%d checks)\n' "${#phase1[@]}"
start1=$(date +%s)
for c in "${phase1[@]}"; do
printf ' [%s] ... ' "$c"
log=$(mktemp /tmp/ferro_prepush_XXXXXX)
if run_check "$c" >"$log" 2>&1; then
printf 'ok\n'
else
printf 'FAILED\n'
cat "$log"
rm -f "$log"
echo "" >&2
echo "Fast gate failed on '$c' — aborting before slow checks." >&2
exit 1
fi
rm -f "$log"
done
printf 'Phase 1 passed (%ds)\n' "$(( $(date +%s) - start1 ))"
fi
# Phase 2: parallel slow checks
if [[ "${#phase2[@]}" -gt 0 ]]; then
printf '\nPhase 2 — parallel slow checks\n'
run_parallel "${phase2[@]}" || exit 1
fi
printf '\nAll pre-push checks passed.\n'
-98
View File
@@ -1,98 +0,0 @@
use ndarray::Array2;
use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use rayon::prelude::*;
/// Dynamic Time Warping — distance and optimal warping path between two 1-D series.
///
/// Returns a tuple `(distance, path)` where `path` is a NumPy array of shape
/// `(N, 2)` containing `(i, j)` index pairs from `(0, 0)` to `(n-1, m-1)`.
///
/// Local cost: `|series1[i] - series2[j]|` (Euclidean, matches `dtaidistance`).
#[pyfunction]
#[pyo3(signature = (series1, series2, window = None))]
pub fn dtw<'py>(
py: Python<'py>,
series1: PyReadonlyArray1<'py, f64>,
series2: PyReadonlyArray1<'py, f64>,
window: Option<usize>,
) -> PyResult<(f64, Bound<'py, PyArray2<usize>>)> {
let s1 = series1.as_slice()?;
let s2 = series2.as_slice()?;
if s1.is_empty() || s2.is_empty() {
return Err(PyValueError::new_err(
"series1 and series2 must not be empty",
));
}
let (dist, path) = ferro_ta_core::statistic::dtw_path(s1, s2, window);
let n = path.len();
let flat: Vec<usize> = path.iter().flat_map(|&(i, j)| [i, j]).collect();
let arr =
Array2::from_shape_vec((n, 2), flat).map_err(|e| PyValueError::new_err(e.to_string()))?;
Ok((dist, arr.into_pyarray(py)))
}
/// Dynamic Time Warping — distance only (faster, no path reconstruction).
///
/// Returns the accumulated Euclidean cost along the optimal warping path.
/// Use this when you only need the distance, not the alignment path.
#[pyfunction]
#[pyo3(signature = (series1, series2, window = None))]
pub fn dtw_distance<'py>(
_py: Python<'py>,
series1: PyReadonlyArray1<'py, f64>,
series2: PyReadonlyArray1<'py, f64>,
window: Option<usize>,
) -> PyResult<f64> {
let s1 = series1.as_slice()?;
let s2 = series2.as_slice()?;
if s1.is_empty() || s2.is_empty() {
return Err(PyValueError::new_err(
"series1 and series2 must not be empty",
));
}
Ok(ferro_ta_core::statistic::dtw_distance(s1, s2, window))
}
/// Batch Dynamic Time Warping — compute DTW distance from each row of a 2-D matrix
/// to a single reference series, in parallel.
///
/// Parameters
/// ----------
/// matrix : np.ndarray, shape (N, L)
/// N time series of length L. Each row is compared against `reference`.
/// reference : np.ndarray, shape (L,)
/// The reference series.
/// window : int, optional
/// Sakoe-Chiba band width. `None` = unconstrained.
///
/// Returns
/// -------
/// np.ndarray, shape (N,)
/// DTW distances, one per row.
#[pyfunction]
#[pyo3(signature = (matrix, reference, window = None))]
pub fn batch_dtw<'py>(
py: Python<'py>,
matrix: PyReadonlyArray2<'py, f64>,
reference: PyReadonlyArray1<'py, f64>,
window: Option<usize>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let mat = matrix.as_array();
let ref_slice = reference.as_slice()?;
if ref_slice.is_empty() {
return Err(PyValueError::new_err("reference must not be empty"));
}
let (n_rows, _) = mat.dim();
let rows: Vec<Vec<f64>> = (0..n_rows).map(|i| mat.row(i).to_vec()).collect();
let result: Vec<f64> = rows
.par_iter()
.map(|series| ferro_ta_core::statistic::dtw_distance(series, ref_slice, window))
.collect();
Ok(result.into_pyarray(py))
}
-4
View File
@@ -4,7 +4,6 @@
mod beta; mod beta;
pub(crate) mod common; pub(crate) mod common;
mod correl; mod correl;
mod dtw;
mod linearreg; mod linearreg;
mod stddev; mod stddev;
mod var; mod var;
@@ -24,8 +23,5 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(pyo3::wrap_pyfunction!(self::linearreg::tsf, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::linearreg::tsf, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::beta::beta, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::beta::beta, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::correl::correl, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::correl::correl, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::dtw::dtw, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::dtw::dtw_distance, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::dtw::batch_dtw, m)?)?;
Ok(()) Ok(())
} }
-178
View File
@@ -1,14 +1,10 @@
"""Unit tests for ferro_ta.indicators.statistic""" """Unit tests for ferro_ta.indicators.statistic"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.statistic import ( from ferro_ta.indicators.statistic import (
BATCH_DTW,
BETA, BETA,
CORREL, CORREL,
DTW,
DTW_DISTANCE,
LINEARREG, LINEARREG,
LINEARREG_ANGLE, LINEARREG_ANGLE,
LINEARREG_INTERCEPT, LINEARREG_INTERCEPT,
@@ -312,177 +308,3 @@ class TestTSF:
expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0) expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0)
result = TSF(_A, timeperiod=14) result = TSF(_A, timeperiod=14)
np.testing.assert_allclose(result, expected, equal_nan=True) np.testing.assert_allclose(result, expected, equal_nan=True)
# ---------------------------------------------------------------------------
# DTW — Dynamic Time Warping
# ---------------------------------------------------------------------------
dtai = pytest.importorskip("dtaidistance", reason="dtaidistance not installed")
_DTW_RNG = np.random.default_rng(42)
class TestDTW:
# --- Validation against dtaidistance (SOTA reference) ---
def test_distance_matches_dtaidistance_random(self):
"""Core correctness: our distance == dtaidistance on 20 random pairs."""
for _ in range(20):
n = int(_DTW_RNG.integers(5, 50))
a = _DTW_RNG.random(n)
b = _DTW_RNG.random(n)
expected = dtai.dtw.distance(a, b)
actual = DTW_DISTANCE(a, b)
np.testing.assert_allclose(
actual, expected, rtol=1e-9, err_msg=f"Mismatch on series length {n}"
)
def test_distance_matches_dtaidistance_unequal_length(self):
"""Handles unequal-length series correctly."""
for _ in range(10):
a = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30)))
b = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30)))
expected = dtai.dtw.distance(a, b)
actual = DTW_DISTANCE(a, b)
np.testing.assert_allclose(actual, expected, rtol=1e-9)
def test_path_distance_matches_dtaidistance(self):
"""DTW() path variant: returned distance matches dtaidistance."""
a = _DTW_RNG.random(20)
b = _DTW_RNG.random(25)
expected = dtai.dtw.distance(a, b)
dist, _ = DTW(a, b)
np.testing.assert_allclose(dist, expected, rtol=1e-9)
def test_path_matches_dtaidistance_warping_path(self):
"""Warping path matches dtaidistance.dtw.warping_path() on same-length series."""
for _ in range(10):
n = int(_DTW_RNG.integers(5, 20))
a = _DTW_RNG.random(n)
b = _DTW_RNG.random(n)
expected_path = dtai.dtw.warping_path(a, b)
_, actual_path = DTW(a, b)
actual_pairs = [tuple(int(x) for x in row) for row in actual_path]
assert actual_pairs == expected_path, (
f"Path mismatch for n={n}:\n ours={actual_pairs}\n dtai={expected_path}"
)
def test_window_constrained_matches_dtaidistance(self):
"""Sakoe-Chiba window matches dtaidistance window parameter."""
a = _DTW_RNG.random(30)
b = _DTW_RNG.random(30)
for w in [3, 8, 15]:
expected = dtai.dtw.distance(a, b, window=w)
actual = DTW_DISTANCE(a, b, window=w)
np.testing.assert_allclose(
actual, expected, rtol=1e-9, err_msg=f"Mismatch at window={w}"
)
def test_batch_matches_dtaidistance(self):
"""BATCH_DTW matches calling dtaidistance per-row."""
ref = _DTW_RNG.random(20)
matrix = _DTW_RNG.random((8, 20))
batch_result = BATCH_DTW(matrix, ref)
for i in range(8):
expected = dtai.dtw.distance(matrix[i], ref)
np.testing.assert_allclose(
batch_result[i],
expected,
rtol=1e-9,
err_msg=f"Batch mismatch at row {i}",
)
# --- Mathematical properties ---
def test_identical_distance_is_zero(self):
a = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
dist, _ = DTW(a, a)
assert dist == pytest.approx(0.0, abs=1e-10)
def test_symmetry(self):
a, b = _DTW_RNG.random(20), _DTW_RNG.random(20)
assert DTW_DISTANCE(a, b) == pytest.approx(DTW_DISTANCE(b, a), rel=1e-10)
def test_triangle_inequality(self):
a, b, c = _DTW_RNG.random(15), _DTW_RNG.random(15), _DTW_RNG.random(15)
assert DTW_DISTANCE(a, c) <= DTW_DISTANCE(a, b) + DTW_DISTANCE(b, c) + 1e-9
# --- Known hardcoded values ---
def test_known_shifted_series(self):
# [0,1,2] vs [1,2,3]: optimal path (0,0)→(1,0)→(2,1)→(2,2)
# Squared costs: 1+0+0+1=2, sqrt(2). Verified against dtaidistance.
a = np.array([0.0, 1.0, 2.0])
b = np.array([1.0, 2.0, 3.0])
np.testing.assert_allclose(DTW_DISTANCE(a, b), np.sqrt(2.0), rtol=1e-9)
def test_known_single_element(self):
# sqrt((3-7)^2) = sqrt(16) = 4.0
np.testing.assert_allclose(
DTW_DISTANCE(np.array([3.0]), np.array([7.0])), 4.0, rtol=1e-9
)
def test_known_constant_series(self):
assert DTW_DISTANCE(np.full(10, 5.0), np.full(10, 5.0)) == pytest.approx(
0.0, abs=1e-12
)
# --- Path structural guarantees ---
def test_path_starts_at_origin(self):
_, path = DTW(_DTW_RNG.random(10), _DTW_RNG.random(10))
assert tuple(int(x) for x in path[0]) == (0, 0)
def test_path_ends_at_corner(self):
_, path = DTW(_DTW_RNG.random(7), _DTW_RNG.random(9))
assert tuple(int(x) for x in path[-1]) == (6, 8)
def test_path_is_monotone(self):
_, path = DTW(_DTW_RNG.random(20), _DTW_RNG.random(20))
for k in range(1, len(path)):
assert path[k][0] >= path[k - 1][0]
assert path[k][1] >= path[k - 1][1]
def test_path_steps_unit_size(self):
_, path = DTW(_DTW_RNG.random(15), _DTW_RNG.random(12))
for k in range(1, len(path)):
di = int(path[k][0]) - int(path[k - 1][0])
dj = int(path[k][1]) - int(path[k - 1][1])
assert di in (0, 1) and dj in (0, 1)
assert not (di == 0 and dj == 0)
# --- DTW_DISTANCE == DTW distance ---
def test_distance_only_matches_full(self):
a, b = _DTW_RNG.random(25), _DTW_RNG.random(25)
d_full, _ = DTW(a, b)
np.testing.assert_allclose(DTW_DISTANCE(a, b), d_full, rtol=1e-10)
# --- Batch ---
def test_batch_single_row(self):
ref = np.array([1.0, 2.0, 3.0])
result = BATCH_DTW(np.array([[1.0, 2.0, 3.0]]), ref)
assert result[0] == pytest.approx(0.0, abs=1e-10)
def test_batch_matches_single_calls(self):
ref = _DTW_RNG.random(20)
matrix = _DTW_RNG.random((8, 20))
batch = BATCH_DTW(matrix, ref)
for i in range(8):
np.testing.assert_allclose(
batch[i], DTW_DISTANCE(matrix[i], ref), rtol=1e-10
)
# --- Edge cases ---
def test_empty_series_raises(self):
with pytest.raises((ValueError, Exception)):
DTW(np.array([]), np.array([1.0, 2.0]))
def test_window_constrained_ge_unconstrained(self):
a, b = _DTW_RNG.random(20), _DTW_RNG.random(20)
d_full = DTW_DISTANCE(a, b)
d_narrow = DTW_DISTANCE(a, b, window=2)
assert d_narrow >= d_full - 1e-9
Generated
+1 -1
View File
@@ -950,7 +950,7 @@ wheels = [
[[package]] [[package]]
name = "ferro-ta" name = "ferro-ta"
version = "1.1.4" version = "1.1.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy" },
+2 -2
View File
@@ -49,11 +49,11 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.1.4" version = "1.1.3"
[[package]] [[package]]
name = "ferro_ta_wasm" name = "ferro_ta_wasm"
version = "1.1.4" version = "1.1.3"
dependencies = [ dependencies = [
"ferro_ta_core", "ferro_ta_core",
"js-sys", "js-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ferro_ta_wasm" name = "ferro_ta_wasm"
version = "1.1.4" version = "1.1.3"
edition = "2021" edition = "2021"
description = "WebAssembly bindings for ferro-ta technical analysis indicators" description = "WebAssembly bindings for ferro-ta technical analysis indicators"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ferro-ta-wasm", "name": "ferro-ta-wasm",
"version": "1.1.4", "version": "1.1.3",
"description": "WebAssembly bindings for ferro-ta technical analysis indicators", "description": "WebAssembly bindings for ferro-ta technical analysis indicators",
"main": "node/ferro_ta_wasm.js", "main": "node/ferro_ta_wasm.js",
"module": "web/ferro_ta_wasm.js", "module": "web/ferro_ta_wasm.js",
-12
View File
@@ -1653,18 +1653,6 @@ pub fn correl(real0: &Float64Array, real1: &Float64Array, timeperiod: usize) ->
from_vec(ferro_ta_core::statistic::correl(&to_vec(real0), &to_vec(real1), timeperiod)) from_vec(ferro_ta_core::statistic::correl(&to_vec(real0), &to_vec(real1), timeperiod))
} }
/// Dynamic Time Warping distance between two series.
///
/// Returns the accumulated Euclidean cost along the optimal warping path.
/// Pass `window` as `0` for unconstrained (no Sakoe-Chiba band).
#[wasm_bindgen]
pub fn dtw_distance(series1: &Float64Array, series2: &Float64Array, window: usize) -> f64 {
let s1 = to_vec(series1);
let s2 = to_vec(series2);
let w = if window == 0 { None } else { Some(window) };
ferro_ta_core::statistic::dtw_distance(&s1, &s2, w)
}
// =========================================================================== // ===========================================================================
// Streaming / Stateful API // Streaming / Stateful API
// =========================================================================== // ===========================================================================