Compare commits

..

1 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
40 changed files with 519 additions and 1928 deletions
-11
View File
@@ -34,14 +34,3 @@ updates:
labels: labels:
- "dependencies" - "dependencies"
- "github-actions" - "github-actions"
# WASM JavaScript package
- package-ecosystem: "npm"
directory: "/wasm"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "javascript"
+59 -127
View File
@@ -87,7 +87,7 @@ jobs:
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v5 uses: actions/deploy-pages@v4
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# CI gate — all required jobs must pass before this job succeeds. # CI gate — all required jobs must pass before this job succeeds.
@@ -136,123 +136,88 @@ jobs:
# Keep release jobs here because trusted-publisher configuration points to # Keep release jobs here because trusted-publisher configuration points to
# CI.yml specifically. # CI.yml specifically.
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# One abi3 wheel per (platform, arch). abi3-py310 means a single wheel
# covers CPython 3.10+ on each target, so there is no python-version axis.
# extension-module + abi3 link no libpython, which is what lets the linux
# jobs cross-compile aarch64/musl from an x86_64 runner via maturin-action.
build-wheels-linux: build-wheels-linux:
name: Build wheels (linux-gnu / ${{ matrix.target }}) name: Build wheels (linux / py${{ matrix.python-version }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release) if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
target: [x86_64, aarch64] python-version: ["3.10", "3.11", "3.12", "3.13"]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Build abi3 manylinux wheel - name: Build wheel
uses: PyO3/maturin-action@v1 uses: PyO3/maturin-action@v1
with: with:
command: build command: build
target: ${{ matrix.target }} args: --release --out dist --compatibility pypi -i python${{ matrix.python-version }}
args: --release --out dist
manylinux: "2_17" manylinux: "2_17"
- name: Upload wheels as artifact - name: Upload wheels as artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: wheels-linux-gnu-${{ matrix.target }} name: wheels-linux-py${{ matrix.python-version }}
path: dist/*.whl
build-wheels-musllinux:
name: Build wheels (linux-musl / ${{ matrix.target }})
runs-on: ubuntu-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
strategy:
fail-fast: false
matrix:
target: [x86_64, aarch64]
steps:
- uses: actions/checkout@v6
- name: Build abi3 musllinux wheel
uses: PyO3/maturin-action@v1
with:
command: build
target: ${{ matrix.target }}
args: --release --out dist
manylinux: musllinux_1_2
- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: wheels-linux-musl-${{ matrix.target }}
path: dist/*.whl path: dist/*.whl
build-wheels-macos: build-wheels-macos:
name: Build wheels (macos / universal2) name: Build wheels (macos / py${{ matrix.python-version }})
runs-on: macos-latest runs-on: macos-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release) if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.10"
- name: Build universal2 abi3 wheel
uses: PyO3/maturin-action@v1
with:
command: build
target: universal2-apple-darwin
args: --release --out dist
- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: wheels-macos-universal2
path: dist/*.whl
build-wheels-windows:
name: Build wheels (windows / ${{ matrix.platform.arch }})
runs-on: ${{ matrix.platform.runner }}
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
platform: python-version: ["3.10", "3.11", "3.12", "3.13"]
- runner: windows-latest
arch: x64
target: x64
- runner: windows-11-arm
arch: arm64
target: aarch64
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Set up Python - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: "3.11" python-version: ${{ matrix.python-version }}
architecture: ${{ matrix.platform.arch }}
- name: Build abi3 wheel - name: Build universal2 wheel
uses: PyO3/maturin-action@v1 uses: PyO3/maturin-action@v1
with: with:
command: build command: build
target: ${{ matrix.platform.target }} args: --release --out dist --compatibility pypi -i python
args: --release --out dist target: universal2-apple-darwin
- name: Upload wheels as artifact - name: Upload wheels as artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: wheels-windows-${{ matrix.platform.arch }} name: wheels-macos-py${{ matrix.python-version }}
path: dist/*.whl
build-wheels-windows:
name: Build wheels (windows / py${{ matrix.python-version }})
runs-on: windows-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Build wheel
uses: PyO3/maturin-action@v1
with:
command: build
args: --release --out dist --compatibility pypi -i python
- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: wheels-windows-py${{ matrix.python-version }}
path: dist/*.whl path: dist/*.whl
build-sdist: build-sdist:
@@ -282,7 +247,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- build-wheels-linux - build-wheels-linux
- build-wheels-musllinux
- build-wheels-macos - build-wheels-macos
- build-wheels-windows - build-wheels-windows
- build-sdist - build-sdist
@@ -292,8 +256,6 @@ jobs:
url: https://pypi.org/p/ferro-ta url: https://pypi.org/p/ferro-ta
permissions: permissions:
id-token: write id-token: write
attestations: write
contents: read
steps: steps:
- name: Download all wheels - name: Download all wheels
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
@@ -308,11 +270,6 @@ jobs:
name: sdist name: sdist
path: dist path: dist
- name: Generate SLSA build provenance attestations
uses: actions/attest-build-provenance@v2
with:
subject-path: "dist/*"
- name: Verify distribution coverage - name: Verify distribution coverage
run: | run: |
python3 - <<'PY' python3 - <<'PY'
@@ -326,15 +283,19 @@ jobs:
for name in files: for name in files:
print(f" - {name}") print(f" - {name}")
# One abi3 wheel (cp310-abi3) per platform/arch — covers CPython 3.10+.
expected = [ expected = [
"ferro_ta-*-cp310-abi3-manylinux*_x86_64.whl", "ferro_ta-*-cp310-cp310-manylinux*_x86_64.whl",
"ferro_ta-*-cp310-abi3-manylinux*_aarch64.whl", "ferro_ta-*-cp311-cp311-manylinux*_x86_64.whl",
"ferro_ta-*-cp310-abi3-musllinux*_x86_64.whl", "ferro_ta-*-cp312-cp312-manylinux*_x86_64.whl",
"ferro_ta-*-cp310-abi3-musllinux*_aarch64.whl", "ferro_ta-*-cp313-cp313-manylinux*_x86_64.whl",
"ferro_ta-*-cp310-abi3-macosx*_universal2.whl", "ferro_ta-*-cp310-cp310-win_amd64.whl",
"ferro_ta-*-cp310-abi3-win_amd64.whl", "ferro_ta-*-cp311-cp311-win_amd64.whl",
"ferro_ta-*-cp310-abi3-win_arm64.whl", "ferro_ta-*-cp312-cp312-win_amd64.whl",
"ferro_ta-*-cp313-cp313-win_amd64.whl",
"ferro_ta-*-cp310-cp310-macosx*_universal2.whl",
"ferro_ta-*-cp311-cp311-macosx*_universal2.whl",
"ferro_ta-*-cp312-cp312-macosx*_universal2.whl",
"ferro_ta-*-cp313-cp313-macosx*_universal2.whl",
"ferro_ta-*.tar.gz", "ferro_ta-*.tar.gz",
] ]
@@ -385,7 +346,6 @@ jobs:
permissions: permissions:
contents: write contents: write
id-token: write id-token: write
attestations: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -420,40 +380,12 @@ jobs:
- name: Generate Rust SBOM (CycloneDX) - name: Generate Rust SBOM (CycloneDX)
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: Validate SBOMs (schema check)
run: |
python3 -c "import json, sys; json.load(open('ferro-ta-python-sbom.spdx.json')); print('Python SBOM valid JSON')"
python3 -c "import json, sys; data=json.load(open('ferro-ta-rust-sbom.cdx.json')); assert data.get('bomFormat')=='CycloneDX', 'not CycloneDX'; print('Rust SBOM valid CycloneDX')"
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign SBOMs with cosign (keyless)
env:
COSIGN_EXPERIMENTAL: "1"
run: |
cosign sign-blob --yes ferro-ta-python-sbom.spdx.json --output-signature ferro-ta-python-sbom.spdx.json.sig --output-certificate ferro-ta-python-sbom.spdx.json.pem
cosign sign-blob --yes ferro-ta-rust-sbom.cdx.json --output-signature ferro-ta-rust-sbom.cdx.json.sig --output-certificate ferro-ta-rust-sbom.cdx.json.pem
- name: Attest SBOM provenance
uses: actions/attest-build-provenance@v2
with:
subject-path: |
ferro-ta-python-sbom.spdx.json
ferro-ta-rust-sbom.cdx.json
- 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: | files: ferro-ta-python-sbom.spdx.json
ferro-ta-python-sbom.spdx.json
ferro-ta-python-sbom.spdx.json.sig
ferro-ta-python-sbom.spdx.json.pem
- 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: | files: ferro-ta-rust-sbom.cdx.json
ferro-ta-rust-sbom.cdx.json
ferro-ta-rust-sbom.cdx.json.sig
ferro-ta-rust-sbom.cdx.json.pem
+1 -1
View File
@@ -44,6 +44,6 @@ jobs:
- name: Upload GitHub Pages artifact - name: Upload GitHub Pages artifact
if: ${{ inputs.upload-pages-artifact }} if: ${{ inputs.upload-pages-artifact }}
uses: actions/upload-pages-artifact@v5 uses: actions/upload-pages-artifact@v4
with: with:
path: docs/_build/ path: docs/_build/
+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
-100
View File
@@ -1,100 +0,0 @@
name: Nightly benchmarks
on:
schedule:
# 03:15 UTC every day — off peak, avoids colliding with the regular CI surge.
- cron: "15 3 * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
bench:
name: Run perf contract and regression checks
runs-on: ubuntu-latest
timeout-minutes: 60
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install uv
run: pip install uv
- name: Build ferro-ta wheel
run: |
uv sync --extra dev
uv run maturin build --release --out dist
uv run pip install --force-reinstall dist/*.whl
- name: Run vs-TA-Lib benchmark
run: uv run python benchmarks/bench_vs_talib.py --output benchmarks/artifacts/nightly_vs_talib.json
- name: Run SIMD benchmark matrix
run: uv run python benchmarks/bench_simd.py --output benchmarks/artifacts/nightly_simd.json
- name: Check hotspot regression
id: hotspot
continue-on-error: true
run: uv run python benchmarks/check_hotspot_regression.py --tolerance 0.05
- name: Check vs-TA-Lib regression
id: vs_talib
continue-on-error: true
run: uv run python benchmarks/check_vs_talib_regression.py --tolerance 0.05
- name: Run criterion bench (build only)
run: cargo bench -p ferro_ta_core --no-run
- name: Upload nightly artifacts
uses: actions/upload-artifact@v7
if: always()
with:
name: nightly-bench-${{ github.run_id }}
path: benchmarks/artifacts/
retention-days: 30
- name: Open issue on regression
if: steps.hotspot.outcome == 'failure' || steps.vs_talib.outcome == 'failure'
uses: actions/github-script@v9
env:
HOTSPOT_OUTCOME: ${{ steps.hotspot.outcome }}
VS_TALIB_OUTCOME: ${{ steps.vs_talib.outcome }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const hotspot = process.env.HOTSPOT_OUTCOME;
const vsTalib = process.env.VS_TALIB_OUTCOME;
const runUrl = process.env.RUN_URL;
const failed = [];
if (hotspot === 'failure') failed.push('hotspot');
if (vsTalib === 'failure') failed.push('vs-TA-Lib');
const title = `Nightly benchmark regression: ${failed.join(' + ')}`;
const body = [
`The nightly benchmark workflow reported a >5% regression in: **${failed.join(', ')}**.`,
'',
`Run: ${runUrl}`,
'',
'Artifacts with the raw JSON are attached to the run above.',
'If this is a known regression, update the baseline JSON and close.',
'If it is unexpected, investigate the last commit on `main` before the nightly ran.',
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['performance', 'regression', 'nightly'],
});
+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 }}
-68
View File
@@ -9,74 +9,6 @@ and the project uses [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added
- **Runtime CPU-feature dispatch** for SIMD hot paths via the `multiversion`
crate (`ferro_ta_core/src/simd.rs`). One binary now selects baseline /
AVX2-FMA / AVX-512 / NEON kernels at load time via CPUID instead of being
pinned at build time, so it runs on any CPU of the target architecture with
no illegal-instruction crashes on older chips. The `simd` feature is on by
default; `--no-default-features` gives a pure-scalar build.
- **Broader wheel coverage**: release builds now also produce Linux `aarch64`
(manylinux) and `musllinux` (x86_64 + aarch64) wheels and a Windows `arm64`
wheel, alongside the existing Linux x86_64, macOS universal2, and Windows
x64 wheels.
- **abi3 wheels** (`cp310-abi3`): a single stable-ABI wheel per platform now
covers CPython 3.10+ (including future 3.14+), replacing the per-version
wheel matrix.
- **Dynamic Time Warping** (`DTW`, `DTW_DISTANCE`, `BATCH_DTW`): Euclidean-cost
DTW with optional Sakoe-Chiba band (`window=` parameter). `DTW()` returns
`(distance, path)` with the optimal warping path as an `(N, 2)` index array;
`DTW_DISTANCE()` is the faster distance-only variant; `BATCH_DTW()` computes
distances from each row of a 2-D matrix to a reference series in parallel
via rayon. Distance convention matches `dtaidistance.dtw.distance()`.
- **Indicator-specific exception types** (`FerroTaError`, `InvalidPeriodError`,
`InsufficientDataError`, `LengthMismatchError`, `NumericConvergenceError`,
`InvalidInputError`): finer-grained errors for catching specific failure
modes. All subclass `ValueError` so existing `except ValueError` code keeps
working (backward compatible).
### Changed
- **SIMD is now enabled by default and runtime-dispatched.** Replaced the
compile-time `wide` crate (which was never actually enabled in published
wheels) with `multiversion`. The `wide` Cargo feature is removed; use the
default `simd` feature instead.
- **Dependency bumps.** Rust: `log` 0.4.32, `serde_json` 1.0.150,
`rayon` 1.12.0. API service (`api/requirements.txt`): `uvicorn>=0.49.0`,
`pydantic>=2.13.4`, `ferro-ta>=1.1.4`. CI actions: `actions/deploy-pages` v5,
`actions/upload-pages-artifact` v5, `softprops/action-gh-release` v3.
- Python coverage threshold raised from 65% to 80% and enforced in CI.
### Fixed
- **aarch64 Linux containers can now install ferro-ta.** Previously no Linux
`aarch64` wheel was published, so arm64 images (e.g. AWS Graviton) fell back
to an sdist build that failed without a Rust toolchain. A manylinux/musllinux
aarch64 wheel is now published.
- Published wheels now actually ship SIMD-accelerated kernels; the prior build
enabled no SIMD feature at all.
### Security
- **SLSA build provenance** attestations are now generated for every PyPI
wheel and sdist via `actions/attest-build-provenance`. Verify with
`gh attestation verify <wheel>`.
- **Sigstore keyless signatures** are now published alongside both
CycloneDX/SPDX SBOMs on every GitHub Release (`.sig` + `.pem` files).
- `ferro_ta_core` crate now declares `#![forbid(unsafe_code)]` to prevent
regression — the pure-logic layer has no unsafe code and never will.
- Dependabot now covers the WASM npm package in addition to pip, cargo,
and GitHub Actions.
- **pip-audit fixes**: bumped dev lockfile deps `idna` 3.18, `pytest` 9.1.1,
and `urllib3` 2.7.0 to clear PYSEC-2026-215, CVE-2025-71176, and
PYSEC-2026-141/142.
- **pyo3 advisories triaged**: RUSTSEC-2026-0176 and RUSTSEC-2026-0177 are
ignored in `deny.toml` with rationale — ferro-ta uses neither affected code
path (PyList/PyTuple `nth` iterators; `PyCFunction::new_closure`). The
upstream fix requires pyo3 >=0.29 (a large API migration), tracked for a
follow-up.
## [1.1.3] — 2026-04-02 ## [1.1.3] — 2026-04-02
### Added ### Added
Generated
+58 -61
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",
] ]
@@ -53,6 +53,12 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]] [[package]]
name = "cast" name = "cast"
version = "0.3.0" version = "0.3.0"
@@ -61,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",
@@ -201,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",
@@ -216,12 +222,12 @@ dependencies = [
[[package]] [[package]]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.1.4" version = "1.1.3"
dependencies = [ dependencies = [
"criterion", "criterion",
"multiversion",
"serde", "serde",
"serde_json", "serde_json",
"wide",
] ]
[[package]] [[package]]
@@ -273,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",
@@ -283,15 +289,15 @@ 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"
version = "0.4.32" version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]] [[package]]
name = "matrixmultiply" name = "matrixmultiply"
@@ -318,28 +324,6 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "multiversion"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
dependencies = [
"multiversion-macros",
"target-features",
]
[[package]]
name = "multiversion-macros"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"target-features",
]
[[package]] [[package]]
name = "ndarray" name = "ndarray"
version = "0.16.1" version = "0.16.1"
@@ -562,9 +546,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.12.0" version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [ dependencies = [
"either", "either",
"rayon-core", "rayon-core",
@@ -611,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"
@@ -621,6 +605,15 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "safe_arch"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -662,9 +655,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.150" version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -696,12 +689,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226" checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
[[package]]
name = "target-features"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.13.5" version = "0.13.5"
@@ -742,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",
@@ -755,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",
@@ -765,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",
@@ -778,23 +765,33 @@ 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",
] ]
[[package]]
name = "wide"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "198f6abc41fab83526d10880fa5c17e2b4ee44e763949b4bb34e2fd1e8ca48e4"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -843,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",
+4 -12
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"
@@ -22,9 +22,7 @@ name = "ferro_ta"
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [dependencies]
# abi3-py310: build a single stable-ABI wheel that runs on CPython 3.10+ pyo3 = { version = "0.25", features = ["extension-module"] }
# (including future 3.14+), instead of one wheel per minor version.
pyo3 = { version = "0.25", features = ["extension-module", "abi3-py310"] }
ta = "0.5.0" ta = "0.5.0"
numpy = "0.25" numpy = "0.25"
# Must be < 0.17 while numpy 0.25 is used (numpy's IntoPyArray is for its own ndarray only). # Must be < 0.17 while numpy 0.25 is used (numpy's IntoPyArray is for its own ndarray only).
@@ -32,11 +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"
# default-features = false so the `simd` toggle is forwarded explicitly via ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.3", features = ["serde"] }
# this crate's own `simd` feature (below). Without this, core's default `simd`
# would always be on and `--no-default-features` could never produce a true
# pure-scalar build (used by the SIMD benchmark baseline).
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.4", default-features = false, features = ["serde"] }
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] } criterion = { version = "0.8", features = ["html_reports"] }
@@ -46,7 +40,5 @@ lto = true
codegen-units = 1 codegen-units = 1
[features] [features]
# SIMD runtime dispatch ON by default → published wheels ship the adaptive default = []
# fast path with no extra flags. `--no-default-features` yields pure scalar.
default = ["simd"]
simd = ["ferro_ta_core/simd"] simd = ["ferro_ta_core/simd"]
+7 -22
View File
@@ -8,36 +8,21 @@
# #
# Environment variables (override at runtime): # Environment variables (override at runtime):
# MAX_SERIES_LENGTH=100000 # maximum data-point count per request # MAX_SERIES_LENGTH=100000 # maximum data-point count per request
#
# CPU portability
# ---------------
# This image installs the PRE-BUILT ferro-ta wheel from PyPI — we do NOT
# recompile from sdist with `RUSTFLAGS=-C target-cpu=...`. The wheel is built
# at the manylinux baseline (x86-64-v1) and selects AVX2/AVX-512/NEON kernels
# at RUNTIME via CPU dispatch. One image therefore runs on any node — old or
# new CPU, x86_64 or arm64 — with no illegal-instruction (SIGILL) crashes.
# Pinning a target-cpu would be faster on a uniform fleet but would crash on
# any older/heterogeneous node, which is the opposite of broad coverage.
#
# Build this image for whichever arch your nodes use:
# docker build --platform linux/amd64 -t ferro-ta-api .
# docker build --platform linux/arm64 -t ferro-ta-api . # Graviton/Ampere
# Both resolve a matching manylinux wheel — no Rust toolchain needed here.
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Copy and install dependencies first (cache layer). No compiler is needed: # Install system dependencies required to build ferro_ta (Rust is pre-compiled
# ferro-ta, numpy, and pydantic-core all ship prebuilt wheels for linux # into the wheel, so only pip + wheel tooling is needed at runtime).
# x86_64 and aarch64. RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy and install dependencies first (cache layer)
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Fail the build immediately if the wheel did not resolve for this arch
# (e.g. an exotic platform that fell back to an sdist build without Rust).
RUN python -c "import ferro_ta, numpy as np; ferro_ta.SMA(np.arange(10.0), 3); print('ferro_ta', ferro_ta.__version__, 'import OK')"
# Copy API source # Copy API source
COPY main.py ./ COPY main.py ./
+3 -3
View File
@@ -1,6 +1,6 @@
# Runtime dependencies for ferro-ta API # Runtime dependencies for ferro-ta API
ferro_ta>=1.1.4 ferro_ta>=1.0.0
fastapi>=0.110.0 fastapi>=0.110.0
uvicorn[standard]>=0.49.0 uvicorn[standard]>=0.27.0
pydantic>=2.13.4 pydantic>=2.0.0
numpy>=1.20 numpy>=1.20
+1 -4
View File
@@ -55,11 +55,8 @@ def run_simd_benchmark(
iv_bars: int = 50_000, iv_bars: int = 50_000,
window: int = 252, window: int = 252,
) -> dict[str, Any]: ) -> dict[str, Any]:
# `simd` is a default feature, so a pure-scalar baseline must explicitly
# opt out via --no-default-features; otherwise both builds would be
# identical and every reported speedup would collapse to 1.0.
variants = [ variants = [
("portable_release", ["--no-default-features"]), ("portable_release", []),
("simd_release", ["--features", "simd"]), ("simd_release", ["--features", "simd"]),
] ]
reports = { 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 }}
+4 -10
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"
@@ -16,7 +16,7 @@ name = "ferro_ta_core"
crate-type = ["lib"] crate-type = ["lib"]
[dependencies] [dependencies]
multiversion = { version = "0.8", optional = true } wide = { version = "1.1.1", optional = true }
serde = { version = "1.0", features = ["derive"], optional = true } serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true } serde_json = { version = "1.0", optional = true }
@@ -28,12 +28,6 @@ name = "indicators"
harness = false harness = false
[features] [features]
# Runtime CPU-feature dispatch (multiversion). Default ON so `cargo add wide = ["dep:wide"]
# ferro_ta_core` and the published wheels get SIMD-accelerated reductions simd = ["wide"]
# that adapt to the running CPU (baseline .. AVX-512 / NEON) WITHOUT pinning
# a target-cpu — one binary runs on any CPU of the target arch, with no
# illegal-instruction crashes on older chips. Disable with
# `--no-default-features` for a pure-scalar build.
default = ["simd"]
simd = ["dep:multiversion"]
serde = ["dep:serde", "dep:serde_json"] serde = ["dep:serde", "dep:serde_json"]
+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
-4
View File
@@ -1,5 +1,3 @@
#![forbid(unsafe_code)]
/*! /*!
ferro_ta_core Pure Rust indicator library. ferro_ta_core Pure Rust indicator library.
@@ -51,8 +49,6 @@ pub mod price_transform;
pub mod regime; pub mod regime;
pub mod resampling; pub mod resampling;
pub mod signals; pub mod signals;
/// Runtime-dispatched SIMD reduction primitives (internal).
pub(crate) mod simd;
pub mod statistic; pub mod statistic;
pub mod streaming; pub mod streaming;
pub mod volatility; pub mod volatility;
+61 -7
View File
@@ -38,10 +38,27 @@ pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: u
return; return;
} }
// Seed the rolling window with a runtime-dispatched reduction. The O(n) #[cfg(feature = "simd")]
// streaming recurrence below is inherently sequential, so SIMD only ever let window_sum_init = {
// applies to this initial window sum. use wide::f64x4;
let mut window_sum = crate::simd::sum(&src[..timeperiod]); let p_data = &src[..timeperiod];
let mut sum = f64x4::splat(0.0);
let mut chunks = p_data.chunks_exact(4);
for chunk in &mut chunks {
sum += f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
}
let arr = sum.to_array();
let mut total = arr[0] + arr[1] + arr[2] + arr[3];
for &v in chunks.remainder() {
total += v;
}
total
};
#[cfg(not(feature = "simd"))]
let window_sum_init: f64 = src[..timeperiod].iter().sum();
let mut window_sum = window_sum_init;
let tp_f64 = timeperiod as f64; let tp_f64 = timeperiod as f64;
dest[dest_offset + timeperiod - 1] = window_sum / tp_f64; dest[dest_offset + timeperiod - 1] = window_sum / tp_f64;
@@ -107,9 +124,46 @@ pub fn wma(close: &[f64], timeperiod: usize) -> Vec<f64> {
let denom: f64 = (timeperiod * (timeperiod + 1) / 2) as f64; let denom: f64 = (timeperiod * (timeperiod + 1) / 2) as f64;
let p = timeperiod as f64; let p = timeperiod as f64;
// Seed: compute T and S for the first window via a runtime-dispatched // Seed: compute T and S for the first window.
// reduction (the streaming recurrence below is sequential). #[cfg(feature = "simd")]
let (mut t, mut s) = crate::simd::wma_seed(&close[..timeperiod]); let (mut t, mut s) = {
use wide::f64x4;
let p_data = &close[..timeperiod];
let mut t_simd = f64x4::splat(0.0);
let mut s_simd = f64x4::splat(0.0);
let mut chunks = p_data.chunks_exact(4);
let mut idx = 1.0;
let step = f64x4::new([0.0, 1.0, 2.0, 3.0]);
for chunk in &mut chunks {
let vals = f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
let mults = f64x4::splat(idx) + step;
t_simd += vals * mults;
s_simd += vals;
idx += 4.0;
}
let t_arr = t_simd.to_array();
let s_arr = s_simd.to_array();
let mut t = t_arr[0] + t_arr[1] + t_arr[2] + t_arr[3];
let mut s = s_arr[0] + s_arr[1] + s_arr[2] + s_arr[3];
for &v in chunks.remainder() {
t += v * idx;
s += v;
idx += 1.0;
}
(t, s)
};
#[cfg(not(feature = "simd"))]
let (mut t, mut s) = {
let t_val: f64 = close[..timeperiod]
.iter()
.enumerate()
.map(|(k, &v)| v * (k + 1) as f64)
.sum();
let s_val: f64 = close[..timeperiod].iter().sum();
(t_val, s_val)
};
result[timeperiod - 1] = t / denom; result[timeperiod - 1] = t / denom;
-161
View File
@@ -1,161 +0,0 @@
//! Runtime-dispatched SIMD primitives.
//!
//! Each public reduction here is compiled into several CPU-feature-specific
//! variants (baseline, SSE, AVX2/FMA, AVX-512 on x86_64; NEON on aarch64; …)
//! by [`multiversion`]. The fastest variant the *current* CPU supports is
//! chosen at runtime via CPUID. This gives one binary that:
//!
//! * runs on **any** CPU of the target architecture — no illegal-instruction
//! (SIGILL) crashes on pre-AVX2 chips, unlike a static `-C target-cpu=…`;
//! * still uses wide vector units where the hardware has them.
//!
//! The hot loops accumulate into **independent lanes** before a final
//! horizontal combine. That is what lets the optimizer auto-vectorize them:
//! a plain sequential `iter().sum()` is a dependency chain LLVM may not
//! reorder (doing so would change floating-point rounding). As a consequence
//! these results differ from a strict left-to-right sum by a few ULPs — well
//! inside every indicator's documented tolerance.
/// Number of independent accumulator lanes. Eight `f64` lanes cover the
/// widest target we dispatch to (AVX-512 = 8×f64); narrower targets (AVX2,
/// NEON) simply use a subset.
#[cfg(feature = "simd")]
const LANES: usize = 8;
/// Sum of a slice of `f64`, runtime-dispatched.
#[cfg(feature = "simd")]
#[multiversion::multiversion(targets = "simd")]
pub(crate) fn sum(data: &[f64]) -> f64 {
let mut acc = [0.0f64; LANES];
let mut chunks = data.chunks_exact(LANES);
for chunk in &mut chunks {
for (a, &v) in acc.iter_mut().zip(chunk) {
*a += v;
}
}
let remainder: f64 = chunks.remainder().iter().sum();
remainder + acc.iter().sum::<f64>()
}
/// Pure-scalar fallback when the `simd` feature is disabled.
#[cfg(not(feature = "simd"))]
pub(crate) fn sum(data: &[f64]) -> f64 {
data.iter().sum()
}
/// Weighted-moving-average seed for the first window.
///
/// Returns `(t, s)` where `t = Σ data[k] * (k + 1)` (1-based linear weights)
/// and `s = Σ data[k]`. Used to seed the O(n) WMA recurrence.
#[cfg(feature = "simd")]
#[multiversion::multiversion(targets = "simd")]
pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) {
// Lane-local accumulation (same idea as `sum`) so each CPU-feature clone
// can vectorize: `t` weights each value by its 1-based global index.
let mut t_acc = [0.0f64; LANES];
let mut s_acc = [0.0f64; LANES];
let mut chunks = data.chunks_exact(LANES);
let mut base = 0.0f64; // global index of this chunk's first element
for chunk in &mut chunks {
for (lane, ((t, s), &v)) in t_acc
.iter_mut()
.zip(s_acc.iter_mut())
.zip(chunk)
.enumerate()
{
*t += v * (base + lane as f64 + 1.0);
*s += v;
}
base += LANES as f64;
}
let mut t = 0.0;
let mut s = 0.0;
for (i, &v) in chunks.remainder().iter().enumerate() {
t += v * (base + i as f64 + 1.0);
s += v;
}
(t + t_acc.iter().sum::<f64>(), s + s_acc.iter().sum::<f64>())
}
/// Pure-scalar fallback when the `simd` feature is disabled.
#[cfg(not(feature = "simd"))]
pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) {
let mut t = 0.0;
let mut s = 0.0;
for (k, &v) in data.iter().enumerate() {
t += v * (k + 1) as f64;
s += v;
}
(t, s)
}
#[cfg(test)]
mod tests {
use super::*;
/// Strict sequential reference — the ground truth we compare against.
fn naive_sum(data: &[f64]) -> f64 {
data.iter().sum()
}
fn naive_wma_seed(data: &[f64]) -> (f64, f64) {
let t = data
.iter()
.enumerate()
.map(|(k, &v)| v * (k + 1) as f64)
.sum();
let s = data.iter().sum();
(t, s)
}
/// Deterministic test vectors spanning the lane boundaries: empty, a
/// partial chunk (< LANES), an exact multiple, and an exact-multiple +
/// remainder. This exercises every branch of the chunked reduction.
fn cases() -> Vec<Vec<f64>> {
let big: Vec<f64> = (0..1000).map(|i| (i as f64) * 0.5 - 123.0).collect();
vec![
vec![],
vec![42.0],
vec![1.0, 2.0, 3.0], // < LANES
(1..=8).map(|i| i as f64).collect(), // exactly LANES
(1..=17).map(|i| i as f64).collect(), // LANES*2 + 1
big,
]
}
#[test]
fn sum_matches_sequential_within_tolerance() {
for data in cases() {
let got = sum(&data);
let want = naive_sum(&data);
assert!(
(got - want).abs() <= 1e-9 * want.abs().max(1.0),
"sum mismatch: got {got}, want {want}, len {}",
data.len()
);
}
}
#[test]
fn wma_seed_matches_sequential_within_tolerance() {
for data in cases() {
let (t, s) = wma_seed(&data);
let (wt, ws) = naive_wma_seed(&data);
assert!(
(t - wt).abs() <= 1e-9 * wt.abs().max(1.0),
"wma t mismatch: got {t}, want {wt}, len {}",
data.len()
);
assert!(
(s - ws).abs() <= 1e-9 * ws.abs().max(1.0),
"wma s mismatch: got {s}, want {ws}, len {}",
data.len()
);
}
}
#[test]
fn sum_empty_is_zero() {
assert_eq!(sum(&[]), 0.0);
}
}
-230
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,122 +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_nan_in_input_propagates() {
// NaN in either input must propagate to the distance (IEEE 754 semantics).
let a = vec![1.0, 2.0, f64::NAN, 4.0];
let b = vec![1.0, 2.0, 3.0, 4.0];
assert!(dtw_distance(&a, &b, None).is_nan());
assert!(dtw_distance(&b, &a, None).is_nan());
}
#[test]
fn dtw_is_symmetric() {
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0, 6.0, 5.0];
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0, 1.0, 9.0];
let d_ab = dtw_distance(&a, &b, None);
let d_ba = dtw_distance(&b, &a, None);
assert!((d_ab - d_ba).abs() < 1e-12);
}
#[test]
fn dtw_path_length_bounded() {
// A valid warp path has length between max(n, m) and n + m - 1.
let a: Vec<f64> = (0..7).map(|x| x as f64).collect();
let b: Vec<f64> = (0..10).map(|x| (x as f64).sin()).collect();
let (_, path) = dtw_path(&a, &b, None);
let n = a.len();
let m = b.len();
assert!(path.len() >= n.max(m));
assert!(path.len() <= n + m - 1);
}
#[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);
}
} }
+1 -13
View File
@@ -53,19 +53,7 @@ skip = []
db-urls = ["https://github.com/rustsec/advisory-db"] db-urls = ["https://github.com/rustsec/advisory-db"]
# Deny known security vulnerabilities # Deny known security vulnerabilities
version = 2 version = 2
ignore = [ ignore = []
# pyo3 0.25 advisories — fix is pyo3 >=0.29, which is a large API
# migration (IntoPy/ToPyObject were removed in 0.26). Ignored here
# because ferro-ta does NOT use either affected code path:
# * RUSTSEC-2026-0176 — OOB read in PyList/PyTuple nth/nth_back
# iterators: the crate has zero PyList/PyTuple iterator usage.
# * RUSTSEC-2026-0177 — missing Sync bound on
# PyCFunction::new_closure: the crate uses #[pyfunction], never
# new_closure.
# Tracked for removal once the pyo3 0.29 upgrade lands.
"RUSTSEC-2026-0176",
"RUSTSEC-2026-0177",
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sources — only allow crates from crates.io and our own path deps # Sources — only allow crates from crates.io and our own path deps
+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)
------------------------ ------------------------
-80
View File
@@ -1,80 +0,0 @@
# Dynamic Time Warping
ferro-ta ships three DTW entry points. Pick the one that matches your
workload — the distance-only path is measurably faster than the one that
reconstructs the warping path, and `BATCH_DTW` parallelises over rows.
## Quick reference
| Function | Returns | When to use |
|---|---|---|
| `DTW_DISTANCE(a, b, window=None)` | `float` | You only need the distance. Fastest. |
| `DTW(a, b, window=None)` | `(float, ndarray[N, 2])` | You need the alignment path for plotting or downstream analysis. |
| `BATCH_DTW(matrix, reference, window=None)` | `ndarray[N]` | You have N candidate series and one reference; uses rayon. |
## Distance convention
ferro-ta's DTW uses squared-Euclidean local cost accumulated along the
optimal path, with a single `sqrt()` applied at the end. This matches
`dtaidistance.dtw.distance()` to within floating-point tolerance (parity
tests assert numerical agreement, not bitwise identity). Example:
```python
>>> import ferro_ta as fta
>>> fta.DTW_DISTANCE([0.0, 1.0, 2.0], [1.0, 2.0, 3.0])
1.4142135623730951 # == sqrt(2), same as dtaidistance
```
If you are migrating from a library that uses absolute-difference local
cost without the final sqrt (e.g. `fastdtw`'s default), your numbers will
not line up. That is a choice ferro-ta made for parity with the
scientific-Python ecosystem.
## Window constraint (Sakoe-Chiba band)
Passing `window=w` constrains the DP to cells where `|i - j| < w`. This
turns the O(n·m) cost into O(n·w), which is typically a 520× speedup for
realistic `w`. A narrower band can only *increase* the distance, so
`window=` is safe to use whenever your series are roughly aligned.
```python
# Unconstrained
fta.DTW_DISTANCE(a, b)
# Constrained: warping may shift up to 5 positions
fta.DTW_DISTANCE(a, b, window=5)
```
## Batch usage
`BATCH_DTW` compares each row of a 2-D matrix against one reference
series, in parallel:
```python
import numpy as np
import ferro_ta as fta
reference = np.random.random(500)
candidates = np.random.random((1000, 500))
distances = fta.BATCH_DTW(candidates, reference, window=20)
nearest = int(np.argmin(distances))
```
Parallelism is via rayon; no thread-pool configuration is needed on the
Python side. For the sequence lengths ferro-ta targets (thousands of
bars, hundreds to low thousands of candidates), batch-parallel classic
DTW beats FastDTW-style approximations.
## Edge cases
- **Empty input:** raises `FerroTAInputError`.
- **NaN in input:** propagates to the output (matches IEEE 754). Call
`ferro_ta.core.exceptions.check_finite()` first if you want to fail
loudly instead.
- **Different-length series:** fully supported. The path array length
is bounded by `max(n, m) <= len(path) <= n + m - 1`.
## See also
- `tests/unit/indicators/test_statistic.py` — parity tests against `dtaidistance`.
-92
View File
@@ -1,92 +0,0 @@
# SIMD acceleration
ferro-ta accelerates hot reductions with **runtime CPU-feature dispatch**
via the [`multiversion`](https://crates.io/crates/multiversion) crate. Each
dispatched function is compiled into several variants — baseline, SSE,
AVX2/FMA, AVX-512 on x86_64; NEON on aarch64 — and the fastest one the
**current** CPU supports is chosen at load time via CPUID.
## Why dispatch instead of `-C target-cpu`
A static `RUSTFLAGS=-C target-cpu=x86-64-v3` build *requires* AVX2 on the
running CPU; on an older chip it crashes with an illegal instruction
(SIGILL). Runtime dispatch instead ships every code path in one binary and
picks at runtime, so a single artifact:
- runs on **any** CPU of the target architecture (no SIGILL on pre-AVX2
hardware), and
- still uses wide vector units where the hardware has them.
That property is what lets the **same** wheel / Docker image / crate run
across a heterogeneous fleet.
## When it helps
SIMD helps indicators whose inner loop is a reduction over contiguous
`f64` data — e.g. the initial window sum that seeds SMA, the `(T, S)` seed
for WMA, and similar fixed-window reductions. It does **not** help:
- The O(n) streaming recurrences (`window_sum += new - old`): each step
depends on the previous one, so they are inherently sequential.
- Branchy inner loops (SAR, candlestick patterns).
- Streaming classes (a single-bar update is one or two ops).
The shared primitives live in `crates/ferro_ta_core/src/simd.rs`
(`sum`, `wma_seed`). They accumulate into independent lanes before a final
horizontal combine — that lane independence is what allows the optimizer to
vectorize each CPU-feature variant. A consequence is that results differ
from a strict left-to-right sum by a few ULPs, well inside every
indicator's documented tolerance.
## The `simd` feature
Dispatch is gated behind the `simd` Cargo feature, which is **on by
default**:
```bash
# default build — runtime dispatch enabled
cargo build -p ferro_ta_core --release
# pure-scalar build (debugging / baseline benchmarking)
cargo build -p ferro_ta_core --release --no-default-features
```
For Python, wheels published to PyPI are built with the default features,
so `pip install ferro-ta` ships the dispatched fast path with no action on
your part. To build a pure-scalar extension from source:
```bash
maturin develop --release --no-default-features
```
## Measured speedups
The nightly `benchmarks/bench_simd.py` job (see
`.github/workflows/nightly-bench.yml`) builds the extension twice — once
with `--no-default-features` (pure scalar) and once with `--features simd`
(dispatch) — and reports the per-indicator delta. Numbers are regenerated
on every run and vary with hardware; treat any table in a PR as a snapshot,
not a contract. The dispatched kernels here target correctness-preserving
reductions, so gains are modest on the sliding-window indicators and larger
on full-array reductions.
## Adding a SIMD-optimized indicator
1. Write and test the **scalar** implementation first — it is the ground
truth.
2. If the hot path is a contiguous `f64` reduction, route it through a
`crate::simd` primitive, or wrap a new helper in
`#[multiversion::multiversion(targets = "simd")]` with the loop body
accumulating into independent lanes.
3. Add a parity test comparing the dispatched result against the strict
scalar reference within tolerance (see `simd.rs` tests for the pattern).
4. Benchmark scalar vs dispatch via `bench_simd.py`. Only keep the SIMD
path if it wins — alignment and tail-handling overhead can make a naive
vectorization *lose* to scalar.
## See also
- `crates/ferro_ta_core/src/simd.rs` — dispatched primitives and tests.
- `benches/indicators.rs` — criterion suite.
- `crates/ferro_ta_core/Cargo.toml` `[features] simd = ["dep:multiversion"]`
— the gate (default-on).
+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>`_
+2 -2
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" }
@@ -84,7 +84,7 @@ omit = ["*/_ferro_ta*", "*/mcp/*"]
[tool.coverage.report] [tool.coverage.report]
show_missing = true show_missing = true
fail_under = 80 fail_under = 65
exclude_lines = [ exclude_lines = [
"pragma: no cover", "pragma: no cover",
"if TYPE_CHECKING:", "if TYPE_CHECKING:",
-6
View File
@@ -110,14 +110,8 @@ __version__ = _detect_version()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from ferro_ta.core.exceptions import ( # noqa: F401 from ferro_ta.core.exceptions import ( # noqa: F401
FerroTAError, FerroTAError,
FerroTaError,
FerroTAInputError, FerroTAInputError,
FerroTAValueError, FerroTAValueError,
InsufficientDataError,
InvalidInputError,
InvalidPeriodError,
LengthMismatchError,
NumericConvergenceError,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+4 -61
View File
@@ -131,63 +131,6 @@ class FerroTAInputError(FerroTAError, ValueError):
code = "FTERR002" code = "FTERR002"
# ---------------------------------------------------------------------------
# Finer-grained exception subclasses (added in 1.2.0).
#
# These are drop-in compatible with the base classes: every subclass still
# inherits from ``FerroTAError`` and ``ValueError``, so existing user code
# like ``except FerroTAValueError:`` or ``except ValueError:`` keeps working.
# The subclasses exist so users can catch *specific* failure modes without
# string-matching on the error message.
# ---------------------------------------------------------------------------
class InvalidPeriodError(FerroTAValueError):
"""Parameter like ``timeperiod``, ``fastperiod``, ``slowperiod`` is out of range.
Default error code: ``FTERR001``.
"""
class InsufficientDataError(FerroTAInputError):
"""Input array is shorter than the minimum required for the indicator.
Default error code: ``FTERR003``.
"""
code = "FTERR003"
class LengthMismatchError(FerroTAInputError):
"""Two or more input arrays (e.g. OHLC) have different lengths.
Default error code: ``FTERR004``.
"""
code = "FTERR004"
class NumericConvergenceError(FerroTAValueError):
"""An iterative calculation failed to converge within tolerance.
Raised by iterative pricing models (implied volatility root-finding,
Newton-Raphson, etc.) when the maximum iteration count is exhausted.
"""
class InvalidInputError(FerroTAInputError):
"""Input contains NaN/Inf in strict mode, wrong dtype, or wrong shape.
Default error code: ``FTERR005``.
"""
code = "FTERR005"
# Public aliases that match the names documented in the README and
# CHANGELOG [Unreleased] section.
FerroTaError = FerroTAError # type: ignore[misc]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Validation helpers (called by Python wrappers) # Validation helpers (called by Python wrappers)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -211,7 +154,7 @@ def check_timeperiod(value: int, name: str = "timeperiod", minimum: int = 1) ->
If ``value < minimum``. If ``value < minimum``.
""" """
if value < minimum: if value < minimum:
raise InvalidPeriodError( raise FerroTAValueError(
f"{name} must be >= {minimum}, got {value}", f"{name} must be >= {minimum}, got {value}",
suggestion=f"Set {name}={minimum} or higher.", suggestion=f"Set {name}={minimum} or higher.",
) )
@@ -250,7 +193,7 @@ def check_equal_length(**arrays: object) -> None:
if len(set(lengths.values())) > 1: if len(set(lengths.values())) > 1:
detail = ", ".join(f"{k}={v}" for k, v in lengths.items()) detail = ", ".join(f"{k}={v}" for k, v in lengths.items())
raise LengthMismatchError( raise FerroTAInputError(
f"All input arrays must have the same length. Got: {detail}", f"All input arrays must have the same length. Got: {detail}",
code=_CODE_LENGTH_MISMATCH, code=_CODE_LENGTH_MISMATCH,
suggestion="Trim or align your arrays so that open, high, low, close, and volume all have the same number of rows.", suggestion="Trim or align your arrays so that open, high, low, close, and volume all have the same number of rows.",
@@ -280,7 +223,7 @@ def check_finite(arr: object, name: str = "input") -> None:
a = np.asarray(arr, dtype=np.float64) a = np.asarray(arr, dtype=np.float64)
if not np.all(np.isfinite(a)): if not np.all(np.isfinite(a)):
raise InvalidInputError( raise FerroTAInputError(
f"{name} contains NaN or Inf values. " f"{name} contains NaN or Inf values. "
"ferro_ta propagates NaN by default; call check_finite() only " "ferro_ta propagates NaN by default; call check_finite() only "
"when you require all-finite inputs.", "when you require all-finite inputs.",
@@ -312,7 +255,7 @@ def check_min_length(arr: object, min_len: int, name: str = "input") -> None:
elif hasattr(arr, "shape"): elif hasattr(arr, "shape"):
length = arr.shape[0] # type: ignore[union-attr] length = arr.shape[0] # type: ignore[union-attr]
if length < min_len: if length < min_len:
raise InsufficientDataError( raise FerroTAInputError(
f"{name} must have at least {min_len} elements, got {length}", f"{name} must have at least {min_len} elements, got {length}",
code=_CODE_TOO_SHORT, code=_CODE_TOO_SHORT,
suggestion=f"Provide at least {min_len} data points. Current length: {length}.", suggestion=f"Provide at least {min_len} data points. Current length: {length}.",
-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
+10 -10
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" },
@@ -1272,11 +1272,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.18" version = "3.11"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]] [[package]]
@@ -2906,7 +2906,7 @@ wheels = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.1.1" version = "9.0.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -2917,9 +2917,9 @@ dependencies = [
{ name = "pygments" }, { name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tomli", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
] ]
[[package]] [[package]]
@@ -4261,11 +4261,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.7.0" version = "2.6.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
] ]
[[package]] [[package]]
+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
// =========================================================================== // ===========================================================================