Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2080e4673d |
@@ -34,14 +34,3 @@ updates:
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "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
@@ -87,7 +87,7 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 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
|
||||
# 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:
|
||||
name: Build wheels (linux-gnu / ${{ matrix.target }})
|
||||
name: Build wheels (linux / py${{ matrix.python-version }})
|
||||
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]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build abi3 manylinux wheel
|
||||
- name: Build wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: build
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --out dist
|
||||
args: --release --out dist --compatibility pypi -i python${{ matrix.python-version }}
|
||||
manylinux: "2_17"
|
||||
|
||||
- name: Upload wheels as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wheels-linux-gnu-${{ matrix.target }}
|
||||
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 }}
|
||||
name: wheels-linux-py${{ matrix.python-version }}
|
||||
path: dist/*.whl
|
||||
|
||||
build-wheels-macos:
|
||||
name: Build wheels (macos / universal2)
|
||||
name: Build wheels (macos / py${{ matrix.python-version }})
|
||||
runs-on: macos-latest
|
||||
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:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- runner: windows-latest
|
||||
arch: x64
|
||||
target: x64
|
||||
- runner: windows-11-arm
|
||||
arch: arm64
|
||||
target: aarch64
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
architecture: ${{ matrix.platform.arch }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Build abi3 wheel
|
||||
- name: Build universal2 wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: build
|
||||
target: ${{ matrix.platform.target }}
|
||||
args: --release --out dist
|
||||
args: --release --out dist --compatibility pypi -i python
|
||||
target: universal2-apple-darwin
|
||||
|
||||
- name: Upload wheels as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
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
|
||||
|
||||
build-sdist:
|
||||
@@ -282,7 +247,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-wheels-linux
|
||||
- build-wheels-musllinux
|
||||
- build-wheels-macos
|
||||
- build-wheels-windows
|
||||
- build-sdist
|
||||
@@ -292,8 +256,6 @@ jobs:
|
||||
url: https://pypi.org/p/ferro-ta
|
||||
permissions:
|
||||
id-token: write
|
||||
attestations: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Download all wheels
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -308,11 +270,6 @@ jobs:
|
||||
name: sdist
|
||||
path: dist
|
||||
|
||||
- name: Generate SLSA build provenance attestations
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: "dist/*"
|
||||
|
||||
- name: Verify distribution coverage
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
@@ -326,15 +283,19 @@ jobs:
|
||||
for name in files:
|
||||
print(f" - {name}")
|
||||
|
||||
# One abi3 wheel (cp310-abi3) per platform/arch — covers CPython 3.10+.
|
||||
expected = [
|
||||
"ferro_ta-*-cp310-abi3-manylinux*_x86_64.whl",
|
||||
"ferro_ta-*-cp310-abi3-manylinux*_aarch64.whl",
|
||||
"ferro_ta-*-cp310-abi3-musllinux*_x86_64.whl",
|
||||
"ferro_ta-*-cp310-abi3-musllinux*_aarch64.whl",
|
||||
"ferro_ta-*-cp310-abi3-macosx*_universal2.whl",
|
||||
"ferro_ta-*-cp310-abi3-win_amd64.whl",
|
||||
"ferro_ta-*-cp310-abi3-win_arm64.whl",
|
||||
"ferro_ta-*-cp310-cp310-manylinux*_x86_64.whl",
|
||||
"ferro_ta-*-cp311-cp311-manylinux*_x86_64.whl",
|
||||
"ferro_ta-*-cp312-cp312-manylinux*_x86_64.whl",
|
||||
"ferro_ta-*-cp313-cp313-manylinux*_x86_64.whl",
|
||||
"ferro_ta-*-cp310-cp310-win_amd64.whl",
|
||||
"ferro_ta-*-cp311-cp311-win_amd64.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",
|
||||
]
|
||||
|
||||
@@ -385,7 +346,6 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -420,40 +380,12 @@ jobs:
|
||||
- name: Generate Rust SBOM (CycloneDX)
|
||||
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
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
ferro-ta-python-sbom.spdx.json
|
||||
ferro-ta-python-sbom.spdx.json.sig
|
||||
ferro-ta-python-sbom.spdx.json.pem
|
||||
files: ferro-ta-python-sbom.spdx.json
|
||||
|
||||
- name: Upload Rust SBOM to release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
ferro-ta-rust-sbom.cdx.json
|
||||
ferro-ta-rust-sbom.cdx.json.sig
|
||||
ferro-ta-rust-sbom.cdx.json.pem
|
||||
files: ferro-ta-rust-sbom.cdx.json
|
||||
|
||||
@@ -44,6 +44,6 @@ jobs:
|
||||
|
||||
- name: Upload GitHub Pages artifact
|
||||
if: ${{ inputs.upload-pages-artifact }}
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: docs/_build/
|
||||
|
||||
@@ -7,157 +7,151 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# -------------------------------------------------------------------------
|
||||
# Lint — no Rust, no wheel build, very fast
|
||||
# -------------------------------------------------------------------------
|
||||
lint:
|
||||
name: Lint (ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
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/
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Type checking — needs wheel; build once with cache
|
||||
# -------------------------------------------------------------------------
|
||||
- name: Install uv
|
||||
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:
|
||||
name: Type checking (mypy + pyright)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
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
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Build wheel artifact (once per run, reused by all test matrix entries)
|
||||
# -------------------------------------------------------------------------
|
||||
build-wheel:
|
||||
name: Build wheel (ubuntu / Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
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
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Run mypy on ferro_ta via uv
|
||||
run: uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
|
||||
|
||||
- name: Run pyright on ferro_ta via uv
|
||||
run: uv run --with pyright python -m pyright python/ferro_ta
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test matrix — downloads pre-built wheel, no Rust compilation
|
||||
# -------------------------------------------------------------------------
|
||||
test:
|
||||
name: Test (Python ${{ matrix.python-version }})
|
||||
name: Test (ubuntu-latest / Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheel
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wheel-${{ matrix.python-version }}
|
||||
path: dist
|
||||
- name: Install wheel + test deps
|
||||
|
||||
- name: Install maturin and test dependencies
|
||||
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 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
|
||||
|
||||
- name: Check API manifest is current
|
||||
if: matrix.python-version == '3.12'
|
||||
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'
|
||||
with:
|
||||
name: coverage-python-${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Benchmark vs TA-Lib (downloads wheel from build-wheel job)
|
||||
# -------------------------------------------------------------------------
|
||||
benchmark-vs-talib:
|
||||
name: Benchmark vs TA-Lib
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheel
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wheel-3.12
|
||||
path: dist
|
||||
- name: Install TA-Lib C library
|
||||
|
||||
- name: Install TA-Lib C library (Ubuntu)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
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
|
||||
tar -xzf ta-lib-0.4.0-src.tar.gz
|
||||
cd ta-lib && ./configure --prefix=/usr && make && sudo make install && sudo ldconfig
|
||||
- run: pip install dist/*.whl numpy ta-lib
|
||||
- run: python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
- run: python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
|
||||
- uses: actions/upload-artifact@v7
|
||||
cd ta-lib
|
||||
./configure --prefix=/usr
|
||||
make
|
||||
sudo make install
|
||||
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:
|
||||
name: benchmark-vs-talib
|
||||
path: benchmark_vs_talib.json
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Performance smoke (downloads wheel from build-wheel job)
|
||||
# -------------------------------------------------------------------------
|
||||
perf-smoke:
|
||||
name: Performance smoke and contracts
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheel
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wheel-3.12
|
||||
path: dist
|
||||
- run: pip install dist/*.whl numpy pytest
|
||||
- run: |
|
||||
|
||||
- name: Install maturin and perf dependencies
|
||||
run: |
|
||||
pip install maturin numpy pytest
|
||||
|
||||
- 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 \
|
||||
--output-dir perf-contract \
|
||||
--skip-talib \
|
||||
@@ -167,8 +161,12 @@ jobs:
|
||||
--streaming-bars 20000 \
|
||||
--price-bars 20000 \
|
||||
--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:
|
||||
name: perf-contract
|
||||
path: perf-contract/
|
||||
|
||||
@@ -7,119 +7,102 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# -------------------------------------------------------------------------
|
||||
# cargo-deny — uses the official pre-built action (no cargo install needed)
|
||||
# -------------------------------------------------------------------------
|
||||
cargo-deny:
|
||||
name: cargo deny check
|
||||
audit:
|
||||
name: Dependency audit (cargo + pip)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# pip-audit + uv.lock freshness check (lightweight, no Rust needed)
|
||||
# -------------------------------------------------------------------------
|
||||
pip-audit:
|
||||
name: pip-audit + uv.lock check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
- name: Install cargo-audit and run cargo audit
|
||||
run: |
|
||||
cargo install cargo-audit
|
||||
cargo audit
|
||||
|
||||
- name: Install cargo-deny and run cargo deny check
|
||||
run: |
|
||||
cargo install cargo-deny --locked
|
||||
cargo deny check
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install uv
|
||||
- run: uv run --with pip-audit pip-audit --skip-editable
|
||||
- run: uv lock --check
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# fmt + clippy (with Rust cache so subsequent runs skip recompilation)
|
||||
# -------------------------------------------------------------------------
|
||||
rust-lint:
|
||||
name: Rust fmt + clippy
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Install pip-audit via uv and run pip-audit
|
||||
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
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --all -- --check
|
||||
- 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
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Build + test ferro_ta_core (with Rust cache)
|
||||
# -------------------------------------------------------------------------
|
||||
rust-core:
|
||||
name: Rust core (build + test)
|
||||
name: Rust core library (ferro_ta_core)
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
- run: cargo build -p ferro_ta_core
|
||||
- run: cargo test -p ferro_ta_core
|
||||
- name: Build core crate
|
||||
run: cargo build -p ferro_ta_core
|
||||
- name: Test core crate
|
||||
run: cargo test -p ferro_ta_core
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Coverage (optional, cached)
|
||||
# -------------------------------------------------------------------------
|
||||
rust-coverage:
|
||||
name: Rust coverage (tarpaulin)
|
||||
name: Rust coverage (tarpaulin, optional)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
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: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-tarpaulin
|
||||
- run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/
|
||||
- uses: actions/upload-artifact@v7
|
||||
- name: Install cargo-tarpaulin
|
||||
run: cargo install cargo-tarpaulin --locked
|
||||
- name: Collect Rust coverage (ferro_ta_core)
|
||||
run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/
|
||||
- name: Upload Rust coverage artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: rust-coverage
|
||||
path: coverage/
|
||||
if-no-files-found: ignore
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Fuzz (optional, nightly, cached)
|
||||
# -------------------------------------------------------------------------
|
||||
fuzz:
|
||||
name: Fuzz targets (short CI run)
|
||||
name: Fuzz targets (short CI run, optional)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
- name: Install cargo-fuzz
|
||||
run: cargo install cargo-fuzz --locked
|
||||
- name: Run fuzz_sma (10000 iterations)
|
||||
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)
|
||||
working-directory: fuzz
|
||||
run: cargo fuzz run fuzz_rsi --target x86_64-unknown-linux-gnu -- -runs=10000 -max_len=512
|
||||
- uses: actions/upload-artifact@v7
|
||||
run: cargo fuzz run fuzz_rsi -- -runs=10000 -max_len=512
|
||||
- name: Upload fuzz artifacts on crash
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: fuzz-artifacts
|
||||
|
||||
@@ -8,43 +8,54 @@ permissions:
|
||||
|
||||
jobs:
|
||||
wasm:
|
||||
name: WASM (test + build + bench)
|
||||
name: WASM binding (wasm-pack test --node)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
|
||||
- name: Install Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: wasm-pack
|
||||
- name: Test WASM binding
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
- name: Build and test WASM binding
|
||||
working-directory: wasm
|
||||
run: wasm-pack test --node
|
||||
- name: Build WASM packages (node + web)
|
||||
|
||||
- name: Build WASM package (both targets)
|
||||
working-directory: wasm
|
||||
run: npm run build
|
||||
|
||||
- name: Check API manifest is current
|
||||
run: python3 scripts/check_api_manifest.py
|
||||
- name: Benchmark WASM
|
||||
|
||||
- name: Benchmark WASM package
|
||||
working-directory: wasm
|
||||
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:
|
||||
name: wasm-pkg-node
|
||||
path: wasm/node/
|
||||
- uses: actions/upload-artifact@v7
|
||||
|
||||
- name: Upload WASM web package artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-pkg-web
|
||||
path: wasm/web/
|
||||
- uses: actions/upload-artifact@v7
|
||||
|
||||
- name: Upload WASM benchmark artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-benchmark
|
||||
path: wasm_benchmark.json
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
@@ -52,11 +51,10 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: v${{ steps.version.outputs.version }}
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
|
||||
@@ -9,74 +9,6 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+58
-61
@@ -34,9 +34,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -53,6 +53,12 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -61,9 +67,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.59"
|
||||
version = "1.2.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -201,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta"
|
||||
version = "1.1.4"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ferro_ta_core",
|
||||
@@ -216,12 +222,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.1.4"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"multiversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -273,9 +279,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.94"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
|
||||
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
@@ -283,15 +289,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
version = "0.2.183"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.32"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
@@ -318,28 +324,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ndarray"
|
||||
version = "0.16.1"
|
||||
@@ -562,9 +546,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
@@ -611,9 +595,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
@@ -621,6 +605,15 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -662,9 +655,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -696,12 +689,6 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
|
||||
|
||||
[[package]]
|
||||
name = "target-features"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
@@ -742,9 +729,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.117"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
|
||||
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -755,9 +742,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.117"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
|
||||
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -765,9 +752,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.117"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
|
||||
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -778,23 +765,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.117"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
|
||||
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.94"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
|
||||
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"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]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -843,18 +840,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
version = "0.8.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
version = "0.8.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+4
-12
@@ -5,7 +5,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "ferro_ta"
|
||||
version = "1.1.4"
|
||||
version = "1.1.3"
|
||||
edition = "2021"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
license = "MIT"
|
||||
@@ -22,9 +22,7 @@ name = "ferro_ta"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# abi3-py310: build a single stable-ABI wheel that runs on CPython 3.10+
|
||||
# (including future 3.14+), instead of one wheel per minor version.
|
||||
pyo3 = { version = "0.25", features = ["extension-module", "abi3-py310"] }
|
||||
pyo3 = { version = "0.25", features = ["extension-module"] }
|
||||
ta = "0.5.0"
|
||||
numpy = "0.25"
|
||||
# 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"
|
||||
log = "0.4"
|
||||
pyo3-log = "0.12"
|
||||
# default-features = false so the `simd` toggle is forwarded explicitly via
|
||||
# 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"] }
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.3", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
@@ -46,7 +40,5 @@ lto = true
|
||||
codegen-units = 1
|
||||
|
||||
[features]
|
||||
# SIMD runtime dispatch ON by default → published wheels ship the adaptive
|
||||
# fast path with no extra flags. `--no-default-features` yields pure scalar.
|
||||
default = ["simd"]
|
||||
default = []
|
||||
simd = ["ferro_ta_core/simd"]
|
||||
|
||||
+7
-22
@@ -8,36 +8,21 @@
|
||||
#
|
||||
# Environment variables (override at runtime):
|
||||
# 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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy and install dependencies first (cache layer). No compiler is needed:
|
||||
# ferro-ta, numpy, and pydantic-core all ship prebuilt wheels for linux
|
||||
# x86_64 and aarch64.
|
||||
# Install system dependencies required to build ferro_ta (Rust is pre-compiled
|
||||
# into the wheel, so only pip + wheel tooling is needed at runtime).
|
||||
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 ./
|
||||
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 main.py ./
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Runtime dependencies for ferro-ta API
|
||||
ferro_ta>=1.1.4
|
||||
ferro_ta>=1.0.0
|
||||
fastapi>=0.110.0
|
||||
uvicorn[standard]>=0.49.0
|
||||
pydantic>=2.13.4
|
||||
uvicorn[standard]>=0.27.0
|
||||
pydantic>=2.0.0
|
||||
numpy>=1.20
|
||||
|
||||
@@ -55,11 +55,8 @@ def run_simd_benchmark(
|
||||
iv_bars: int = 50_000,
|
||||
window: int = 252,
|
||||
) -> 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 = [
|
||||
("portable_release", ["--no-default-features"]),
|
||||
("portable_release", []),
|
||||
("simd_release", ["--features", "simd"]),
|
||||
]
|
||||
reports = {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{% set name = "ferro-ta" %}
|
||||
{% set version = "1.1.4" %}
|
||||
{% set version = "1.1.3" %}
|
||||
|
||||
package:
|
||||
name: {{ name|lower }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.1.4"
|
||||
version = "1.1.3"
|
||||
edition = "2021"
|
||||
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
|
||||
license = "MIT"
|
||||
@@ -16,7 +16,7 @@ name = "ferro_ta_core"
|
||||
crate-type = ["lib"]
|
||||
|
||||
[dependencies]
|
||||
multiversion = { version = "0.8", optional = true }
|
||||
wide = { version = "1.1.1", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
|
||||
@@ -28,12 +28,6 @@ name = "indicators"
|
||||
harness = false
|
||||
|
||||
[features]
|
||||
# Runtime CPU-feature dispatch (multiversion). Default ON so `cargo add
|
||||
# ferro_ta_core` and the published wheels get SIMD-accelerated reductions
|
||||
# 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"]
|
||||
wide = ["dep:wide"]
|
||||
simd = ["wide"]
|
||||
serde = ["dep:serde", "dep:serde_json"]
|
||||
|
||||
@@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ferro_ta_core = "1.1.4"
|
||||
ferro_ta_core = "1.1.3"
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/*!
|
||||
ferro_ta_core — Pure Rust indicator library.
|
||||
|
||||
@@ -51,8 +49,6 @@ pub mod price_transform;
|
||||
pub mod regime;
|
||||
pub mod resampling;
|
||||
pub mod signals;
|
||||
/// Runtime-dispatched SIMD reduction primitives (internal).
|
||||
pub(crate) mod simd;
|
||||
pub mod statistic;
|
||||
pub mod streaming;
|
||||
pub mod volatility;
|
||||
|
||||
@@ -38,10 +38,27 @@ pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: u
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed the rolling window with a runtime-dispatched reduction. The O(n)
|
||||
// streaming recurrence below is inherently sequential, so SIMD only ever
|
||||
// applies to this initial window sum.
|
||||
let mut window_sum = crate::simd::sum(&src[..timeperiod]);
|
||||
#[cfg(feature = "simd")]
|
||||
let window_sum_init = {
|
||||
use wide::f64x4;
|
||||
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;
|
||||
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 p = timeperiod as f64;
|
||||
|
||||
// Seed: compute T and S for the first window via a runtime-dispatched
|
||||
// reduction (the streaming recurrence below is sequential).
|
||||
let (mut t, mut s) = crate::simd::wma_seed(&close[..timeperiod]);
|
||||
// Seed: compute T and S for the first window.
|
||||
#[cfg(feature = "simd")]
|
||||
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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -247,118 +247,6 @@ pub fn correl(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -371,122 +259,4 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,19 +53,7 @@ skip = []
|
||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
# Deny known security vulnerabilities
|
||||
version = 2
|
||||
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",
|
||||
]
|
||||
ignore = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sources — only allow crates from crates.io and our own path deps
|
||||
|
||||
+7
-63
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"surfaces": {
|
||||
"python": {
|
||||
"indicator_count": 211,
|
||||
"method_count": 467,
|
||||
"indicator_count": 208,
|
||||
"method_count": 464,
|
||||
"categories": [
|
||||
"aggregation",
|
||||
"alerts",
|
||||
@@ -131,13 +131,6 @@
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "BATCH_DTW",
|
||||
"category": "statistic",
|
||||
"module": "ferro_ta.indicators.statistic",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "BBANDS",
|
||||
"category": "overlap",
|
||||
@@ -663,20 +656,6 @@
|
||||
"doc": "",
|
||||
"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",
|
||||
"category": "momentum",
|
||||
@@ -3304,13 +3283,6 @@
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "BATCH_DTW",
|
||||
"category": "statistic",
|
||||
"module": "ferro_ta.indicators.statistic",
|
||||
"doc": "",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"name": "BETA",
|
||||
"category": "statistic",
|
||||
@@ -3325,20 +3297,6 @@
|
||||
"doc": "",
|
||||
"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",
|
||||
"category": "statistic",
|
||||
@@ -4777,7 +4735,7 @@
|
||||
]
|
||||
},
|
||||
"rust_core": {
|
||||
"public_function_count": 351,
|
||||
"public_function_count": 349,
|
||||
"functions": [
|
||||
{
|
||||
"module": "aggregation",
|
||||
@@ -6229,16 +6187,6 @@
|
||||
"function": "correl",
|
||||
"file": "statistic.rs"
|
||||
},
|
||||
{
|
||||
"module": "statistic",
|
||||
"function": "dtw_distance",
|
||||
"file": "statistic.rs"
|
||||
},
|
||||
{
|
||||
"module": "statistic",
|
||||
"function": "dtw_path",
|
||||
"file": "statistic.rs"
|
||||
},
|
||||
{
|
||||
"module": "statistic",
|
||||
"function": "linearreg",
|
||||
@@ -6537,7 +6485,7 @@
|
||||
]
|
||||
},
|
||||
"wasm_node": {
|
||||
"export_count": 222,
|
||||
"export_count": 221,
|
||||
"exports": [
|
||||
"ad",
|
||||
"adosc",
|
||||
@@ -6598,7 +6546,6 @@
|
||||
"digital_price",
|
||||
"donchian",
|
||||
"drawdown_series",
|
||||
"dtw_distance",
|
||||
"dx",
|
||||
"early_exercise_premium",
|
||||
"ema",
|
||||
@@ -6765,9 +6712,9 @@
|
||||
}
|
||||
},
|
||||
"parity_summary": {
|
||||
"python_indicator_count": 210,
|
||||
"wasm_export_count": 222,
|
||||
"common_python_wasm_count": 92,
|
||||
"python_indicator_count": 207,
|
||||
"wasm_export_count": 221,
|
||||
"common_python_wasm_count": 91,
|
||||
"common_python_wasm": [
|
||||
"ad",
|
||||
"adosc",
|
||||
@@ -6796,7 +6743,6 @@
|
||||
"dema",
|
||||
"detect_breaks_cusum",
|
||||
"donchian",
|
||||
"dtw_distance",
|
||||
"dx",
|
||||
"ema",
|
||||
"ht_dcperiod",
|
||||
@@ -6871,7 +6817,6 @@
|
||||
"asin",
|
||||
"atan",
|
||||
"batch_apply",
|
||||
"batch_dtw",
|
||||
"beta",
|
||||
"cdl2crows",
|
||||
"cdl3blackcrows",
|
||||
@@ -6941,7 +6886,6 @@
|
||||
"cosh",
|
||||
"div",
|
||||
"drawdown",
|
||||
"dtw",
|
||||
"exp",
|
||||
"feature_matrix",
|
||||
"floor",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
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,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 5–20× 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`.
|
||||
@@ -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).
|
||||
@@ -180,7 +180,7 @@ For source builds, packaging details, and platform notes, see
|
||||
Release status
|
||||
--------------
|
||||
|
||||
These docs track package version ``1.1.4``.
|
||||
These docs track package version ``1.1.3``.
|
||||
|
||||
- Release notes by version: :doc:`changelog`
|
||||
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "ferro-ta"
|
||||
version = "1.1.4"
|
||||
version = "1.1.3"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
@@ -84,7 +84,7 @@ omit = ["*/_ferro_ta*", "*/mcp/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
fail_under = 80
|
||||
fail_under = 65
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
|
||||
@@ -110,14 +110,8 @@ __version__ = _detect_version()
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.core.exceptions import ( # noqa: F401
|
||||
FerroTAError,
|
||||
FerroTaError,
|
||||
FerroTAInputError,
|
||||
FerroTAValueError,
|
||||
InsufficientDataError,
|
||||
InvalidInputError,
|
||||
InvalidPeriodError,
|
||||
LengthMismatchError,
|
||||
NumericConvergenceError,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -131,63 +131,6 @@ class FerroTAInputError(FerroTAError, ValueError):
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -211,7 +154,7 @@ def check_timeperiod(value: int, name: str = "timeperiod", minimum: int = 1) ->
|
||||
If ``value < minimum``.
|
||||
"""
|
||||
if value < minimum:
|
||||
raise InvalidPeriodError(
|
||||
raise FerroTAValueError(
|
||||
f"{name} must be >= {minimum}, got {value}",
|
||||
suggestion=f"Set {name}={minimum} or higher.",
|
||||
)
|
||||
@@ -250,7 +193,7 @@ def check_equal_length(**arrays: object) -> None:
|
||||
|
||||
if len(set(lengths.values())) > 1:
|
||||
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}",
|
||||
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.",
|
||||
@@ -280,7 +223,7 @@ def check_finite(arr: object, name: str = "input") -> None:
|
||||
|
||||
a = np.asarray(arr, dtype=np.float64)
|
||||
if not np.all(np.isfinite(a)):
|
||||
raise InvalidInputError(
|
||||
raise FerroTAInputError(
|
||||
f"{name} contains NaN or Inf values. "
|
||||
"ferro_ta propagates NaN by default; call check_finite() only "
|
||||
"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"):
|
||||
length = arr.shape[0] # type: ignore[union-attr]
|
||||
if length < min_len:
|
||||
raise InsufficientDataError(
|
||||
raise FerroTAInputError(
|
||||
f"{name} must have at least {min_len} elements, got {length}",
|
||||
code=_CODE_TOO_SHORT,
|
||||
suggestion=f"Provide at least {min_len} data points. Current length: {length}.",
|
||||
|
||||
@@ -12,33 +12,19 @@ LINEARREG_ANGLE — Linear Regression Angle (degrees)
|
||||
TSF — Time Series Forecast
|
||||
BETA — Beta
|
||||
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 typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._ferro_ta import (
|
||||
batch_dtw as _batch_dtw,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
beta as _beta,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
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 (
|
||||
linearreg as _linearreg,
|
||||
)
|
||||
@@ -261,98 +247,6 @@ def CORREL(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 30) -> np.ndarr
|
||||
_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__ = [
|
||||
"STDDEV",
|
||||
"VAR",
|
||||
@@ -363,7 +257,4 @@ __all__ = [
|
||||
"TSF",
|
||||
"BETA",
|
||||
"CORREL",
|
||||
"DTW",
|
||||
"DTW_DISTANCE",
|
||||
"BATCH_DTW",
|
||||
]
|
||||
|
||||
+127
-199
@@ -1,40 +1,27 @@
|
||||
#!/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
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
AVAILABLE_CHECKS=(
|
||||
version changelog manifest
|
||||
rust_fmt rust_clippy rust_core rust_bench
|
||||
python_lint python_typecheck python_test
|
||||
docs wasm
|
||||
version
|
||||
changelog
|
||||
rust_fmt
|
||||
rust_clippy
|
||||
rust_core
|
||||
rust_bench
|
||||
python_lint
|
||||
python_typecheck
|
||||
python_test
|
||||
docs
|
||||
wasm
|
||||
manifest
|
||||
)
|
||||
|
||||
DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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'
|
||||
"$@"
|
||||
}
|
||||
python_env_ready=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -43,60 +30,93 @@ Usage:
|
||||
scripts/pre_push_checks.sh <check> [<check> ...]
|
||||
scripts/pre_push_checks.sh --list
|
||||
|
||||
Environment:
|
||||
FERRO_FAST=1 Skip docs and wasm (fastest local feedback loop)
|
||||
Runs the repo's basic local CI gate before push. By default it covers:
|
||||
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
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual check functions
|
||||
# ---------------------------------------------------------------------------
|
||||
list_checks() {
|
||||
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() {
|
||||
need_cmd uv
|
||||
run_cmd uv run --with ruff ruff 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() {
|
||||
need_cmd uv
|
||||
run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta \
|
||||
--ignore-missing-imports --no-error-summary
|
||||
run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
|
||||
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() {
|
||||
ensure_python_env
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
run_docs() {
|
||||
@@ -105,161 +125,69 @@ run_docs() {
|
||||
}
|
||||
|
||||
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
|
||||
trap 'rm -f "$benchmark_json"' EXIT
|
||||
run_cmd wasm-pack test --node
|
||||
run_cmd npm run build
|
||||
if [[ "${FERRO_FAST:-0}" != "1" ]]; then
|
||||
local bj="../.wasm_benchmark.prepush.json"
|
||||
run_cmd node bench.js --json "$bj"
|
||||
rm -f "$bj"
|
||||
fi
|
||||
run_cmd node bench.js --json "$benchmark_json"
|
||||
)
|
||||
}
|
||||
|
||||
run_manifest() {
|
||||
need_cmd python3
|
||||
run_cmd python3 scripts/check_api_manifest.py
|
||||
}
|
||||
|
||||
run_check() {
|
||||
case "$1" in
|
||||
version) run_version ;;
|
||||
changelog) run_changelog ;;
|
||||
manifest) run_manifest ;;
|
||||
rust_fmt) run_rust_fmt ;;
|
||||
rust_clippy) run_rust_clippy ;;
|
||||
rust_core) run_rust_core ;;
|
||||
rust_bench) run_rust_bench ;;
|
||||
python_lint) run_python_lint ;;
|
||||
local check_name="$1"
|
||||
case "$check_name" in
|
||||
version) run_version ;;
|
||||
changelog) run_changelog ;;
|
||||
rust_fmt) run_rust_fmt ;;
|
||||
rust_clippy) run_rust_clippy ;;
|
||||
rust_core) run_rust_core ;;
|
||||
rust_bench) run_rust_bench ;;
|
||||
python_lint) run_python_lint ;;
|
||||
python_typecheck) run_python_typecheck ;;
|
||||
python_test) run_python_test ;;
|
||||
docs) run_docs ;;
|
||||
wasm) run_wasm ;;
|
||||
*) echo "Unknown check: $1 — use --list" >&2; exit 1 ;;
|
||||
python_test) run_python_test ;;
|
||||
docs) run_docs ;;
|
||||
wasm) run_wasm ;;
|
||||
manifest) run_manifest ;;
|
||||
*)
|
||||
echo "Unknown check: $check_name" >&2
|
||||
echo "Use --list to see supported checks." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parallel runner — starts all checks concurrently, collects results
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_parallel() {
|
||||
local -a checks=("$@")
|
||||
[[ "${#checks[@]}" -eq 0 ]] && return 0
|
||||
|
||||
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; }
|
||||
if [[ "${1:-}" == "--list" ]]; then
|
||||
list_checks
|
||||
exit 0
|
||||
fi
|
||||
|
||||
selected_checks=()
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
selected_checks=("$@")
|
||||
else
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution strategy:
|
||||
# Phase 1 — instant gate (sequential, fail-fast):
|
||||
# version, changelog, manifest, python_lint, rust_fmt
|
||||
# These are trivial to run and catch the most common mistakes early.
|
||||
# If any fail here we abort immediately without waiting for slow checks.
|
||||
#
|
||||
# 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
|
||||
total_checks="${#selected_checks[@]}"
|
||||
index=0
|
||||
for check_name in "${selected_checks[@]}"; do
|
||||
index=$((index + 1))
|
||||
printf '\n[%d/%d] %s\n' "$index" "$total_checks" "$check_name"
|
||||
run_check "$check_name"
|
||||
done
|
||||
|
||||
# Phase 1: fast gate
|
||||
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'
|
||||
printf '\nAll selected pre-push checks passed.\n'
|
||||
|
||||
@@ -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,7 +4,6 @@
|
||||
mod beta;
|
||||
pub(crate) mod common;
|
||||
mod correl;
|
||||
mod dtw;
|
||||
mod linearreg;
|
||||
mod stddev;
|
||||
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::beta::beta, 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(())
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
"""Unit tests for ferro_ta.indicators.statistic"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.indicators.statistic import (
|
||||
BATCH_DTW,
|
||||
BETA,
|
||||
CORREL,
|
||||
DTW,
|
||||
DTW_DISTANCE,
|
||||
LINEARREG,
|
||||
LINEARREG_ANGLE,
|
||||
LINEARREG_INTERCEPT,
|
||||
@@ -312,177 +308,3 @@ class TestTSF:
|
||||
expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0)
|
||||
result = TSF(_A, timeperiod=14)
|
||||
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
|
||||
|
||||
@@ -950,7 +950,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro-ta"
|
||||
version = "1.1.4"
|
||||
version = "1.1.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
@@ -1272,11 +1272,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.18"
|
||||
version = "3.11"
|
||||
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 = [
|
||||
{ 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]]
|
||||
@@ -2906,7 +2906,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -2917,9 +2917,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -4261,11 +4261,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.7.0"
|
||||
version = "2.6.3"
|
||||
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 = [
|
||||
{ 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]]
|
||||
|
||||
Generated
+2
-2
@@ -49,11 +49,11 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.1.4"
|
||||
version = "1.1.3"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_wasm"
|
||||
version = "1.1.4"
|
||||
version = "1.1.3"
|
||||
dependencies = [
|
||||
"ferro_ta_core",
|
||||
"js-sys",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ferro_ta_wasm"
|
||||
version = "1.1.4"
|
||||
version = "1.1.3"
|
||||
edition = "2021"
|
||||
description = "WebAssembly bindings for ferro-ta technical analysis indicators"
|
||||
license = "MIT"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ferro-ta-wasm",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.3",
|
||||
"description": "WebAssembly bindings for ferro-ta technical analysis indicators",
|
||||
"main": "node/ferro_ta_wasm.js",
|
||||
"module": "web/ferro_ta_wasm.js",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
/// 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
|
||||
// ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user