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