Compare commits

...

10 Commits

Author SHA1 Message Date
Pratik Bhadane 4f70778255 chore(release): 1.2.0 (#30)
Bump 1.1.4 -> 1.2.0 across all release files (Cargo, pyproject, wasm,
conda, docs) and roll the CHANGELOG [Unreleased] section into [1.2.0].

Highlights: runtime CPU-feature dispatch (multiversion), broader wheel
coverage (linux aarch64 + musllinux + windows arm64), abi3 wheels,
Dynamic Time Warping, and indicator-specific exception types.
2026-06-29 18:31:08 +05:30
Pratik Bhadane 288b1546b2 feat: broaden CPU and platform coverage across PyPI, nodes, and crate… (#26)
* feat: broaden CPU and platform coverage across PyPI, nodes, and crates.io

Replace static SIMD with runtime CPU-feature dispatch and expand the release
wheel matrix so one set of artifacts runs on any target CPU and platform
without illegal-instruction crashes.

Rust core:
- Add multiversion runtime dispatch (crates/ferro_ta_core/src/simd.rs); drop
  compile-time `wide`. `simd` feature is now default-on and forwarded through
  the pyo3 crate, and stays compatible with #![forbid(unsafe_code)].

Packaging:
- abi3-py310: one cp310-abi3 wheel per platform (covers CPython 3.10+).
- CI matrix adds Linux aarch64 + musllinux (x86_64/aarch64) and Windows arm64.

Node/Docker + docs:
- api/Dockerfile: document baseline+dispatch (no target-cpu pin) and add a
  fail-fast import check; aarch64 containers now install cleanly.
- Rewrite docs/guides/simd.md; fix stale `wide` mention in ADR 0003.
- Add ADR 0006 (CPU coverage strategy).

Also bundles in-flight release prep already staged in the tree (DTW exception
types, SBOM/provenance security, supporting docs).

* fix(ci): clear cargo-deny and pip-audit failures; apply dependency bumps

cargo-deny (advisories):
- Ignore pyo3 RUSTSEC-2026-0176 / RUSTSEC-2026-0177 in deny.toml with a
  documented rationale: ferro-ta uses neither affected code path
  (PyList/PyTuple nth iterators; PyCFunction::new_closure). Upstream fix
  needs pyo3 >=0.29 (large API migration), tracked as a follow-up.

pip-audit:
- Bump dev lockfile idna 3.18, pytest 9.1.1, urllib3 2.7.0 to clear
  PYSEC-2026-215, CVE-2025-71176, PYSEC-2026-141/142.

Dependency bumps (supersede open dependabot PRs; they auto-close on merge):
- cargo: log 0.4.32, serde_json 1.0.150, rayon 1.12.0
- api/requirements.txt: uvicorn>=0.49.0, pydantic>=2.13.4, ferro-ta>=1.1.4
- CI actions: deploy-pages v5, upload-pages-artifact v5, action-gh-release v3

The open `wide` 1.5.0 bump (PR #24) is obsolete — the crate is removed in
this branch.

* chore: address CodeRabbit review; remove docs/adr section

CodeRabbit findings:
- CI sbom job: add `attestations: write` so attest-build-provenance can run
  (it had only contents:write + id-token:write).
- simd.rs: vectorize `wma_seed` with lane-local accumulators — it was scalar
  behind the multiversion wrapper, adding dispatch overhead for no SIMD gain.
- CHANGELOG: consolidate the duplicate `### Changed` heading.
- python/ferro_ta/__init__.py: also re-export the `FerroTaError` alias.
- docs/guides/dtw.md: soften "byte-for-byte" parity to within-tolerance.

Remove docs/adr/ at maintainer request and clean up the ADR links in the
SIMD and DTW guides. The ADR files remain in commit 9506a30 if ever needed.
2026-06-29 18:21:22 +05:30
Pratik Bhadane fd1bb137d6 Dtw algo (#9)
* feat: implement Dynamic Time Warping (DTW) functionality

- Added DTW distance computation and optimal warping path functions in Rust.
- Introduced corresponding Python bindings for DTW, DTW_DISTANCE, and BATCH_DTW.
- Enhanced WASM support with a new dtw_distance function.
- Included comprehensive unit tests for DTW functionality, validating against the dtaidistance library and ensuring mathematical properties.

* chore: update ferro-ta version to 1.1.4

- Bumped version number of ferro-ta to 1.1.4 in uv.lock and Cargo.lock files.
- Ensured consistency across package dependencies for the updated version.
2026-04-07 23:38:36 +05:30
Pratik Bhadane 388dc05c89 ci: remove cargo-audit step from CI workflows
- Eliminated the cargo-audit job from the CI configuration to streamline the workflow, as caching for the RustSec advisory database is no longer included.
2026-04-02 17:11:48 +05:30
Pratik Bhadane 0ee5f246ed ci: add RUSTFLAGS environment variable to CI workflows
- Introduced RUSTFLAGS environment variable in Python, Rust, and WASM CI workflows to override target-cpu settings from .cargo/config.toml, ensuring consistent build configurations across different environments.
2026-04-02 17:05:14 +05:30
Pratik Bhadane 500716177e ci: optimize CI workflows for Rust and pre-push checks
- Enhanced Rust CI by adding caching for the RustSec advisory database, significantly reducing audit run times.
- Updated fuzz testing commands to specify the target architecture for improved compatibility.
- Refactored pre-push checks script to streamline available checks and improve parallel execution, ensuring faster feedback during development.
2026-04-02 17:00:37 +05:30
Pratik Bhadane 06c536bcb7 ci: enhance CI workflows for Python, Rust, and WASM
- Updated Python CI to streamline linting, type checking, and testing processes, including the addition of a wheel build job.
- Refactored Rust CI to utilize pre-built actions for cargo-deny and cargo-audit, improving dependency checks.
- Optimized WASM CI by consolidating build and test steps, and ensuring proper artifact uploads for both Node.js and web packages.
- Added token authentication for GitHub actions in the release workflow to enhance security.
2026-04-02 16:54:35 +05:30
Pratik Bhadane 3e0f289d51 chore: update ferro-ta version to 1.1.3 (#8)
- Bumped version numbers across Cargo.toml, Cargo.lock, pyproject.toml, and conda/meta.yaml to 1.1.3.
- Added new features including American option pricing, digital options, extended Greeks, and historical volatility estimators.
- Enhanced documentation and tests for new functionalities.
- Updated CHANGELOG.md to reflect changes for version 1.1.3.
2026-04-02 16:38:32 +05:30
Pratik Bhadane 125eb32d9f chore: update npm publish command to remove provenance flag and add NODE_AUTH_TOKEN environment variable 2026-04-02 00:20:18 +05:30
Pratik Bhadane 3b6c7a15a3 chore: update npm publish command to include provenance flag 2026-04-02 00:07:36 +05:30
63 changed files with 7515 additions and 568 deletions
+11
View File
@@ -34,3 +34,14 @@ 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"
+110 -42
View File
@@ -87,7 +87,7 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
# -------------------------------------------------------------------------
# CI gate — all required jobs must pass before this job succeeds.
@@ -136,88 +136,123 @@ 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 / py${{ matrix.python-version }})
name: Build wheels (linux-gnu / ${{ 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:
python-version: ["3.10", "3.11", "3.12", "3.13"]
target: [x86_64, aarch64]
steps:
- uses: actions/checkout@v6
- name: Build wheel
- name: Build abi3 manylinux wheel
uses: PyO3/maturin-action@v1
with:
command: build
args: --release --out dist --compatibility pypi -i python${{ matrix.python-version }}
target: ${{ matrix.target }}
args: --release --out dist
manylinux: "2_17"
- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: wheels-linux-py${{ matrix.python-version }}
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 }}
path: dist/*.whl
build-wheels-macos:
name: Build wheels (macos / py${{ matrix.python-version }})
name: Build wheels (macos / universal2)
runs-on: macos-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 }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
python-version: "3.10"
- name: Build universal2 wheel
- name: Build universal2 abi3 wheel
uses: PyO3/maturin-action@v1
with:
command: build
args: --release --out dist --compatibility pypi -i python
target: universal2-apple-darwin
args: --release --out dist
- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: wheels-macos-py${{ matrix.python-version }}
name: wheels-macos-universal2
path: dist/*.whl
build-wheels-windows:
name: Build wheels (windows / py${{ matrix.python-version }})
runs-on: windows-latest
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:
python-version: ["3.10", "3.11", "3.12", "3.13"]
platform:
- runner: windows-latest
arch: x64
target: x64
- runner: windows-11-arm
arch: arm64
target: aarch64
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
python-version: "3.11"
architecture: ${{ matrix.platform.arch }}
- name: Build wheel
- name: Build abi3 wheel
uses: PyO3/maturin-action@v1
with:
command: build
args: --release --out dist --compatibility pypi -i python
target: ${{ matrix.platform.target }}
args: --release --out dist
- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: wheels-windows-py${{ matrix.python-version }}
name: wheels-windows-${{ matrix.platform.arch }}
path: dist/*.whl
build-sdist:
@@ -247,6 +282,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-wheels-linux
- build-wheels-musllinux
- build-wheels-macos
- build-wheels-windows
- build-sdist
@@ -256,6 +292,8 @@ 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
@@ -270,6 +308,11 @@ 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'
@@ -283,19 +326,15 @@ jobs:
for name in files:
print(f" - {name}")
# One abi3 wheel (cp310-abi3) per platform/arch — covers CPython 3.10+.
expected = [
"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-*-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-*.tar.gz",
]
@@ -346,6 +385,7 @@ jobs:
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@v6
@@ -380,12 +420,40 @@ jobs:
- name: Generate Rust SBOM (CycloneDX)
run: cargo cyclonedx --format json --override-filename ferro-ta-rust-sbom.cdx
- name: Upload Python SBOM to release
uses: softprops/action-gh-release@v2
- 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:
files: ferro-ta-python-sbom.spdx.json
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
with:
files: |
ferro-ta-python-sbom.spdx.json
ferro-ta-python-sbom.spdx.json.sig
ferro-ta-python-sbom.spdx.json.pem
- name: Upload Rust SBOM to release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: ferro-ta-rust-sbom.cdx.json
files: |
ferro-ta-rust-sbom.cdx.json
ferro-ta-rust-sbom.cdx.json.sig
ferro-ta-rust-sbom.cdx.json.pem
+1 -1
View File
@@ -44,6 +44,6 @@ jobs:
- name: Upload GitHub Pages artifact
if: ${{ inputs.upload-pages-artifact }}
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: docs/_build/
+94 -92
View File
@@ -7,151 +7,157 @@ 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
- name: Set up Python 3.12
uses: actions/setup-python@v6
- 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/
- 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/
# -------------------------------------------------------------------------
# Type checking — needs wheel; build once with cache
# -------------------------------------------------------------------------
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
- name: Set up Python 3.12
uses: actions/setup-python@v6
- 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
- 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:
name: Test (ubuntu-latest / Python ${{ matrix.python-version }})
# -------------------------------------------------------------------------
# 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
# -------------------------------------------------------------------------
# Test matrix — downloads pre-built wheel, no Rust compilation
# -------------------------------------------------------------------------
test:
name: Test (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
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install maturin and test dependencies
- uses: actions/download-artifact@v8
with:
name: wheel-${{ matrix.python-version }}
path: dist
- name: Install wheel + test deps
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
- name: Run unit tests with coverage
pip install pytest pytest-cov pandas polars hypothesis pyyaml mcp scipy
- 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
- name: Check API manifest is current
if: matrix.python-version == '3.12'
run: python scripts/check_api_manifest.py
- name: Upload coverage report
uses: actions/upload-artifact@v7
- 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
- name: Set up Python 3.12
uses: actions/setup-python@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install TA-Lib C library (Ubuntu)
- uses: actions/download-artifact@v8
with:
name: wheel-3.12
path: dist
- name: Install TA-Lib C library
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
- 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
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
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
- name: Set up Python 3.12
uses: actions/setup-python@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- 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: |
- uses: actions/download-artifact@v8
with:
name: wheel-3.12
path: dist
- run: pip install dist/*.whl numpy pytest
- run: |
python benchmarks/run_perf_contract.py \
--output-dir perf-contract \
--skip-talib \
@@ -161,12 +167,8 @@ jobs:
--streaming-bars 20000 \
--price-bars 20000 \
--iv-bars 50000
- 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
- run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
- uses: actions/upload-artifact@v7
with:
name: perf-contract
path: perf-contract/
+62 -45
View File
@@ -7,102 +7,119 @@ permissions:
contents: read
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
steps:
- uses: actions/checkout@v6
- uses: EmbarkStudios/cargo-deny-action@v2
- 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
# -------------------------------------------------------------------------
# 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
with:
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
- 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)
# -------------------------------------------------------------------------
# fmt + clippy (with Rust cache so subsequent runs skip recompilation)
# -------------------------------------------------------------------------
rust-lint:
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 (ferro_ta_core)
- name: Verify benchmarks compile
run: cargo bench -p ferro_ta_core --no-run
# -------------------------------------------------------------------------
# Build + test ferro_ta_core (with Rust cache)
# -------------------------------------------------------------------------
rust-core:
name: Rust core library (ferro_ta_core)
name: Rust core (build + test)
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
- name: Build core crate
run: cargo build -p ferro_ta_core
- name: Test core crate
run: cargo test -p ferro_ta_core
- uses: Swatinem/rust-cache@v2
- run: cargo build -p ferro_ta_core
- run: cargo test -p ferro_ta_core
# -------------------------------------------------------------------------
# Coverage (optional, cached)
# -------------------------------------------------------------------------
rust-coverage:
name: Rust coverage (tarpaulin, optional)
name: Rust coverage (tarpaulin)
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
- 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
- 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
with:
name: rust-coverage
path: coverage/
if-no-files-found: ignore
# -------------------------------------------------------------------------
# Fuzz (optional, nightly, cached)
# -------------------------------------------------------------------------
fuzz:
name: Fuzz targets (short CI run, optional)
name: Fuzz targets (short CI run)
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
- name: Install cargo-fuzz
run: cargo install cargo-fuzz --locked
targets: x86_64-unknown-linux-gnu
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
- name: Run fuzz_sma (10000 iterations)
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)
working-directory: fuzz
run: cargo fuzz run fuzz_rsi -- -runs=10000 -max_len=512
- name: Upload fuzz artifacts on crash
uses: actions/upload-artifact@v7
run: cargo fuzz run fuzz_rsi --target x86_64-unknown-linux-gnu -- -runs=10000 -max_len=512
- uses: actions/upload-artifact@v7
if: always()
with:
name: fuzz-artifacts
+15 -26
View File
@@ -8,54 +8,43 @@ permissions:
jobs:
wasm:
name: WASM binding (wasm-pack test --node)
name: WASM (test + build + bench)
runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v6
- uses: actions/setup-node@v6
with:
node-version: "20"
- name: Install Rust (stable)
uses: dtolnay/rust-toolchain@v1
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
targets: wasm32-unknown-unknown
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Build and test WASM binding
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: wasm-pack
- name: Test WASM binding
working-directory: wasm
run: wasm-pack test --node
- name: Build WASM package (both targets)
- name: Build WASM packages (node + web)
working-directory: wasm
run: npm run build
- name: Check API manifest is current
run: python3 scripts/check_api_manifest.py
- name: Benchmark WASM package
- name: Benchmark WASM
working-directory: wasm
run: node bench.js --json ../wasm_benchmark.json
- name: Upload WASM node package artifact
uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7
with:
name: wasm-pkg-node
path: wasm/node/
- name: Upload WASM web package artifact
uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7
with:
name: wasm-pkg-web
path: wasm/web/
- name: Upload WASM benchmark artifact
uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7
with:
name: wasm-benchmark
path: wasm_benchmark.json
+100
View File
@@ -0,0 +1,100 @@
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@v8
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'],
});
+3 -1
View File
@@ -20,6 +20,7 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GH_PAT }}
- name: Extract version from tag
id: version
@@ -51,10 +52,11 @@ jobs:
PY
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
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 }}
+1 -1
View File
@@ -47,4 +47,4 @@ jobs:
- name: Publish to npm
working-directory: wasm
run: npm publish --access public
run: npm publish --access public --provenance
+122
View File
@@ -9,6 +9,128 @@ and the project uses [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.2.0] — 2026-06-29
### 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
- **Stock instrument** (`instrument="stock"`) in `PayoffLeg` and `StrategyLeg`
for modelling equity-holding strategies (Covered Call, Protective Put, Collar,
Covered Strangle, Stock + Spread). Linear payoff identical to futures.
Exposed in all three layers: Rust core, Python, and WASM.
- **Extended Greeks** (`extended_greeks`): closed-form vanna (∂Δ/∂σ), volga
(∂²V/∂σ²), charm (∂Δ/∂t), speed (∂Γ/∂S), and color (∂Γ/∂t) for BSM.
Batch vectorisation supported.
- **Digital options** (`digital_option_price`, `digital_option_greeks`):
cash-or-nothing and asset-or-nothing pricing (BSM closed-form) plus
numerical delta / gamma / vega. Scalar and batch variants.
- **American options** (`american_option_price`, `early_exercise_premium`):
Barone-Adesi-Whaley (1987) quadratic approximation — O(1) per evaluation.
Scalar and batch variants.
- **Historical volatility estimators** (all rolling, annualised): close-to-close,
Parkinson, Garman-Klass, Rogers-Satchell, Yang-Zhang. Yang-Zhang is
~14× more efficient than close-to-close and handles overnight gaps.
- **Volatility cone** (`vol_cone`): min / p25 / median / p75 / max distribution
of realised vol across user-specified window lengths — contextualises current
IV against historical norms.
- **`strategy_value`**: pre-expiry BSM mid-price value of a multi-leg strategy
over a spot grid (time value included), complementing `strategy_payoff`
(expiry intrinsic).
- **`expected_move`**: log-normal ±1σ expected price range over N days.
- **`put_call_parity_deviation`**: detects stale quotes or data errors by
computing C P (S·e^{qT} K·e^{rT}).
- All new analytics exposed to **WASM** (`wasm/src/lib.rs`):
`extended_greeks`, `digital_price`, `digital_greeks`, `american_price`,
`early_exercise_premium`, `close_to_close_vol`, `parkinson_vol`,
`garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol`, `vol_cone`,
`expected_move`, `put_call_parity_deviation`, `strategy_payoff_dense`,
`aggregate_greeks_dense`, `strategy_value_grid`.
- `aggregate_greeks_dense` added to `ferro_ta_core::options::payoff` (pure
Rust, no PyO3/numpy dependency) enabling WASM reuse.
- Comprehensive docstrings (NumPy style with Parameters / Returns / Notes /
Examples) on all new Python functions.
- Accuracy test suite `tests/unit/test_derivatives_accuracy.py` validates
digital options, extended Greeks, American options, and vol estimators
against scipy and analytical reference formulas.
- scipy added to `dev` optional dependencies for reference testing.
### Changed
- `StrategyLeg.expiry_selector`, `StrategyLeg.strike_selector`, and
`StrategyLeg.option_type` are now `Optional` (None allowed for stock legs).
Existing option legs are unaffected.
- `docs/derivatives-analytics.md` rewritten to cover all new features with
runnable examples and an efficiency comparison table for vol estimators.
## [1.1.2] — 2026-04-01
### Changed
Generated
+61 -58
View File
@@ -34,9 +34,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "arc-swap"
version = "1.9.0"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
@@ -53,12 +53,6 @@ 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"
@@ -67,9 +61,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.57"
version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -207,7 +201,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "ferro_ta"
version = "1.1.2"
version = "1.2.0"
dependencies = [
"criterion",
"ferro_ta_core",
@@ -222,12 +216,12 @@ dependencies = [
[[package]]
name = "ferro_ta_core"
version = "1.1.2"
version = "1.2.0"
dependencies = [
"criterion",
"multiversion",
"serde",
"serde_json",
"wide",
]
[[package]]
@@ -279,9 +273,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.91"
version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -289,15 +283,15 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.183"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "matrixmultiply"
@@ -324,6 +318,28 @@ 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"
@@ -546,9 +562,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.11.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
@@ -595,9 +611,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc-hash"
version = "2.1.1"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustversion"
@@ -605,15 +621,6 @@ 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"
@@ -655,9 +662,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -689,6 +696,12 @@ 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"
@@ -729,9 +742,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
dependencies = [
"cfg-if",
"once_cell",
@@ -742,9 +755,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -752,9 +765,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -765,33 +778,23 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.91"
version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
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"
@@ -840,18 +843,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.47"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.47"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
+12 -4
View File
@@ -5,7 +5,7 @@ resolver = "2"
[package]
name = "ferro_ta"
version = "1.1.2"
version = "1.2.0"
edition = "2021"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
license = "MIT"
@@ -22,7 +22,9 @@ name = "ferro_ta"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.25", features = ["extension-module"] }
# 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"] }
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).
@@ -30,7 +32,11 @@ ndarray = "0.16"
rayon = "1.10"
log = "0.4"
pyo3-log = "0.12"
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.2", features = ["serde"] }
# 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.2.0", default-features = false, features = ["serde"] }
[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
@@ -40,5 +46,7 @@ lto = true
codegen-units = 1
[features]
default = []
# 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"]
simd = ["ferro_ta_core/simd"]
+22 -7
View File
@@ -8,21 +8,36 @@
#
# 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
# 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 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.
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 ./
+3 -3
View File
@@ -1,6 +1,6 @@
# Runtime dependencies for ferro-ta API
ferro_ta>=1.0.0
ferro_ta>=1.1.4
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
pydantic>=2.0.0
uvicorn[standard]>=0.49.0
pydantic>=2.13.4
numpy>=1.20
+4 -1
View File
@@ -55,8 +55,11 @@ 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", []),
("portable_release", ["--no-default-features"]),
("simd_release", ["--features", "simd"]),
]
reports = {
+1 -1
View File
@@ -1,5 +1,5 @@
{% set name = "ferro-ta" %}
{% set version = "1.1.2" %}
{% set version = "1.2.0" %}
package:
name: {{ name|lower }}
+10 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "ferro_ta_core"
version = "1.1.2"
version = "1.2.0"
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]
wide = { version = "1.1.1", optional = true }
multiversion = { version = "0.8", optional = true }
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true }
@@ -28,6 +28,12 @@ name = "indicators"
harness = false
[features]
wide = ["dep:wide"]
simd = ["wide"]
# 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"]
serde = ["dep:serde", "dep:serde_json"]
+1 -1
View File
@@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
```toml
[dependencies]
ferro_ta_core = "1.1.2"
ferro_ta_core = "1.2.0"
```
## Design
+4
View File
@@ -1,3 +1,5 @@
#![forbid(unsafe_code)]
/*!
ferro_ta_core — Pure Rust indicator library.
@@ -49,6 +51,8 @@ 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;
@@ -0,0 +1,410 @@
//! American option pricing via the Barone-Adesi-Whaley (1987) quadratic approximation.
use super::normal::cdf;
use super::pricing::black_scholes_price;
use super::OptionKind;
fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
!spot.is_finite()
|| !strike.is_finite()
|| !time_to_expiry.is_finite()
|| !volatility.is_finite()
|| spot <= 0.0
|| strike <= 0.0
|| time_to_expiry < 0.0
|| volatility < 0.0
}
/// Compute d1 for BSM given spot S* (used inside the Newton-Raphson loop).
fn d1_fn(s: f64, strike: f64, rate: f64, carry: f64, time_to_expiry: f64, volatility: f64) -> f64 {
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
((s / strike).ln() + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry)
/ sigma_sqrt_t
}
/// Find the critical spot price S* for American call early exercise using Newton-Raphson.
///
/// S* satisfies: C(S*) - (S* - K) = (S*/q2) * (1 - e^{-q*T} * N(d1(S*)))
/// Rearranged as F(S*) = 0:
/// F(x) = C(x) - (x - K) - (x/q2) * (1 - carry_discount * N(d1(x))) = 0
fn find_critical_call(
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
q2: f64,
) -> f64 {
let carry_discount = (-carry * time_to_expiry).exp();
// Initial guess: S* ≈ K * q2 / (q2 - 1), clamped to be above strike
let mut s = if q2 > 1.0 {
strike * q2 / (q2 - 1.0)
} else {
// q2 <= 1 means the denominator is small/negative; fall back to a safe value
strike * 2.0
};
// Ensure starting guess is positive
if s <= 0.0 {
s = strike * 1.5;
}
for _ in 0..50 {
let c = black_scholes_price(
s,
strike,
rate,
carry,
time_to_expiry,
volatility,
OptionKind::Call,
);
let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility);
let nd1 = cdf(d1);
let lhs = c - (s - strike);
let rhs = (s / q2) * (1.0 - carry_discount * nd1);
let f = lhs - rhs;
// Derivative of F with respect to s:
// dC/ds = e^{-q*T} * N(d1) (BSM delta for call)
// d(s - K)/ds = 1
// d(rhs)/ds = (1/q2) * (1 - carry_discount * N(d1))
// + (s/q2) * (-carry_discount * phi(d1) / (s * vol * sqrt(T)))
// = (1/q2) * (1 - carry_discount * N(d1)) - carry_discount * phi(d1) / (q2 * vol * sqrt(T))
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
let phi_d1 = super::normal::pdf(d1);
let d_lhs_ds = carry_discount * nd1 - 1.0;
let d_rhs_ds = (1.0 / q2) * (1.0 - carry_discount * nd1)
- carry_discount * phi_d1 / (q2 * sigma_sqrt_t);
let df = d_lhs_ds - d_rhs_ds;
if df.abs() < 1e-14 {
break;
}
let step = f / df;
s -= step;
// Keep s positive
if s <= 0.0 {
s = strike * 0.1;
}
if step.abs() < 1e-8 {
break;
}
}
s
}
/// Find the critical spot price S** for American put early exercise using Newton-Raphson.
///
/// S** satisfies: P(S**) - (K - S**) = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**)))
/// F(x) = P(x) - (K - x) + (x/q1) * (1 - carry_discount * N(-d1(x))) = 0
fn find_critical_put(
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
q1: f64,
) -> f64 {
let carry_discount = (-carry * time_to_expiry).exp();
// Initial guess for put: S** ≈ K * q1 / (q1 - 1)
// q1 is negative, so q1 - 1 < 0, and the guess should be below strike.
let mut s = if (q1 - 1.0).abs() > 1e-10 {
strike * q1 / (q1 - 1.0)
} else {
strike * 0.5
};
if s <= 0.0 || s >= strike {
s = strike * 0.5;
}
for _ in 0..50 {
let p = black_scholes_price(
s,
strike,
rate,
carry,
time_to_expiry,
volatility,
OptionKind::Put,
);
let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility);
let n_neg_d1 = cdf(-d1);
let lhs = p - (strike - s);
// rhs = -(s/q1) * (1 - carry_discount * N(-d1))
let rhs = -(s / q1) * (1.0 - carry_discount * n_neg_d1);
let f = lhs - rhs;
// Derivative:
// dP/ds = -e^{-q*T} * N(-d1) (BSM delta for put = e^{-q*T}*(N(d1)-1))
// d(K - s)/ds = -1 so d(lhs)/ds = dP/ds - (-1) = dP/ds + 1
// d(rhs)/ds = -(1/q1)*(1 - carry_discount*N(-d1))
// + -(s/q1)*carry_discount*phi(d1)/(s*vol*sqrt(T)) [since d(N(-d1))/ds = -phi(d1)*dd1/ds]
// = -(1/q1)*(1 - carry_discount*N(-d1))
// - carry_discount*phi(d1)/(q1*vol*sqrt(T))
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
let phi_d1 = super::normal::pdf(d1);
let d_lhs_ds = -carry_discount * n_neg_d1 + 1.0;
let d_rhs_ds = -(1.0 / q1) * (1.0 - carry_discount * n_neg_d1)
- carry_discount * phi_d1 / (q1 * sigma_sqrt_t);
let df = d_lhs_ds - d_rhs_ds;
if df.abs() < 1e-14 {
break;
}
let step = f / df;
s -= step;
if s <= 0.0 {
s = strike * 0.01;
}
if s >= strike {
s = strike * 0.99;
}
if step.abs() < 1e-8 {
break;
}
}
s
}
/// American option price using the Barone-Adesi-Whaley (1987) quadratic approximation.
///
/// # Parameters
/// - `spot`: current underlying price
/// - `strike`: option strike price
/// - `rate`: risk-free rate (annualized, decimal)
/// - `carry`: continuous dividend yield / carry rate
/// - `time_to_expiry`: time to expiry in years
/// - `volatility`: implied vol (annualized, decimal)
/// - `kind`: call or put
pub fn american_price_baw(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
kind: OptionKind,
) -> f64 {
if invalid_inputs(spot, strike, time_to_expiry, volatility)
|| !rate.is_finite()
|| !carry.is_finite()
{
return f64::NAN;
}
// At expiry: immediate exercise value
if time_to_expiry == 0.0 {
return match kind {
OptionKind::Call => (spot - strike).max(0.0),
OptionKind::Put => (strike - spot).max(0.0),
};
}
// At zero vol: deterministic — exercise if ITM
if volatility == 0.0 {
return match kind {
OptionKind::Call => (spot - strike).max(0.0),
OptionKind::Put => (strike - spot).max(0.0),
};
}
let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind);
match kind {
OptionKind::Call => {
// No early exercise premium when there are no dividends (carry == 0 means q==0
// in BSM parameterisation where carry = q).
if carry <= 0.0 {
return european;
}
let sigma2 = volatility * volatility;
let m = 2.0 * rate / sigma2;
let n = 2.0 * (rate - carry) / sigma2;
let h = 1.0 - (-rate * time_to_expiry).exp();
if h.abs() < 1e-14 {
return european;
}
let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h;
if discriminant < 0.0 {
return european;
}
let q2 = (-(n - 1.0) + discriminant.sqrt()) / 2.0;
// Find critical price S*
let s_star = find_critical_call(strike, rate, carry, time_to_expiry, volatility, q2);
if s_star <= strike {
// Degenerate critical price; fall back to European
return european;
}
// A2 = (S*/q2) * (1 - e^{-q*T} * N(d1(S*)))
let carry_discount = (-carry * time_to_expiry).exp();
let d1_star = d1_fn(s_star, strike, rate, carry, time_to_expiry, volatility);
let a2 = (s_star / q2) * (1.0 - carry_discount * cdf(d1_star));
if spot >= s_star {
// Immediate exercise is optimal
(spot - strike).max(0.0)
} else {
(european + a2 * (spot / s_star).powf(q2)).max(european)
}
}
OptionKind::Put => {
// No early exercise when rate == 0 (no time value of money)
if rate <= 0.0 {
return european;
}
let sigma2 = volatility * volatility;
let m = 2.0 * rate / sigma2;
let n = 2.0 * (rate - carry) / sigma2;
let h = 1.0 - (-rate * time_to_expiry).exp();
if h.abs() < 1e-14 {
return european;
}
let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h;
if discriminant < 0.0 {
return european;
}
let q1 = (-(n - 1.0) - discriminant.sqrt()) / 2.0;
// Find critical price S**
let s_star_star =
find_critical_put(strike, rate, carry, time_to_expiry, volatility, q1);
if s_star_star <= 0.0 || s_star_star >= strike {
return european;
}
// A1 = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**)))
let carry_discount = (-carry * time_to_expiry).exp();
let d1_star = d1_fn(s_star_star, strike, rate, carry, time_to_expiry, volatility);
let a1 = -(s_star_star / q1) * (1.0 - carry_discount * cdf(-d1_star));
if spot <= s_star_star {
// Immediate exercise is optimal
(strike - spot).max(0.0)
} else {
(european + a1 * (spot / s_star_star).powf(q1)).max(european)
}
}
}
}
/// Early exercise premium = american_price - european_bsm_price.
///
/// Always non-negative for valid inputs.
pub fn early_exercise_premium(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
kind: OptionKind,
) -> f64 {
let american = american_price_baw(spot, strike, rate, carry, time_to_expiry, volatility, kind);
let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind);
if american.is_nan() || european.is_nan() {
return f64::NAN;
}
(american - european).max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::OptionKind;
#[test]
fn american_call_gte_european_call() {
let european = crate::options::pricing::black_scholes_price(
100.0,
100.0,
0.05,
0.03,
1.0,
0.2,
OptionKind::Call,
);
let american = american_price_baw(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call);
assert!(american >= european - 1e-10);
}
#[test]
fn american_put_gte_european_put() {
let european = crate::options::pricing::black_scholes_price(
100.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Put,
);
let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Put);
assert!(american >= european - 1e-10);
}
#[test]
fn early_exercise_premium_nonneg() {
let prem = early_exercise_premium(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call);
assert!(prem >= 0.0);
}
#[test]
fn american_call_no_dividends_equals_european() {
// With no dividends (carry == 0), no early exercise is optimal for calls
let european = crate::options::pricing::black_scholes_price(
100.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
);
let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
assert!((american - european).abs() < 1e-10);
}
#[test]
fn american_price_returns_nan_for_invalid() {
let price = american_price_baw(-1.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
assert!(price.is_nan());
}
#[test]
fn american_price_at_expiry_is_intrinsic() {
let call = american_price_baw(110.0, 100.0, 0.05, 0.03, 0.0, 0.2, OptionKind::Call);
assert!((call - 10.0).abs() < 1e-10);
let put = american_price_baw(90.0, 100.0, 0.05, 0.0, 0.0, 0.2, OptionKind::Put);
assert!((put - 10.0).abs() < 1e-10);
}
#[test]
fn american_put_itm_has_positive_premium() {
// Deep ITM put with high rate should have meaningful early exercise premium
let prem = early_exercise_premium(80.0, 100.0, 0.10, 0.0, 1.0, 0.2, OptionKind::Put);
assert!(prem >= 0.0);
}
#[test]
fn american_prices_are_finite_for_valid_inputs() {
let call = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Call);
let put = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Put);
assert!(call.is_finite());
assert!(put.is_finite());
}
}
+382
View File
@@ -0,0 +1,382 @@
//! Digital (binary) option pricing.
use super::normal::cdf;
use super::OptionKind;
/// Type of digital option payoff.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DigitalKind {
/// Pays 1 unit of cash if option expires in the money.
CashOrNothing,
/// Pays the underlying asset if option expires in the money.
AssetOrNothing,
}
fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
!spot.is_finite()
|| !strike.is_finite()
|| !time_to_expiry.is_finite()
|| !volatility.is_finite()
|| spot <= 0.0
|| strike <= 0.0
|| time_to_expiry < 0.0
|| volatility < 0.0
}
/// Price a digital (binary) option under BSM.
///
/// # Parameters
/// - `spot`: current underlying price
/// - `strike`: option strike price
/// - `rate`: risk-free rate (annualized, decimal)
/// - `carry`: continuous dividend yield / carry rate
/// - `time_to_expiry`: time to expiry in years
/// - `volatility`: implied vol (annualized, decimal)
/// - `option_kind`: call or put
/// - `digital_kind`: cash-or-nothing or asset-or-nothing
#[allow(clippy::too_many_arguments)]
pub fn digital_price(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
option_kind: OptionKind,
digital_kind: DigitalKind,
) -> f64 {
if invalid_inputs(spot, strike, time_to_expiry, volatility)
|| !rate.is_finite()
|| !carry.is_finite()
{
return f64::NAN;
}
// At expiry: pay intrinsic based on ITM status
if time_to_expiry == 0.0 {
let itm = match option_kind {
OptionKind::Call => spot > strike,
OptionKind::Put => spot < strike,
};
return if itm {
match digital_kind {
DigitalKind::CashOrNothing => 1.0,
DigitalKind::AssetOrNothing => spot,
}
} else {
0.0
};
}
let discount = (-rate * time_to_expiry).exp();
let carry_discount = (-carry * time_to_expiry).exp();
// At zero vol: deterministic payoff
if volatility == 0.0 {
let forward = spot * (carry_discount / discount); // S * e^{(r-q)*T} equivalent: S*e^{-q*T}/e^{-r*T}
// forward = S * e^{(r-q)*T}; ITM if forward > K for call
let itm = match option_kind {
OptionKind::Call => spot * carry_discount > strike * discount,
OptionKind::Put => spot * carry_discount < strike * discount,
};
let _ = forward; // suppress unused warning
return if itm {
match digital_kind {
DigitalKind::CashOrNothing => discount,
DigitalKind::AssetOrNothing => spot * carry_discount,
}
} else {
0.0
};
}
let sqrt_t = time_to_expiry.sqrt();
let sigma_sqrt_t = volatility * sqrt_t;
let d1 = ((spot / strike).ln()
+ (rate - carry + 0.5 * volatility * volatility) * time_to_expiry)
/ sigma_sqrt_t;
let d2 = d1 - sigma_sqrt_t;
match digital_kind {
DigitalKind::CashOrNothing => match option_kind {
OptionKind::Call => discount * cdf(d2),
OptionKind::Put => discount * cdf(-d2),
},
DigitalKind::AssetOrNothing => match option_kind {
OptionKind::Call => spot * carry_discount * cdf(d1),
OptionKind::Put => spot * carry_discount * cdf(-d1),
},
}
}
/// Compute numerical delta, gamma, and vega for a digital option.
///
/// Uses central finite differences:
/// - delta/gamma: bump spot by ε = spot * 1e-3
/// - vega: bump volatility by 1e-3
///
/// Returns `(delta, gamma, vega)`.
#[allow(clippy::too_many_arguments)]
pub fn digital_greeks(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
option_kind: OptionKind,
digital_kind: DigitalKind,
) -> (f64, f64, f64) {
let eps = spot * 1e-3;
if eps <= 0.0 {
return (f64::NAN, f64::NAN, f64::NAN);
}
let price_mid = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility,
option_kind,
digital_kind,
);
let price_up = digital_price(
spot + eps,
strike,
rate,
carry,
time_to_expiry,
volatility,
option_kind,
digital_kind,
);
let price_dn = digital_price(
spot - eps,
strike,
rate,
carry,
time_to_expiry,
volatility,
option_kind,
digital_kind,
);
let delta = (price_up - price_dn) / (2.0 * eps);
let gamma = (price_up - 2.0 * price_mid + price_dn) / (eps * eps);
let vol_bump = 1e-3;
let vega = if volatility + vol_bump > 0.0 && volatility - vol_bump > 0.0 {
let price_vup = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility + vol_bump,
option_kind,
digital_kind,
);
let price_vdn = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility - vol_bump,
option_kind,
digital_kind,
);
(price_vup - price_vdn) / (2.0 * vol_bump)
} else {
// vol too close to zero; one-sided bump
let price_vup = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility + vol_bump,
option_kind,
digital_kind,
);
(price_vup - price_mid) / vol_bump
};
(delta, gamma, vega)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::OptionKind;
#[test]
fn cash_or_nothing_call_atm() {
// ATM cash-or-nothing call: price = e^{-rT} * N(d2)
// At S=K=100, r=0.05, q=0, T=1, σ=0.2:
// d1 = (0 + 0.07) / 0.2 = 0.35, d2 = 0.15 → N(0.15) ≈ 0.5596
// price ≈ e^{-0.05} * 0.5596 ≈ 0.532
let price = digital_price(
100.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!(
price > 0.0 && price < 1.0,
"price should be between 0 and 1"
);
assert!((price - 0.532).abs() < 0.01, "price ≈ 0.532, got {price}");
}
#[test]
fn asset_or_nothing_call_at_zero_vol() {
// At zero vol, ITM asset-or-nothing call should equal S * e^{-q*T}
let price = digital_price(
110.0,
100.0,
0.05,
0.0,
1.0,
0.0,
OptionKind::Call,
DigitalKind::AssetOrNothing,
);
assert!((price - 110.0).abs() < 1e-6);
}
#[test]
fn digital_price_returns_nan_for_invalid() {
let price = digital_price(
-1.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!(price.is_nan());
}
#[test]
fn cash_or_nothing_put_call_parity() {
// Cash-or-nothing call + cash-or-nothing put = e^{-rT}
let call = digital_price(
100.0,
100.0,
0.05,
0.02,
1.0,
0.25,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
let put = digital_price(
100.0,
100.0,
0.05,
0.02,
1.0,
0.25,
OptionKind::Put,
DigitalKind::CashOrNothing,
);
let discount = (-0.05_f64).exp();
assert!((call + put - discount).abs() < 1e-10);
}
#[test]
fn asset_or_nothing_put_call_parity() {
// Asset-or-nothing call + asset-or-nothing put = S * e^{-q*T}
let s = 100.0_f64;
let q = 0.02_f64;
let call = digital_price(
s,
100.0,
0.05,
q,
1.0,
0.25,
OptionKind::Call,
DigitalKind::AssetOrNothing,
);
let put = digital_price(
s,
100.0,
0.05,
q,
1.0,
0.25,
OptionKind::Put,
DigitalKind::AssetOrNothing,
);
let expected = s * (-q).exp();
assert!((call + put - expected).abs() < 1e-10);
}
#[test]
fn digital_greeks_are_finite_for_valid_inputs() {
let (delta, gamma, vega) = digital_greeks(
100.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!(delta.is_finite());
assert!(gamma.is_finite());
assert!(vega.is_finite());
}
#[test]
fn digital_at_expiry_itm_returns_intrinsic() {
let price = digital_price(
110.0,
100.0,
0.05,
0.0,
0.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!((price - 1.0).abs() < 1e-10);
let price2 = digital_price(
110.0,
100.0,
0.05,
0.0,
0.0,
0.2,
OptionKind::Call,
DigitalKind::AssetOrNothing,
);
assert!((price2 - 110.0).abs() < 1e-10);
}
#[test]
fn digital_at_expiry_otm_returns_zero() {
let price = digital_price(
90.0,
100.0,
0.05,
0.0,
0.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!((price - 0.0).abs() < 1e-10);
}
}
+99 -2
View File
@@ -2,7 +2,7 @@
use super::normal::{cdf, pdf};
use super::pricing::{black_76_price, black_scholes_price};
use super::{Greeks, OptionEvaluation, OptionKind, PricingModel};
use super::{ExtendedGreeks, Greeks, OptionEvaluation, OptionKind, PricingModel};
fn bs_inputs_valid(
underlying: f64,
@@ -203,9 +203,94 @@ pub fn model_theta(input: OptionEvaluation) -> f64 {
})
}
/// Extended Greeks under Black-Scholes-Merton (closed-form).
///
/// All inputs must be positive finite; returns NaN fields for invalid inputs.
pub fn black_scholes_extended_greeks(
spot: f64,
strike: f64,
rate: f64,
dividend_yield: f64,
time_to_expiry: f64,
volatility: f64,
_kind: OptionKind,
) -> ExtendedGreeks {
if !bs_inputs_valid(
spot,
strike,
rate,
dividend_yield,
time_to_expiry,
volatility,
) {
return ExtendedGreeks {
vanna: f64::NAN,
volga: f64::NAN,
charm: f64::NAN,
speed: f64::NAN,
color: f64::NAN,
};
}
let sqrt_t = time_to_expiry.sqrt();
let sigma_sqrt_t = volatility * sqrt_t;
let carry_discount = (-dividend_yield * time_to_expiry).exp();
let d1 = ((spot / strike).ln()
+ (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry)
/ sigma_sqrt_t;
let d2 = d1 - sigma_sqrt_t;
let pdf_d1 = pdf(d1);
let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t);
let vanna = -carry_discount * pdf_d1 * d2 / volatility;
let volga = spot * carry_discount * pdf_d1 * sqrt_t * d1 * d2 / volatility;
let charm = -carry_discount
* pdf_d1
* (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t)
/ (2.0 * time_to_expiry * sigma_sqrt_t);
let speed = -gamma / spot * (d1 / sigma_sqrt_t + 1.0);
let color = -carry_discount * pdf_d1 / (2.0 * spot * time_to_expiry * sigma_sqrt_t)
* (2.0 * (rate - dividend_yield) * time_to_expiry + 1.0
- d1 * (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t)
/ sigma_sqrt_t);
ExtendedGreeks {
vanna,
volga,
charm,
speed,
color,
}
}
/// Model-dispatched extended Greeks.
/// Only BSM is supported with closed-form; Black-76 is not yet supported (returns NaN).
pub fn model_extended_greeks(input: OptionEvaluation) -> ExtendedGreeks {
let contract = input.contract;
match contract.model {
PricingModel::BlackScholes => black_scholes_extended_greeks(
contract.underlying,
contract.strike,
contract.rate,
contract.carry,
contract.time_to_expiry,
input.volatility,
contract.kind,
),
PricingModel::Black76 => ExtendedGreeks {
vanna: f64::NAN,
volga: f64::NAN,
charm: f64::NAN,
speed: f64::NAN,
color: f64::NAN,
},
}
}
#[cfg(test)]
mod tests {
use super::{black_76_greeks, black_scholes_greeks};
use super::{black_76_greeks, black_scholes_extended_greeks, black_scholes_greeks};
use crate::options::OptionKind;
#[test]
@@ -227,4 +312,16 @@ mod tests {
assert!(g.theta.is_finite());
assert!(g.rho.is_finite());
}
#[test]
fn extended_greeks_finite_for_valid_inputs() {
let eg = black_scholes_extended_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
assert!(eg.vanna.is_finite());
assert!(eg.volga.is_finite());
assert!(eg.charm.is_finite());
assert!(eg.speed.is_finite());
assert!(eg.color.is_finite());
// Volga must be positive (convex in vol)
assert!(eg.volga >= 0.0);
}
}
+14
View File
@@ -4,11 +4,15 @@
//! IV-series helpers, and smile/chain utilities. The public API is scalar-first
//! and is used by the PyO3 bridge to build vectorized batch functions.
pub mod american;
pub mod chain;
pub mod digital;
pub mod greeks;
pub mod iv;
pub mod normal;
pub mod payoff;
pub mod pricing;
pub mod realized_vol;
pub mod surface;
/// Option side.
@@ -49,6 +53,16 @@ pub struct Greeks {
pub rho: f64,
}
/// Second-order and cross Greeks.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ExtendedGreeks {
pub vanna: f64, // ∂Δ/∂σ
pub volga: f64, // ∂²V/∂σ² (vomma)
pub charm: f64, // ∂Δ/∂t
pub speed: f64, // ∂Γ/∂S
pub color: f64, // ∂Γ/∂t
}
/// Shared contract fields for model-based option analytics.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OptionContract {
+392
View File
@@ -0,0 +1,392 @@
//! Pure-Rust (no PyO3, no numpy) strategy payoff and value functions.
//!
//! NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;`
//! for this module to be reachable from the rest of the crate and from the PyO3 bridge.
use super::pricing::black_scholes_price;
use super::OptionKind;
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Instrument codes: 0=option, 1=future, 2=stock.
const INSTRUMENT_OPTION: i64 = 0;
const INSTRUMENT_FUTURE: i64 = 1;
const INSTRUMENT_STOCK: i64 = 2;
/// Side sign from encoded value: 1=long (+1.0), -1=short (-1.0).
#[inline]
fn side_sign(v: i64) -> f64 {
if v == 1 {
1.0
} else if v == -1 {
-1.0
} else {
f64::NAN
}
}
/// Option kind from encoded value: 1=call, -1=put.
#[inline]
fn option_kind(v: i64) -> Option<OptionKind> {
match v {
1 => Some(OptionKind::Call),
-1 => Some(OptionKind::Put),
_ => None,
}
}
// ---------------------------------------------------------------------------
// strategy_payoff_dense
// ---------------------------------------------------------------------------
/// Aggregate strategy payoff over a spot grid.
///
/// Parameters (all slices of length n_legs):
/// - `instruments`: 0=option, 1=future, 2=stock
/// - `sides`: 1=long, -1=short
/// - `option_types`: 1=call, -1=put (ignored for futures/stocks)
/// - `strikes`: strike for options
/// - `premiums`: premium for options
/// - `entry_prices`: entry price for futures/stocks
/// - `quantities`, `multipliers`: applied to all instruments
///
/// Returns a Vec<f64> of length spot_grid.len() with aggregate P&L per spot point.
#[allow(clippy::too_many_arguments)]
pub fn strategy_payoff_dense(
spot_grid: &[f64],
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
premiums: &[f64],
entry_prices: &[f64],
quantities: &[f64],
multipliers: &[f64],
) -> Vec<f64> {
let n_legs = instruments.len();
// Validate that all leg slices are the same length; return zeros if not.
if sides.len() != n_legs
|| option_types.len() != n_legs
|| strikes.len() != n_legs
|| premiums.len() != n_legs
|| entry_prices.len() != n_legs
|| quantities.len() != n_legs
|| multipliers.len() != n_legs
{
return vec![0.0; spot_grid.len()];
}
let mut total = vec![0.0_f64; spot_grid.len()];
for leg_idx in 0..n_legs {
let inst = instruments[leg_idx];
let sign = side_sign(sides[leg_idx]);
if sign.is_nan() {
// Invalid side — skip leg (treat as zero contribution).
continue;
}
let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx];
match inst {
INSTRUMENT_OPTION => {
let kind = match option_kind(option_types[leg_idx]) {
Some(k) => k,
None => continue, // Invalid option type — skip.
};
let k = strikes[leg_idx];
let p = premiums[leg_idx];
for (i, &s) in spot_grid.iter().enumerate() {
let intrinsic = match kind {
OptionKind::Call => (s - k).max(0.0),
OptionKind::Put => (k - s).max(0.0),
};
total[i] += leg_scale * (intrinsic - p);
}
}
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
let e = entry_prices[leg_idx];
for (i, &s) in spot_grid.iter().enumerate() {
total[i] += leg_scale * (s - e);
}
}
_ => {
// Unknown instrument code — skip leg (NaN would propagate; zeros are safer).
}
}
}
total
}
// ---------------------------------------------------------------------------
// strategy_value_dense / strategy_value_grid
// ---------------------------------------------------------------------------
/// Current BSM value of a strategy at a single spot (pre-expiry).
///
/// Unlike `strategy_payoff_dense`, this uses BSM pricing for option legs rather
/// than intrinsic value.
///
/// Parameters: same as `strategy_payoff_dense` plus per-leg BSM inputs:
/// - `time_to_expiries`: TTE for each option leg (ignored for futures/stocks)
/// - `volatilities`: vol for each option leg (ignored for futures/stocks)
/// - `rates`: risk-free rate for each leg
/// - `carries`: carry/dividend yield for each option leg
///
/// Returns a scalar f64 (strategy P&L at the given spot).
#[allow(clippy::too_many_arguments)]
pub fn strategy_value_dense(
spot: f64,
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
premiums: &[f64],
entry_prices: &[f64],
quantities: &[f64],
multipliers: &[f64],
time_to_expiries: &[f64],
volatilities: &[f64],
rates: &[f64],
carries: &[f64],
) -> f64 {
let n_legs = instruments.len();
// Validate that all leg slices are the same length; return NaN if not.
if sides.len() != n_legs
|| option_types.len() != n_legs
|| strikes.len() != n_legs
|| premiums.len() != n_legs
|| entry_prices.len() != n_legs
|| quantities.len() != n_legs
|| multipliers.len() != n_legs
|| time_to_expiries.len() != n_legs
|| volatilities.len() != n_legs
|| rates.len() != n_legs
|| carries.len() != n_legs
{
return f64::NAN;
}
let mut total = 0.0_f64;
for leg_idx in 0..n_legs {
let inst = instruments[leg_idx];
let sign = side_sign(sides[leg_idx]);
if sign.is_nan() {
continue;
}
let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx];
match inst {
INSTRUMENT_OPTION => {
let kind = match option_kind(option_types[leg_idx]) {
Some(k) => k,
None => continue,
};
let bsm = black_scholes_price(
spot,
strikes[leg_idx],
rates[leg_idx],
carries[leg_idx],
time_to_expiries[leg_idx],
volatilities[leg_idx],
kind,
);
total += leg_scale * (bsm - premiums[leg_idx]);
}
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
total += leg_scale * (spot - entry_prices[leg_idx]);
}
_ => {}
}
}
total
}
// ---------------------------------------------------------------------------
// aggregate_greeks_dense
// ---------------------------------------------------------------------------
/// Aggregate BSM Greeks for a multi-leg strategy at a single spot.
///
/// Parameters (all slices of length n_legs):
/// - `instruments`: 0=option, 1=future, 2=stock
/// - `sides`: 1=long, -1=short
/// - `option_types`: 1=call, -1=put (ignored for futures/stocks)
/// - `strikes`: strike price for option legs
/// - `volatilities`: implied vol for option legs
/// - `time_to_expiries`: TTE in years for option legs
/// - `rates`: risk-free rate for each leg
/// - `carries`: carry/dividend yield for option legs
/// - `quantities`, `multipliers`: applied to all instruments
///
/// Returns `(delta, gamma, vega, theta, rho)` aggregate across all legs.
/// Future/stock legs contribute `leg_scale` to delta only (all other Greeks = 0).
#[allow(clippy::too_many_arguments)]
pub fn aggregate_greeks_dense(
spot: f64,
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
volatilities: &[f64],
time_to_expiries: &[f64],
rates: &[f64],
carries: &[f64],
quantities: &[f64],
multipliers: &[f64],
) -> (f64, f64, f64, f64, f64) {
use super::greeks::model_greeks;
use super::{OptionContract, OptionEvaluation, PricingModel};
let n_legs = instruments.len();
if sides.len() != n_legs
|| option_types.len() != n_legs
|| strikes.len() != n_legs
|| volatilities.len() != n_legs
|| time_to_expiries.len() != n_legs
|| rates.len() != n_legs
|| carries.len() != n_legs
|| quantities.len() != n_legs
|| multipliers.len() != n_legs
{
return (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN);
}
let mut delta = 0.0_f64;
let mut gamma = 0.0_f64;
let mut vega = 0.0_f64;
let mut theta = 0.0_f64;
let mut rho = 0.0_f64;
for i in 0..n_legs {
let sign = side_sign(sides[i]);
if sign.is_nan() {
continue;
}
let leg_scale = sign * quantities[i] * multipliers[i];
match instruments[i] {
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
delta += leg_scale;
}
INSTRUMENT_OPTION => {
let kind = match option_kind(option_types[i]) {
Some(k) => k,
None => continue,
};
let greeks = model_greeks(OptionEvaluation {
contract: OptionContract {
model: PricingModel::BlackScholes,
underlying: spot,
strike: strikes[i],
rate: rates[i],
carry: carries[i],
time_to_expiry: time_to_expiries[i],
kind,
},
volatility: volatilities[i],
});
delta += leg_scale * greeks.delta;
gamma += leg_scale * greeks.gamma;
vega += leg_scale * greeks.vega;
theta += leg_scale * greeks.theta;
rho += leg_scale * greeks.rho;
}
_ => {}
}
}
(delta, gamma, vega, theta, rho)
}
/// Evaluate `strategy_value_dense` for each point in `spot_grid`.
///
/// Returns a `Vec<f64>` of length `spot_grid.len()`.
#[allow(clippy::too_many_arguments)]
pub fn strategy_value_grid(
spot_grid: &[f64],
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
premiums: &[f64],
entry_prices: &[f64],
quantities: &[f64],
multipliers: &[f64],
time_to_expiries: &[f64],
volatilities: &[f64],
rates: &[f64],
carries: &[f64],
) -> Vec<f64> {
spot_grid
.iter()
.map(|&s| {
strategy_value_dense(
s,
instruments,
sides,
option_types,
strikes,
premiums,
entry_prices,
quantities,
multipliers,
time_to_expiries,
volatilities,
rates,
carries,
)
})
.collect()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payoff_single_call() {
let grid = vec![90.0, 100.0, 110.0, 120.0];
let out = strategy_payoff_dense(
&grid,
&[0],
&[1],
&[1],
&[100.0],
&[5.0],
&[0.0],
&[1.0],
&[1.0],
);
assert!(out[0] < 0.0); // below strike, loss = premium
assert!((out[0] - (-5.0)).abs() < 1e-10);
assert!((out[2] - 5.0).abs() < 1e-10); // at 110, intrinsic=10, net=10-5=5
}
#[test]
fn stock_leg_linear() {
let grid = vec![90.0, 100.0, 110.0];
let out = strategy_payoff_dense(
&grid,
&[2],
&[1],
&[0],
&[0.0],
&[0.0],
&[100.0],
&[1.0],
&[1.0],
);
assert!((out[0] - (-10.0)).abs() < 1e-10);
assert!((out[1] - 0.0).abs() < 1e-10);
assert!((out[2] - 10.0).abs() < 1e-10);
}
}
@@ -118,6 +118,37 @@ pub fn model_price(input: OptionEvaluation) -> f64 {
}
}
/// Put-call parity deviation: `C - P - (S·e^{-q·T} - K·e^{-r·T})`.
///
/// Returns 0.0 when no arbitrage exists. A non-zero value indicates the
/// magnitude of mispricing or data error.
pub fn put_call_parity_deviation(
call_price: f64,
put_price: f64,
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
) -> f64 {
if !call_price.is_finite()
|| !put_price.is_finite()
|| !spot.is_finite()
|| !strike.is_finite()
|| !rate.is_finite()
|| !carry.is_finite()
|| !time_to_expiry.is_finite()
|| spot <= 0.0
|| strike <= 0.0
|| time_to_expiry < 0.0
{
return f64::NAN;
}
let pv_forward = spot * (-carry * time_to_expiry).exp();
let pv_strike = strike * (-rate * time_to_expiry).exp();
call_price - put_price - (pv_forward - pv_strike)
}
/// Lower no-arbitrage bound for the option price.
pub fn price_lower_bound(contract: OptionContract) -> f64 {
match contract.model {
@@ -0,0 +1,445 @@
//! Historical (realized) volatility estimators and volatility cone.
/// Rolling close-to-close realized volatility.
///
/// Returns a `Vec<f64>` of the same length as `close`. The first `window` values
/// are NaN (we need `window` log-returns, which require `window+1` prices, so the
/// first valid output sits at index `window`).
///
/// Annualization: `sqrt(sum(r²) / window * trading_days)`.
pub fn close_to_close_vol(close: &[f64], window: usize, trading_days: f64) -> Vec<f64> {
let n = close.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n <= window {
return out;
}
// Precompute log-returns; returns[i] = ln(close[i+1] / close[i])
let mut returns = vec![f64::NAN; n - 1];
for i in 0..(n - 1) {
if close[i] > 0.0 && close[i + 1] > 0.0 {
returns[i] = (close[i + 1] / close[i]).ln();
}
}
// Rolling sum of squared returns over `window` bars.
// The output at position `end` (in the original close array) uses
// returns[end-window .. end-1], i.e. `window` returns.
for end in window..n {
let slice = &returns[(end - window)..end];
let sum_sq: f64 = slice.iter().map(|&r| r * r).sum();
let var = sum_sq / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
out
}
/// Rolling Parkinson high-low realized volatility estimator.
///
/// Returns a `Vec<f64>` of the same length as `high`. The first `window-1` values
/// are NaN.
#[allow(clippy::needless_range_loop)]
pub fn parkinson_vol(high: &[f64], low: &[f64], window: usize, trading_days: f64) -> Vec<f64> {
let n = high.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n < window || low.len() != n {
return out;
}
let factor = 1.0 / (4.0 * 2_f64.ln());
for end in (window - 1)..n {
let start = end + 1 - window;
let mut sum_sq = 0.0;
let mut valid = true;
for i in start..=end {
if high[i] <= 0.0 || low[i] <= 0.0 || !high[i].is_finite() || !low[i].is_finite() {
valid = false;
break;
}
let u = (high[i] / low[i]).ln();
sum_sq += u * u;
}
if valid {
let var = factor * sum_sq / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
}
out
}
/// Rolling Garman-Klass OHLC realized volatility estimator.
///
/// Returns a `Vec<f64>` of the same length as the inputs. The first `window-1`
/// values are NaN. All four slices must have the same length.
pub fn garman_klass_vol(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
window: usize,
trading_days: f64,
) -> Vec<f64> {
let n = open.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n {
return out;
}
let ln2 = 2_f64.ln();
// Precompute per-bar GK contributions.
let mut gk = vec![f64::NAN; n];
for i in 0..n {
let o = open[i];
let h = high[i];
let l = low[i];
let c = close[i];
if o > 0.0
&& h > 0.0
&& l > 0.0
&& c > 0.0
&& o.is_finite()
&& h.is_finite()
&& l.is_finite()
&& c.is_finite()
{
let u = (h / o).ln();
let d = (l / o).ln();
let ci = (c / o).ln();
gk[i] = 0.5 * (u - d).powi(2) - (2.0 * ln2 - 1.0) * ci * ci;
}
}
for end in (window - 1)..n {
let start = end + 1 - window;
let slice = &gk[start..=end];
if slice.iter().all(|v| v.is_finite()) {
let sum: f64 = slice.iter().sum();
let var = sum / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
}
out
}
/// Compute the Rogers-Satchell per-bar variance contribution.
fn rs_bar(open: f64, high: f64, low: f64, close: f64) -> f64 {
let u = (high / close).ln();
let d = (low / close).ln();
let uo = (high / open).ln();
let do_ = (low / open).ln();
u * uo + d * do_
}
/// Rolling Rogers-Satchell OHLC realized volatility estimator.
///
/// Returns a `Vec<f64>` of the same length as the inputs. The first `window-1`
/// values are NaN. All four slices must have the same length.
pub fn rogers_satchell_vol(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
window: usize,
trading_days: f64,
) -> Vec<f64> {
let n = open.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n {
return out;
}
// Precompute per-bar RS contributions.
let mut rs = vec![f64::NAN; n];
for i in 0..n {
let o = open[i];
let h = high[i];
let l = low[i];
let c = close[i];
if o > 0.0
&& h > 0.0
&& l > 0.0
&& c > 0.0
&& o.is_finite()
&& h.is_finite()
&& l.is_finite()
&& c.is_finite()
{
rs[i] = rs_bar(o, h, l, c);
}
}
for end in (window - 1)..n {
let start = end + 1 - window;
let slice = &rs[start..=end];
if slice.iter().all(|v| v.is_finite()) {
let sum: f64 = slice.iter().sum();
let var = sum / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
}
out
}
/// Rolling Yang-Zhang OHLC realized volatility estimator.
///
/// Handles overnight gaps. Returns a `Vec<f64>` of the same length as the inputs.
/// The first `window` values are NaN (we need `window` bars plus the prior close
/// for overnight returns, so valid output starts at index `window`).
/// All four slices must have the same length.
pub fn yang_zhang_vol(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
window: usize,
trading_days: f64,
) -> Vec<f64> {
let n = open.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n <= window || high.len() != n || low.len() != n || close.len() != n {
return out;
}
let k = 0.34 / (1.34 + (window as f64 + 1.0) / (window as f64 - 1.0).max(1e-10));
// Precompute per-bar components; index 0 has no overnight return.
// overnight[i] = ln(O_i / C_{i-1}), valid for i >= 1
// openclose[i] = ln(C_i / O_i)
// rs[i] = Rogers-Satchell for bar i
let mut overnight = vec![f64::NAN; n];
let mut openclose = vec![f64::NAN; n];
let mut rs = vec![f64::NAN; n];
for i in 0..n {
let o = open[i];
let h = high[i];
let l = low[i];
let c = close[i];
if o > 0.0
&& h > 0.0
&& l > 0.0
&& c > 0.0
&& o.is_finite()
&& h.is_finite()
&& l.is_finite()
&& c.is_finite()
{
openclose[i] = (c / o).ln();
rs[i] = rs_bar(o, h, l, c);
if i > 0 {
let prev_c = close[i - 1];
if prev_c > 0.0 && prev_c.is_finite() {
overnight[i] = (o / prev_c).ln();
}
}
}
}
// Valid windows start at index `window` (using bars [end-window+1 .. end],
// all of which have valid overnight returns since they start at index >= 1).
for end in window..n {
let start = end + 1 - window; // start >= 1 because end >= window
let o_slice = &overnight[start..=end];
let c_slice = &openclose[start..=end];
let r_slice = &rs[start..=end];
if !o_slice.iter().all(|v| v.is_finite())
|| !c_slice.iter().all(|v| v.is_finite())
|| !r_slice.iter().all(|v| v.is_finite())
{
continue;
}
let w = window as f64;
let o_sum: f64 = o_slice.iter().sum();
let o_sum_sq: f64 = o_slice.iter().map(|&x| x * x).sum();
let overnight_var = o_sum_sq / (w - 1.0) - (o_sum / w).powi(2) * w / (w - 1.0);
let c_sum: f64 = c_slice.iter().sum();
let c_sum_sq: f64 = c_slice.iter().map(|&x| x * x).sum();
let openclose_var = c_sum_sq / (w - 1.0) - (c_sum / w).powi(2) * w / (w - 1.0);
let rs_sum: f64 = r_slice.iter().sum();
let rs_var = rs_sum / w;
let yz_var = overnight_var + k * openclose_var + (1.0 - k) * rs_var;
let annualized = yz_var * trading_days;
out[end] = if annualized >= 0.0 {
annualized.sqrt()
} else {
f64::NAN
};
}
out
}
/// Summary statistics of realized vol distribution for one window length.
#[derive(Clone, Copy, Debug)]
pub struct VolConeSlice {
pub window: usize,
pub min: f64,
pub p25: f64,
pub median: f64,
pub p75: f64,
pub max: f64,
}
/// Compute a percentile via linear interpolation on a sorted slice.
///
/// `sorted` must be non-empty and already sorted ascending.
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let idx = (n - 1) as f64 * p;
let lo = idx.floor() as usize;
let hi = idx.ceil() as usize;
let frac = idx - lo as f64;
sorted[lo] + frac * (sorted[hi] - sorted[lo])
}
/// Compute vol cone: distribution of realized vols across multiple window lengths.
///
/// For each window in `windows`, the close-to-close rolling vol is computed,
/// NaN values are filtered out, and the distribution statistics (min, p25,
/// median, p75, max) are derived via linear interpolation.
pub fn vol_cone(close: &[f64], windows: &[usize], trading_days: f64) -> Vec<VolConeSlice> {
windows
.iter()
.map(|&w| {
let vols = close_to_close_vol(close, w, trading_days);
let mut valid: Vec<f64> = vols.into_iter().filter(|v| v.is_finite()).collect();
valid.sort_by(|a, b| a.partial_cmp(b).unwrap());
if valid.is_empty() {
return VolConeSlice {
window: w,
min: f64::NAN,
p25: f64::NAN,
median: f64::NAN,
p75: f64::NAN,
max: f64::NAN,
};
}
VolConeSlice {
window: w,
min: valid[0],
p25: percentile_sorted(&valid, 0.25),
median: percentile_sorted(&valid, 0.5),
p75: percentile_sorted(&valid, 0.75),
max: *valid.last().unwrap(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_prices(n: usize) -> Vec<f64> {
// simple synthetic price series
let mut prices = vec![100.0_f64; n];
for i in 1..n {
prices[i] = prices[i - 1] * (1.0 + 0.01 * (i as f64 % 7_f64 - 3.0) * 0.01);
}
prices
}
#[test]
fn close_to_close_returns_nans_for_warmup() {
let close = fake_prices(100);
let result = close_to_close_vol(&close, 20, 252.0);
assert_eq!(result.len(), 100);
// first 20 values should be NaN (window-1 of returns warmup + 1 for diff)
for i in 0..20 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[20].is_finite());
}
#[test]
fn parkinson_vol_is_positive() {
let close = fake_prices(100);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = parkinson_vol(&high, &low, 20, 252.0);
for v in result.iter().skip(19) {
assert!(v.is_finite() && *v >= 0.0);
}
}
#[test]
fn vol_cone_is_ordered() {
let close = fake_prices(300);
let cones = vol_cone(&close, &[20, 60], 252.0);
assert_eq!(cones.len(), 2);
for cone in &cones {
assert!(cone.min <= cone.p25);
assert!(cone.p25 <= cone.median);
assert!(cone.median <= cone.p75);
assert!(cone.p75 <= cone.max);
}
}
#[test]
fn garman_klass_returns_nans_for_warmup() {
let close = fake_prices(50);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = garman_klass_vol(&close, &high, &low, &close, 10, 252.0);
assert_eq!(result.len(), 50);
for i in 0..9 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[9].is_finite());
}
#[test]
fn rogers_satchell_returns_nans_for_warmup() {
let close = fake_prices(50);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = rogers_satchell_vol(&close, &high, &low, &close, 10, 252.0);
assert_eq!(result.len(), 50);
for i in 0..9 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[9].is_finite());
}
#[test]
fn yang_zhang_returns_nans_for_warmup() {
let close = fake_prices(50);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = yang_zhang_vol(&close, &high, &low, &close, 10, 252.0);
assert_eq!(result.len(), 50);
for i in 0..10 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[10].is_finite());
}
#[test]
fn mismatched_lengths_return_all_nan() {
let a = vec![100.0_f64; 20];
let b = vec![101.0_f64; 15]; // wrong length
let result = parkinson_vol(&a, &b, 5, 252.0);
assert!(result.iter().all(|v| v.is_nan()));
}
#[test]
fn window_larger_than_data_returns_all_nan() {
let close = fake_prices(10);
let result = close_to_close_vol(&close, 20, 252.0);
assert!(result.iter().all(|v| v.is_nan()));
}
}
@@ -202,6 +202,35 @@ pub fn term_structure_slope(tenors: &[f64], atm_ivs: &[f64]) -> f64 {
regression_slope(tenors, atm_ivs)
}
/// Expected ±1σ move over `days_to_expiry` calendar days.
///
/// Returns `(lower_move, upper_move)` as absolute changes from `spot`.
/// Example: if spot=100 and upper_move=5.0 then the 1σ upper bound is 105.
///
/// Uses the log-normal approximation: `spot × e^{±σ√(days/trading_days)} spot`.
pub fn expected_move(
spot: f64,
iv: f64,
days_to_expiry: f64,
trading_days_per_year: f64,
) -> (f64, f64) {
if !spot.is_finite()
|| !iv.is_finite()
|| !days_to_expiry.is_finite()
|| !trading_days_per_year.is_finite()
|| spot <= 0.0
|| iv < 0.0
|| days_to_expiry < 0.0
|| trading_days_per_year <= 0.0
{
return (f64::NAN, f64::NAN);
}
let sigma_sqrt_t = iv * (days_to_expiry / trading_days_per_year).sqrt();
let upper = spot * sigma_sqrt_t.exp() - spot;
let lower = spot * (-sigma_sqrt_t).exp() - spot;
(lower, upper)
}
#[cfg(test)]
mod tests {
use super::{atm_iv, smile_metrics, term_structure_slope};
+7 -61
View File
@@ -38,27 +38,10 @@ pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: u
return;
}
#[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;
// 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]);
let tp_f64 = timeperiod as f64;
dest[dest_offset + timeperiod - 1] = window_sum / tp_f64;
@@ -124,46 +107,9 @@ 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.
#[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)
};
// 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]);
result[timeperiod - 1] = t / denom;
+161
View File
@@ -0,0 +1,161 @@
//! Runtime-dispatched SIMD primitives.
//!
//! Each public reduction here is compiled into several CPU-feature-specific
//! variants (baseline, SSE, AVX2/FMA, AVX-512 on x86_64; NEON on aarch64; …)
//! by [`multiversion`]. The fastest variant the *current* CPU supports is
//! chosen at runtime via CPUID. This gives one binary that:
//!
//! * runs on **any** CPU of the target architecture — no illegal-instruction
//! (SIGILL) crashes on pre-AVX2 chips, unlike a static `-C target-cpu=…`;
//! * still uses wide vector units where the hardware has them.
//!
//! The hot loops accumulate into **independent lanes** before a final
//! horizontal combine. That is what lets the optimizer auto-vectorize them:
//! a plain sequential `iter().sum()` is a dependency chain LLVM may not
//! reorder (doing so would change floating-point rounding). As a consequence
//! these results differ from a strict left-to-right sum by a few ULPs — well
//! inside every indicator's documented tolerance.
/// Number of independent accumulator lanes. Eight `f64` lanes cover the
/// widest target we dispatch to (AVX-512 = 8×f64); narrower targets (AVX2,
/// NEON) simply use a subset.
#[cfg(feature = "simd")]
const LANES: usize = 8;
/// Sum of a slice of `f64`, runtime-dispatched.
#[cfg(feature = "simd")]
#[multiversion::multiversion(targets = "simd")]
pub(crate) fn sum(data: &[f64]) -> f64 {
let mut acc = [0.0f64; LANES];
let mut chunks = data.chunks_exact(LANES);
for chunk in &mut chunks {
for (a, &v) in acc.iter_mut().zip(chunk) {
*a += v;
}
}
let remainder: f64 = chunks.remainder().iter().sum();
remainder + acc.iter().sum::<f64>()
}
/// Pure-scalar fallback when the `simd` feature is disabled.
#[cfg(not(feature = "simd"))]
pub(crate) fn sum(data: &[f64]) -> f64 {
data.iter().sum()
}
/// Weighted-moving-average seed for the first window.
///
/// Returns `(t, s)` where `t = Σ data[k] * (k + 1)` (1-based linear weights)
/// and `s = Σ data[k]`. Used to seed the O(n) WMA recurrence.
#[cfg(feature = "simd")]
#[multiversion::multiversion(targets = "simd")]
pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) {
// Lane-local accumulation (same idea as `sum`) so each CPU-feature clone
// can vectorize: `t` weights each value by its 1-based global index.
let mut t_acc = [0.0f64; LANES];
let mut s_acc = [0.0f64; LANES];
let mut chunks = data.chunks_exact(LANES);
let mut base = 0.0f64; // global index of this chunk's first element
for chunk in &mut chunks {
for (lane, ((t, s), &v)) in t_acc
.iter_mut()
.zip(s_acc.iter_mut())
.zip(chunk)
.enumerate()
{
*t += v * (base + lane as f64 + 1.0);
*s += v;
}
base += LANES as f64;
}
let mut t = 0.0;
let mut s = 0.0;
for (i, &v) in chunks.remainder().iter().enumerate() {
t += v * (base + i as f64 + 1.0);
s += v;
}
(t + t_acc.iter().sum::<f64>(), s + s_acc.iter().sum::<f64>())
}
/// Pure-scalar fallback when the `simd` feature is disabled.
#[cfg(not(feature = "simd"))]
pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) {
let mut t = 0.0;
let mut s = 0.0;
for (k, &v) in data.iter().enumerate() {
t += v * (k + 1) as f64;
s += v;
}
(t, s)
}
#[cfg(test)]
mod tests {
use super::*;
/// Strict sequential reference — the ground truth we compare against.
fn naive_sum(data: &[f64]) -> f64 {
data.iter().sum()
}
fn naive_wma_seed(data: &[f64]) -> (f64, f64) {
let t = data
.iter()
.enumerate()
.map(|(k, &v)| v * (k + 1) as f64)
.sum();
let s = data.iter().sum();
(t, s)
}
/// Deterministic test vectors spanning the lane boundaries: empty, a
/// partial chunk (< LANES), an exact multiple, and an exact-multiple +
/// remainder. This exercises every branch of the chunked reduction.
fn cases() -> Vec<Vec<f64>> {
let big: Vec<f64> = (0..1000).map(|i| (i as f64) * 0.5 - 123.0).collect();
vec![
vec![],
vec![42.0],
vec![1.0, 2.0, 3.0], // < LANES
(1..=8).map(|i| i as f64).collect(), // exactly LANES
(1..=17).map(|i| i as f64).collect(), // LANES*2 + 1
big,
]
}
#[test]
fn sum_matches_sequential_within_tolerance() {
for data in cases() {
let got = sum(&data);
let want = naive_sum(&data);
assert!(
(got - want).abs() <= 1e-9 * want.abs().max(1.0),
"sum mismatch: got {got}, want {want}, len {}",
data.len()
);
}
}
#[test]
fn wma_seed_matches_sequential_within_tolerance() {
for data in cases() {
let (t, s) = wma_seed(&data);
let (wt, ws) = naive_wma_seed(&data);
assert!(
(t - wt).abs() <= 1e-9 * wt.abs().max(1.0),
"wma t mismatch: got {t}, want {wt}, len {}",
data.len()
);
assert!(
(s - ws).abs() <= 1e-9 * ws.abs().max(1.0),
"wma s mismatch: got {s}, want {ws}, len {}",
data.len()
);
}
}
#[test]
fn sum_empty_is_zero() {
assert_eq!(sum(&[]), 0.0);
}
}
+230
View File
@@ -247,6 +247,118 @@ 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::*;
@@ -259,4 +371,122 @@ 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);
}
}
+13 -1
View File
@@ -53,7 +53,19 @@ skip = []
db-urls = ["https://github.com/rustsec/advisory-db"]
# Deny known security vulnerabilities
version = 2
ignore = []
ignore = [
# pyo3 0.25 advisories — fix is pyo3 >=0.29, which is a large API
# migration (IntoPy/ToPyObject were removed in 0.26). Ignored here
# because ferro-ta does NOT use either affected code path:
# * RUSTSEC-2026-0176 — OOB read in PyList/PyTuple nth/nth_back
# iterators: the crate has zero PyList/PyTuple iterator usage.
# * RUSTSEC-2026-0177 — missing Sync bound on
# PyCFunction::new_closure: the crate uses #[pyfunction], never
# new_closure.
# Tracked for removal once the pyo3 0.29 upgrade lands.
"RUSTSEC-2026-0176",
"RUSTSEC-2026-0177",
]
# ---------------------------------------------------------------------------
# Sources — only allow crates from crates.io and our own path deps
+304 -7
View File
@@ -1,8 +1,8 @@
{
"surfaces": {
"python": {
"indicator_count": 208,
"method_count": 447,
"indicator_count": 211,
"method_count": 467,
"categories": [
"aggregation",
"alerts",
@@ -131,6 +131,13 @@
"doc": "",
"params": []
},
{
"name": "BATCH_DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{
"name": "BBANDS",
"category": "overlap",
@@ -656,6 +663,20 @@
"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",
@@ -1736,6 +1757,13 @@
"doc": "",
"params": []
},
{
"name": "stock_leg_payoff",
"category": "derivatives_payoff",
"module": "ferro_ta.analysis.derivatives_payoff",
"doc": "",
"params": []
},
{
"name": "strategy_payoff",
"category": "derivatives_payoff",
@@ -1743,6 +1771,13 @@
"doc": "",
"params": []
},
{
"name": "strategy_value",
"category": "derivatives_payoff",
"module": "ferro_ta.analysis.derivatives_payoff",
"doc": "",
"params": []
},
{
"name": "CHANDELIER_EXIT",
"category": "extended",
@@ -2289,6 +2324,13 @@
"doc": "",
"params": []
},
{
"name": "ExtendedGreeks",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "OptionGreeks",
"category": "options",
@@ -2303,6 +2345,20 @@
"doc": "",
"params": []
},
{
"name": "VolCone",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "american_option_price",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "black_76_price",
"category": "options",
@@ -2317,6 +2373,55 @@
"doc": "",
"params": []
},
{
"name": "close_to_close_vol",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "digital_option_greeks",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "digital_option_price",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "early_exercise_premium",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "expected_move",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "extended_greeks",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "garman_klass_vol",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "greeks",
"category": "options",
@@ -2366,6 +2471,27 @@
"doc": "",
"params": []
},
{
"name": "parkinson_vol",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "put_call_parity_deviation",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "rogers_satchell_vol",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "select_strike",
"category": "options",
@@ -2387,6 +2513,20 @@
"doc": "",
"params": []
},
{
"name": "vol_cone",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "yang_zhang_vol",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{
"name": "DerivativesStrategy",
"category": "options_strategy",
@@ -3164,6 +3304,13 @@
"doc": "",
"params": []
},
{
"name": "BATCH_DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{
"name": "BETA",
"category": "statistic",
@@ -3178,6 +3325,20 @@
"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",
@@ -4616,7 +4777,7 @@
]
},
"rust_core": {
"public_function_count": 331,
"public_function_count": 351,
"functions": [
{
"module": "aggregation",
@@ -5288,6 +5449,16 @@
"function": "willr",
"file": "momentum.rs"
},
{
"module": "options.american",
"function": "american_price_baw",
"file": "options/american.rs"
},
{
"module": "options.american",
"function": "early_exercise_premium",
"file": "options/american.rs"
},
{
"module": "options.chain",
"function": "atm_index",
@@ -5308,16 +5479,36 @@
"function": "select_strike_by_offset",
"file": "options/chain.rs"
},
{
"module": "options.digital",
"function": "digital_greeks",
"file": "options/digital.rs"
},
{
"module": "options.digital",
"function": "digital_price",
"file": "options/digital.rs"
},
{
"module": "options.greeks",
"function": "black_76_greeks",
"file": "options/greeks.rs"
},
{
"module": "options.greeks",
"function": "black_scholes_extended_greeks",
"file": "options/greeks.rs"
},
{
"module": "options.greeks",
"function": "black_scholes_greeks",
"file": "options/greeks.rs"
},
{
"module": "options.greeks",
"function": "model_extended_greeks",
"file": "options/greeks.rs"
},
{
"module": "options.greeks",
"function": "model_greeks",
@@ -5363,6 +5554,26 @@
"function": "pdf",
"file": "options/normal.rs"
},
{
"module": "options.payoff",
"function": "aggregate_greeks_dense",
"file": "options/payoff.rs"
},
{
"module": "options.payoff",
"function": "strategy_payoff_dense",
"file": "options/payoff.rs"
},
{
"module": "options.payoff",
"function": "strategy_value_dense",
"file": "options/payoff.rs"
},
{
"module": "options.payoff",
"function": "strategy_value_grid",
"file": "options/payoff.rs"
},
{
"module": "options.pricing",
"function": "black_76_price",
@@ -5388,11 +5599,51 @@
"function": "price_upper_bound",
"file": "options/pricing.rs"
},
{
"module": "options.pricing",
"function": "put_call_parity_deviation",
"file": "options/pricing.rs"
},
{
"module": "options.realized_vol",
"function": "close_to_close_vol",
"file": "options/realized_vol.rs"
},
{
"module": "options.realized_vol",
"function": "garman_klass_vol",
"file": "options/realized_vol.rs"
},
{
"module": "options.realized_vol",
"function": "parkinson_vol",
"file": "options/realized_vol.rs"
},
{
"module": "options.realized_vol",
"function": "rogers_satchell_vol",
"file": "options/realized_vol.rs"
},
{
"module": "options.realized_vol",
"function": "vol_cone",
"file": "options/realized_vol.rs"
},
{
"module": "options.realized_vol",
"function": "yang_zhang_vol",
"file": "options/realized_vol.rs"
},
{
"module": "options.surface",
"function": "atm_iv",
"file": "options/surface.rs"
},
{
"module": "options.surface",
"function": "expected_move",
"file": "options/surface.rs"
},
{
"module": "options.surface",
"function": "linear_interpolate",
@@ -5978,6 +6229,16 @@
"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",
@@ -6276,16 +6537,18 @@
]
},
"wasm_node": {
"export_count": 205,
"export_count": 222,
"exports": [
"ad",
"adosc",
"adx",
"adx_all",
"adxr",
"aggregate_greeks_dense",
"aggregate_tick_bars",
"aggregate_time_bars",
"aggregate_volume_bars_ticks",
"american_price",
"annualized_basis",
"apo",
"aroon",
@@ -6318,6 +6581,7 @@
"check_cross",
"check_threshold",
"choppiness_index",
"close_to_close_vol",
"cmo",
"collect_alert_bars",
"compose_rank",
@@ -6330,17 +6594,24 @@
"curve_summary",
"dema",
"detect_breaks_cusum",
"digital_greeks",
"digital_price",
"donchian",
"drawdown_series",
"dtw_distance",
"dx",
"early_exercise_premium",
"ema",
"exchange_charges_rate",
"expected_move",
"extended_greeks",
"extract_trades",
"fast_period",
"flat_per_order",
"forward_fill_nan",
"funding_cumulative_pnl",
"futures_basis",
"garman_klass_vol",
"gst_rate",
"half_kelly_fraction",
"ht_dcperiod",
@@ -6396,6 +6667,7 @@
"obv",
"ohlcv_agg",
"parity_gap",
"parkinson_vol",
"per_lot",
"period",
"pivot_points",
@@ -6405,6 +6677,7 @@
"ppo",
"price_lower_bound",
"price_upper_bound",
"put_call_parity_deviation",
"rank_series",
"rank_values",
"rate_of_value",
@@ -6418,6 +6691,7 @@
"rocp",
"rocr",
"rocr100",
"rogers_satchell_vol",
"roll_yield",
"rolling_beta",
"rolling_max",
@@ -6455,6 +6729,8 @@
"stoch",
"stochf",
"stochrsi",
"strategy_payoff_dense",
"strategy_value_grid",
"stt_on_buy",
"stt_on_sell",
"stt_rate",
@@ -6474,6 +6750,7 @@
"typprice",
"ultosc",
"var",
"vol_cone",
"volume_bars",
"vwap",
"vwma",
@@ -6482,14 +6759,15 @@
"weighted_continuous",
"willr",
"wma",
"yang_zhang_vol",
"zscore_series"
]
}
},
"parity_summary": {
"python_indicator_count": 207,
"wasm_export_count": 205,
"common_python_wasm_count": 91,
"python_indicator_count": 210,
"wasm_export_count": 222,
"common_python_wasm_count": 92,
"common_python_wasm": [
"ad",
"adosc",
@@ -6518,6 +6796,7 @@
"dema",
"detect_breaks_cusum",
"donchian",
"dtw_distance",
"dx",
"ema",
"ht_dcperiod",
@@ -6592,6 +6871,7 @@
"asin",
"atan",
"batch_apply",
"batch_dtw",
"beta",
"cdl2crows",
"cdl3blackcrows",
@@ -6661,6 +6941,7 @@
"cosh",
"div",
"drawdown",
"dtw",
"exp",
"feature_matrix",
"floor",
@@ -6703,9 +6984,11 @@
],
"wasm_only_vs_python": [
"adx_all",
"aggregate_greeks_dense",
"aggregate_tick_bars",
"aggregate_time_bars",
"aggregate_volume_bars_ticks",
"american_price",
"annualized_basis",
"atm_index",
"atm_iv",
@@ -6723,19 +7006,26 @@
"bottom_n_indices",
"calendar_spreads",
"carry_spread",
"close_to_close_vol",
"compose_rank",
"compose_weighted",
"compute_performance_metrics",
"curve_slope",
"curve_summary",
"digital_greeks",
"digital_price",
"drawdown_series",
"early_exercise_premium",
"exchange_charges_rate",
"expected_move",
"extended_greeks",
"extract_trades",
"fast_period",
"flat_per_order",
"forward_fill_nan",
"funding_cumulative_pnl",
"futures_basis",
"garman_klass_vol",
"gst_rate",
"half_kelly_fraction",
"implied_carry_rate",
@@ -6763,10 +7053,12 @@
"new",
"ohlcv_agg",
"parity_gap",
"parkinson_vol",
"per_lot",
"period",
"price_lower_bound",
"price_upper_bound",
"put_call_parity_deviation",
"rank_series",
"rank_values",
"rate_of_value",
@@ -6774,6 +7066,7 @@
"ratio_adjusted_continuous",
"regulatory_charges_rate",
"relative_strength",
"rogers_satchell_vol",
"roll_yield",
"rolling_beta",
"rolling_max",
@@ -6803,6 +7096,8 @@
"spread",
"stamp_duty_rate",
"stitch_chunks",
"strategy_payoff_dense",
"strategy_value_grid",
"stt_on_buy",
"stt_on_sell",
"stt_rate",
@@ -6813,8 +7108,10 @@
"trade_stats",
"trim_overlap",
"trix_indicator",
"vol_cone",
"walk_forward_indices",
"weighted_continuous",
"yang_zhang_vol",
"zscore_series"
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
Release Notes
=============
These docs track package version ``1.1.2``.
These docs track package version ``1.2.0``.
1.1.0-audit (2026-03-28)
------------------------
+198 -42
View File
@@ -1,50 +1,187 @@
# Derivatives Analytics
`ferro-ta` now includes a Rust-backed derivatives analytics layer focused on
research, simulation, and risk analysis.
`ferro-ta` ships a Rust-backed derivatives analytics layer focused on
research, simulation, and risk analysis. All functions are implemented in
Rust core and exposed to Python (via PyO3) and WebAssembly (via wasm-bindgen).
---
## Modules
- `ferro_ta.analysis.options`
- Black-Scholes-Merton and Black-76 pricing
- Delta, gamma, vega, theta, rho
- Implied volatility inversion with guarded Newton + bisection fallback
- IV rank / percentile / z-score
- Smile metrics: ATM IV, 25-delta risk reversal, butterfly, skew slope, convexity
- Chain helpers: moneyness labels and strike selection by offset or delta
- `ferro_ta.analysis.futures`
- Synthetic forwards and parity diagnostics
- Basis, annualized basis, implied carry, carry spread
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted
- Curve analytics: calendar spreads, slope, contango summary
- `ferro_ta.analysis.options_strategy`
- Typed strategy schemas for expiry selectors, strike selectors, multi-leg presets,
risk controls, cost assumptions, and simulation limits
- `ferro_ta.analysis.derivatives_payoff`
- Multi-leg payoff aggregation
- Portfolio-level Greeks aggregation across option and futures legs
### `ferro_ta.analysis.options`
| Category | Functions |
|---|---|
| **Pricing** | `black_scholes_price`, `black_76_price`, `option_price` |
| **Greeks** | `greeks`, `extended_greeks` |
| **Implied vol** | `implied_volatility`, `iv_rank`, `iv_percentile`, `iv_zscore` |
| **Digital options** | `digital_option_price`, `digital_option_greeks` |
| **American options** | `american_option_price`, `early_exercise_premium` |
| **Smile / surface** | `smile_metrics`, `term_structure_slope`, `expected_move` |
| **Chain helpers** | `label_moneyness`, `select_strike` |
| **Realised vol** | `close_to_close_vol`, `parkinson_vol`, `garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol` |
| **Vol cone** | `vol_cone` |
| **Diagnostics** | `put_call_parity_deviation` |
### `ferro_ta.analysis.futures`
- Synthetic forwards and parity diagnostics
- Basis, annualized basis, implied carry, carry spread
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted
- Curve analytics: calendar spreads, slope, contango summary
### `ferro_ta.analysis.options_strategy`
Typed strategy schemas: expiry selectors, strike selectors, multi-leg presets
(`STRADDLE`, `STRANGLE`, `IRON_CONDOR`, `BULL_CALL_SPREAD`, `BEAR_PUT_SPREAD`),
risk controls, cost assumptions, and simulation limits.
### `ferro_ta.analysis.derivatives_payoff`
Multi-leg payoff and Greeks aggregation supporting **option**, **future**, and
**stock** instrument types.
| Function | Description |
|---|---|
| `option_leg_payoff` | Expiry P/L for a single option leg |
| `futures_leg_payoff` | Linear P/L for a futures leg |
| `stock_leg_payoff` | Linear P/L for a stock/equity leg |
| `strategy_payoff` | Aggregate expiry payoff across all legs |
| `strategy_value` | Pre-expiry BSM mid-price value of a multi-leg strategy |
| `aggregate_greeks` | Portfolio-level Greeks across option, futures, and stock legs |
---
## Model conventions
- `model="bsm"` expects the underlying input to be spot and `carry` to represent
a continuous dividend yield or generic carry term.
- `model="black76"` expects the underlying input to be the forward price.
- Volatility and rates use decimal units:
- `0.20` means 20% annualized volatility
- `0.05` means 5% annualized rate
- `time_to_expiry` is expressed in years.
| Parameter | Convention |
|---|---|
| `model="bsm"` | Underlying is spot; `carry` = continuous dividend yield |
| `model="black76"` | Underlying is the forward price |
| `volatility` / `rate` / `carry` | Decimal annual (e.g. `0.20` = 20 %, `0.05` = 5 %) |
| `time_to_expiry` | Years (e.g. `0.25` = 3 months) |
---
## Quick examples
### BSM pricing and Greeks
```python
from ferro_ta.analysis.options import greeks, implied_volatility, option_price
price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call")
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
print(price, iv, g.delta)
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call")
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
print(price, iv, g.delta, g.gamma)
```
### Extended (second-order) Greeks
```python
from ferro_ta.analysis.options import extended_greeks
eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
print(eg.vanna, eg.volga, eg.charm, eg.speed, eg.color)
```
### Digital options
```python
from ferro_ta.analysis.options import digital_option_price, digital_option_greeks
# Cash-or-nothing call at ATM ≈ e^{-rT} * N(d2) ≈ 0.53
price = digital_option_price(100.0, 100.0, 0.05, 1.0, 0.20,
option_type="call", digital_type="cash_or_nothing")
g = digital_option_greeks(100.0, 100.0, 0.05, 1.0, 0.20,
option_type="call", digital_type="cash_or_nothing")
print(price, g.delta, g.gamma, g.vega)
```
### American options (BAW approximation)
```python
from ferro_ta.analysis.options import american_option_price, early_exercise_premium
# American put — may have meaningful early exercise premium
american = american_option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
premium = early_exercise_premium(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
print(american, premium)
```
### Historical volatility estimators
```python
import numpy as np
from ferro_ta.analysis.options import (
close_to_close_vol, garman_klass_vol, parkinson_vol,
rogers_satchell_vol, yang_zhang_vol,
)
# Assume daily OHLC arrays of length N
open_p, high_p, low_p, close_p = ... # numpy arrays
ctc = close_to_close_vol(close_p, window=20) # close-only
park = parkinson_vol(high_p, low_p, window=20) # high-low
gk = garman_klass_vol(open_p, high_p, low_p, close_p, window=20)
rs = rogers_satchell_vol(open_p, high_p, low_p, close_p, window=20)
yz = yang_zhang_vol(open_p, high_p, low_p, close_p, window=20)
```
### Volatility cone
```python
from ferro_ta.analysis.options import vol_cone
cone = vol_cone(close_p, windows=(21, 42, 63, 126, 252))
# Overlay current IV against the cone to gauge richness/cheapness
for w, med in zip(cone.windows, cone.median):
print(f"window={int(w):3d} median_rv={med:.1%}")
```
### Put-call parity check
```python
from ferro_ta.analysis.options import option_price, put_call_parity_deviation
call = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
put = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
dev = put_call_parity_deviation(call, put, 100.0, 100.0, 0.05, 1.0)
# dev ≈ 0.0 for BSM-consistent prices; non-zero signals stale/mismatched quotes
```
### Expected move
```python
from ferro_ta.analysis.options import expected_move
lower, upper = expected_move(100.0, 0.20, days_to_expiry=30)
print(f"Expected ±1σ range: [{100+lower:.2f}, {100+upper:.2f}]")
```
### Multi-leg strategies with stock
```python
import numpy as np
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff, strategy_value
# Covered Call: long 100 shares + short 1 OTM call
spot_grid = np.linspace(80, 130, 100)
legs = [
PayoffLeg("stock", "long", entry_price=100.0),
PayoffLeg("option", "short", option_type="call",
strike=110.0, premium=3.0, volatility=0.20, time_to_expiry=0.25),
]
# Expiry P/L
payoff = strategy_payoff(spot_grid, legs=legs)
# Pre-expiry BSM value (T=3 months remaining)
value = strategy_value(spot_grid, legs=legs, time_to_expiry=0.25, volatility=0.20)
```
### Futures analytics
```python
from ferro_ta.analysis.futures import basis, curve_summary
@@ -52,19 +189,38 @@ print(basis(100.0, 103.0))
print(curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0]))
```
```python
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff
---
legs = [
PayoffLeg("option", "long", option_type="call", strike=100.0, premium=5.0),
PayoffLeg("future", "long", entry_price=100.0),
]
grid = [90.0, 100.0, 110.0]
print(strategy_payoff(grid, legs=legs))
```
## Instrument types in `PayoffLeg` / `StrategyLeg`
| `instrument` | Required fields | Payoff |
|---|---|---|
| `"option"` | `option_type`, `strike`, `expiry_selector`, `strike_selector` | `max(φ(SK), 0) premium` |
| `"future"` | `entry_price` | `S entry_price` |
| `"stock"` | `entry_price` | `S entry_price` (identical to future, no margin) |
---
## Volatility estimator efficiency comparison
| Estimator | Relative efficiency vs close-to-close | Handles overnight gaps |
|---|---|---|
| Close-to-close | 1× (baseline) | N/A (uses close only) |
| Parkinson | ~5× | No |
| Garman-Klass | ~7.4× | No |
| Rogers-Satchell | ~8× | No |
| Yang-Zhang | ~14× | Yes |
*Use Yang-Zhang when you have overnight gaps (futures, crypto). Use Parkinson
or Garman-Klass for continuous trading sessions.*
---
## Notes
- Existing `iv_rank`, `iv_percentile`, and `iv_zscore` names are preserved.
- The derivatives layer is analytics-only: there is no broker connectivity,
order routing, or execution workflow in this API.
- All existing function names (`iv_rank`, `iv_percentile`, `iv_zscore`, `greeks`,
`option_price`, etc.) are preserved — fully backward compatible.
- The derivatives layer is analytics-only: no broker connectivity, order routing,
or execution workflow.
- WASM: all functions in this layer are also exported as WebAssembly bindings
(see `wasm/src/lib.rs`).
+80
View File
@@ -0,0 +1,80 @@
# Dynamic Time Warping
ferro-ta ships three DTW entry points. Pick the one that matches your
workload — the distance-only path is measurably faster than the one that
reconstructs the warping path, and `BATCH_DTW` parallelises over rows.
## Quick reference
| Function | Returns | When to use |
|---|---|---|
| `DTW_DISTANCE(a, b, window=None)` | `float` | You only need the distance. Fastest. |
| `DTW(a, b, window=None)` | `(float, ndarray[N, 2])` | You need the alignment path for plotting or downstream analysis. |
| `BATCH_DTW(matrix, reference, window=None)` | `ndarray[N]` | You have N candidate series and one reference; uses rayon. |
## Distance convention
ferro-ta's DTW uses squared-Euclidean local cost accumulated along the
optimal path, with a single `sqrt()` applied at the end. This matches
`dtaidistance.dtw.distance()` to within floating-point tolerance (parity
tests assert numerical agreement, not bitwise identity). Example:
```python
>>> import ferro_ta as fta
>>> fta.DTW_DISTANCE([0.0, 1.0, 2.0], [1.0, 2.0, 3.0])
1.4142135623730951 # == sqrt(2), same as dtaidistance
```
If you are migrating from a library that uses absolute-difference local
cost without the final sqrt (e.g. `fastdtw`'s default), your numbers will
not line up. That is a choice ferro-ta made for parity with the
scientific-Python ecosystem.
## Window constraint (Sakoe-Chiba band)
Passing `window=w` constrains the DP to cells where `|i - j| < w`. This
turns the O(n·m) cost into O(n·w), which is typically a 520× speedup for
realistic `w`. A narrower band can only *increase* the distance, so
`window=` is safe to use whenever your series are roughly aligned.
```python
# Unconstrained
fta.DTW_DISTANCE(a, b)
# Constrained: warping may shift up to 5 positions
fta.DTW_DISTANCE(a, b, window=5)
```
## Batch usage
`BATCH_DTW` compares each row of a 2-D matrix against one reference
series, in parallel:
```python
import numpy as np
import ferro_ta as fta
reference = np.random.random(500)
candidates = np.random.random((1000, 500))
distances = fta.BATCH_DTW(candidates, reference, window=20)
nearest = int(np.argmin(distances))
```
Parallelism is via rayon; no thread-pool configuration is needed on the
Python side. For the sequence lengths ferro-ta targets (thousands of
bars, hundreds to low thousands of candidates), batch-parallel classic
DTW beats FastDTW-style approximations.
## Edge cases
- **Empty input:** raises `FerroTAInputError`.
- **NaN in input:** propagates to the output (matches IEEE 754). Call
`ferro_ta.core.exceptions.check_finite()` first if you want to fail
loudly instead.
- **Different-length series:** fully supported. The path array length
is bounded by `max(n, m) <= len(path) <= n + m - 1`.
## See also
- `tests/unit/indicators/test_statistic.py` — parity tests against `dtaidistance`.
+92
View File
@@ -0,0 +1,92 @@
# SIMD acceleration
ferro-ta accelerates hot reductions with **runtime CPU-feature dispatch**
via the [`multiversion`](https://crates.io/crates/multiversion) crate. Each
dispatched function is compiled into several variants — baseline, SSE,
AVX2/FMA, AVX-512 on x86_64; NEON on aarch64 — and the fastest one the
**current** CPU supports is chosen at load time via CPUID.
## Why dispatch instead of `-C target-cpu`
A static `RUSTFLAGS=-C target-cpu=x86-64-v3` build *requires* AVX2 on the
running CPU; on an older chip it crashes with an illegal instruction
(SIGILL). Runtime dispatch instead ships every code path in one binary and
picks at runtime, so a single artifact:
- runs on **any** CPU of the target architecture (no SIGILL on pre-AVX2
hardware), and
- still uses wide vector units where the hardware has them.
That property is what lets the **same** wheel / Docker image / crate run
across a heterogeneous fleet.
## When it helps
SIMD helps indicators whose inner loop is a reduction over contiguous
`f64` data — e.g. the initial window sum that seeds SMA, the `(T, S)` seed
for WMA, and similar fixed-window reductions. It does **not** help:
- The O(n) streaming recurrences (`window_sum += new - old`): each step
depends on the previous one, so they are inherently sequential.
- Branchy inner loops (SAR, candlestick patterns).
- Streaming classes (a single-bar update is one or two ops).
The shared primitives live in `crates/ferro_ta_core/src/simd.rs`
(`sum`, `wma_seed`). They accumulate into independent lanes before a final
horizontal combine — that lane independence is what allows the optimizer to
vectorize each CPU-feature variant. A consequence is that results differ
from a strict left-to-right sum by a few ULPs, well inside every
indicator's documented tolerance.
## The `simd` feature
Dispatch is gated behind the `simd` Cargo feature, which is **on by
default**:
```bash
# default build — runtime dispatch enabled
cargo build -p ferro_ta_core --release
# pure-scalar build (debugging / baseline benchmarking)
cargo build -p ferro_ta_core --release --no-default-features
```
For Python, wheels published to PyPI are built with the default features,
so `pip install ferro-ta` ships the dispatched fast path with no action on
your part. To build a pure-scalar extension from source:
```bash
maturin develop --release --no-default-features
```
## Measured speedups
The nightly `benchmarks/bench_simd.py` job (see
`.github/workflows/nightly-bench.yml`) builds the extension twice — once
with `--no-default-features` (pure scalar) and once with `--features simd`
(dispatch) — and reports the per-indicator delta. Numbers are regenerated
on every run and vary with hardware; treat any table in a PR as a snapshot,
not a contract. The dispatched kernels here target correctness-preserving
reductions, so gains are modest on the sliding-window indicators and larger
on full-array reductions.
## Adding a SIMD-optimized indicator
1. Write and test the **scalar** implementation first — it is the ground
truth.
2. If the hot path is a contiguous `f64` reduction, route it through a
`crate::simd` primitive, or wrap a new helper in
`#[multiversion::multiversion(targets = "simd")]` with the loop body
accumulating into independent lanes.
3. Add a parity test comparing the dispatched result against the strict
scalar reference within tolerance (see `simd.rs` tests for the pattern).
4. Benchmark scalar vs dispatch via `bench_simd.py`. Only keep the SIMD
path if it wins — alignment and tail-handling overhead can make a naive
vectorization *lose* to scalar.
## See also
- `crates/ferro_ta_core/src/simd.rs` — dispatched primitives and tests.
- `benches/indicators.rs` — criterion suite.
- `crates/ferro_ta_core/Cargo.toml` `[features] simd = ["dep:multiversion"]`
— the gate (default-on).
+1 -1
View File
@@ -180,7 +180,7 @@ For source builds, packaging details, and platform notes, see
Release status
--------------
These docs track package version ``1.1.2``.
These docs track package version ``1.2.0``.
- Release notes by version: :doc:`changelog`
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
+4 -2
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "ferro-ta"
version = "1.1.2"
version = "1.2.0"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
readme = "README.md"
license = { text = "MIT" }
@@ -67,6 +67,7 @@ dev = [
"matplotlib>=3.5",
"fastapi>=0.135.1",
"httpx>=0.24",
"scipy>=1.10",
]
[project.urls]
@@ -83,7 +84,7 @@ omit = ["*/_ferro_ta*", "*/mcp/*"]
[tool.coverage.report]
show_missing = true
fail_under = 65
fail_under = 80
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
@@ -162,4 +163,5 @@ dev = [
"pyyaml>=6.0",
"pandas-ta>=0.3; python_version >= '3.12'",
"ta>=0.10",
"scipy>=1.15.3",
]
+6
View File
@@ -110,8 +110,14 @@ __version__ = _detect_version()
# ---------------------------------------------------------------------------
from ferro_ta.core.exceptions import ( # noqa: F401
FerroTAError,
FerroTaError,
FerroTAInputError,
FerroTAValueError,
InsufficientDataError,
InvalidInputError,
InvalidPeriodError,
LengthMismatchError,
NumericConvergenceError,
)
# ---------------------------------------------------------------------------
+155 -5
View File
@@ -14,6 +14,7 @@ from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import aggregate_greeks_legs as _rust_aggregate_greeks_legs
from ferro_ta._ferro_ta import strategy_payoff_dense as _rust_strategy_payoff_dense
from ferro_ta._ferro_ta import strategy_payoff_legs as _rust_strategy_payoff_legs
from ferro_ta._ferro_ta import strategy_value_dense as _rust_strategy_value_dense
from ferro_ta.analysis.options import OptionGreeks
from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg
from ferro_ta.core.exceptions import (
@@ -26,7 +27,9 @@ __all__ = [
"PayoffLeg",
"option_leg_payoff",
"futures_leg_payoff",
"stock_leg_payoff",
"strategy_payoff",
"strategy_value",
"aggregate_greeks",
]
@@ -47,8 +50,10 @@ class PayoffLeg:
multiplier: float = 1.0
def __post_init__(self) -> None:
if self.instrument not in {"option", "future"}:
raise FerroTAValueError("instrument must be 'option' or 'future'.")
if self.instrument not in {"option", "future", "stock"}:
raise FerroTAValueError(
"instrument must be 'option', 'future', or 'stock'."
)
if self.side not in {"long", "short"}:
raise FerroTAValueError("side must be 'long' or 'short'.")
if self.instrument == "option":
@@ -58,8 +63,8 @@ class PayoffLeg:
)
if self.strike is None:
raise FerroTAValueError("option legs require strike.")
if self.instrument == "future" and self.entry_price is None:
raise FerroTAValueError("future legs require entry_price.")
if self.instrument in {"future", "stock"} and self.entry_price is None:
raise FerroTAValueError(f"{self.instrument} legs require entry_price.")
def _side_sign(side: str) -> float:
@@ -131,6 +136,60 @@ def futures_leg_payoff(
)
def stock_leg_payoff(
spot_grid: ArrayLike,
*,
entry_price: float,
side: str = "long",
quantity: float = 1.0,
multiplier: float = 1.0,
) -> NDArray[np.float64]:
"""P/L profile for a single stock (equity) leg over a spot grid.
Payoff is linear::
P/L = sign(side) × quantity × multiplier × (spot entry_price)
Mathematically equivalent to a futures leg no optionality. Use this
leg type when modelling strategies that hold the underlying equity:
Covered Call, Protective Put, Collar, Covered Strangle, etc.
Parameters
----------
spot_grid:
1-D array of spot prices at which to evaluate the P/L.
entry_price:
Purchase (or short-sale) price of the stock.
side:
``"long"`` (default) or ``"short"``.
quantity:
Number of shares / contracts (default 1).
multiplier:
Contract multiplier (default 1.0).
Returns
-------
NDArray[float64]
P/L at each grid point, same shape as *spot_grid*.
"""
grid = _coerce_spot_grid(spot_grid)
_side_sign(side)
return np.asarray(
_rust_strategy_payoff_dense(
grid,
np.array([2], dtype=np.int64), # stock
np.array([1 if side == "long" else -1], dtype=np.int64),
np.array([-1], dtype=np.int64),
np.array([0.0], dtype=np.float64),
np.array([0.0], dtype=np.float64),
np.array([float(entry_price)], dtype=np.float64),
np.array([float(quantity)], dtype=np.float64),
np.array([float(multiplier)], dtype=np.float64),
),
dtype=np.float64,
)
def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg:
return PayoffLeg(**mapping)
@@ -141,7 +200,9 @@ def _strategy_leg_to_payoff_leg(leg: StrategyLeg) -> PayoffLeg:
side=leg.side,
quantity=float(leg.quantity),
option_type=leg.option_type,
strike=leg.strike_selector.explicit_strike,
strike=leg.strike_selector.explicit_strike
if leg.strike_selector is not None
else None,
)
@@ -205,3 +266,92 @@ def aggregate_greeks(
float(theta),
float(rho),
)
def strategy_value(
spot_grid: ArrayLike,
*,
legs: Sequence[PayoffLeg | Mapping[str, Any]],
time_to_expiry: float,
volatility: float,
rate: float = 0.0,
carry: float = 0.0,
) -> NDArray[np.float64]:
"""Current BSM mid-price value of a multi-leg strategy over a spot grid.
Unlike :func:`strategy_payoff` (which computes intrinsic value at expiry),
this uses live BSM pricing for option legs so the result reflects the
pre-expiry value including time value.
Parameters
----------
spot_grid:
Array of spot prices to evaluate.
legs:
Sequence of :class:`PayoffLeg` (or dicts). Option legs must have
``strike`` and ``premium`` set; future/stock legs must have
``entry_price`` set.
time_to_expiry:
Shared time-to-expiry (years) applied to all option legs.
volatility:
Shared implied vol applied to all option legs.
rate:
Risk-free rate applied to all legs.
carry:
Carry / dividend yield applied to all option legs.
"""
grid = _coerce_spot_grid(spot_grid)
normalized: tuple[PayoffLeg, ...] = tuple(
leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg) for leg in legs
)
if len(normalized) == 0:
return np.zeros_like(grid)
n_legs = len(normalized)
instruments = np.empty(n_legs, dtype=np.int64)
sides = np.empty(n_legs, dtype=np.int64)
option_types = np.empty(n_legs, dtype=np.int64)
strikes = np.zeros(n_legs, dtype=np.float64)
premiums = np.zeros(n_legs, dtype=np.float64)
entry_prices = np.zeros(n_legs, dtype=np.float64)
quantities = np.ones(n_legs, dtype=np.float64)
multipliers = np.ones(n_legs, dtype=np.float64)
ttes = np.full(n_legs, time_to_expiry, dtype=np.float64)
vols = np.full(n_legs, volatility, dtype=np.float64)
rates = np.full(n_legs, rate, dtype=np.float64)
carries = np.full(n_legs, carry, dtype=np.float64)
_inst_map = {"option": 0, "future": 1, "stock": 2}
for i, leg in enumerate(normalized):
instruments[i] = _inst_map[leg.instrument]
sides[i] = 1 if leg.side == "long" else -1
option_types[i] = 1 if leg.option_type == "call" else -1
if leg.strike is not None:
strikes[i] = float(leg.strike)
premiums[i] = float(leg.premium)
if leg.entry_price is not None:
entry_prices[i] = float(leg.entry_price)
quantities[i] = float(leg.quantity)
multipliers[i] = float(leg.multiplier)
try:
return np.asarray(
_rust_strategy_value_dense(
grid,
instruments,
sides,
option_types,
strikes,
premiums,
entry_prices,
quantities,
multipliers,
ttes,
vols,
rates,
carries,
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
+963
View File
@@ -26,6 +26,15 @@ from ferro_ta._ferro_ta import (
from ferro_ta._ferro_ta import (
bsm_price_batch as _rust_bsm_price_batch,
)
from ferro_ta._ferro_ta import (
expected_move as _rust_expected_move,
)
from ferro_ta._ferro_ta import (
extended_greeks as _rust_extended_greeks,
)
from ferro_ta._ferro_ta import (
extended_greeks_batch as _rust_extended_greeks_batch,
)
from ferro_ta._ferro_ta import (
implied_volatility as _rust_implied_volatility,
)
@@ -50,6 +59,9 @@ from ferro_ta._ferro_ta import (
from ferro_ta._ferro_ta import (
option_greeks_batch as _rust_option_greeks_batch,
)
from ferro_ta._ferro_ta import (
put_call_parity_deviation as _rust_put_call_parity_deviation,
)
from ferro_ta._ferro_ta import (
select_strike_delta as _rust_select_strike_delta,
)
@@ -73,11 +85,14 @@ ScalarOrArray: TypeAlias = float | NDArray[np.float64]
__all__ = [
"OptionGreeks",
"ExtendedGreeks",
"SmileMetrics",
"VolCone",
"black_scholes_price",
"black_76_price",
"option_price",
"greeks",
"extended_greeks",
"implied_volatility",
"smile_metrics",
"term_structure_slope",
@@ -86,9 +101,63 @@ __all__ = [
"iv_rank",
"iv_percentile",
"iv_zscore",
"put_call_parity_deviation",
"expected_move",
"digital_option_price",
"digital_option_greeks",
"american_option_price",
"early_exercise_premium",
"close_to_close_vol",
"parkinson_vol",
"garman_klass_vol",
"rogers_satchell_vol",
"yang_zhang_vol",
"vol_cone",
]
@dataclass(frozen=True)
class ExtendedGreeks:
"""Container for second-order and cross Greeks."""
vanna: ScalarOrArray
volga: ScalarOrArray
charm: ScalarOrArray
speed: ScalarOrArray
color: ScalarOrArray
def to_dict(self) -> dict[str, ScalarOrArray]:
return {
"vanna": self.vanna,
"volga": self.volga,
"charm": self.charm,
"speed": self.speed,
"color": self.color,
}
@dataclass(frozen=True)
class VolCone:
"""Historical realized vol distribution across window lengths."""
windows: NDArray[np.float64]
min: NDArray[np.float64]
p25: NDArray[np.float64]
median: NDArray[np.float64]
p75: NDArray[np.float64]
max: NDArray[np.float64]
def to_dict(self) -> dict[str, NDArray[np.float64]]:
return {
"windows": self.windows,
"min": self.min,
"p25": self.p25,
"median": self.median,
"p75": self.p75,
"max": self.max,
}
@dataclass(frozen=True)
class OptionGreeks:
"""Container for first-order Greeks."""
@@ -630,3 +699,897 @@ def select_strike(
except ValueError as err:
_normalize_rust_error(err)
return None if strike is None else float(strike)
def extended_greeks(
underlying: ArrayLike | float,
strike: ArrayLike | float,
rate: ArrayLike | float,
time_to_expiry: ArrayLike | float,
volatility: ArrayLike | float,
*,
option_type: str = "call",
model: str = "bsm",
carry: ArrayLike | float = 0.0,
) -> ExtendedGreeks:
"""Return vanna, volga, charm, speed, and color (second-order / cross Greeks).
All Greeks are computed via closed-form BSM formulas. Black-76 is not
yet supported and returns NaN for all five values.
Parameters
----------
underlying:
Current underlying (spot) price.
strike:
Option strike price.
rate:
Risk-free rate (annualised, decimal e.g. ``0.05`` for 5 %).
time_to_expiry:
Time to expiry in years.
volatility:
Implied volatility (annualised, decimal).
option_type:
``"call"`` (default) or ``"put"``.
model:
``"bsm"`` (default). ``"black76"`` returns NaN for all fields.
carry:
Continuous carry / dividend yield (annualised, decimal). Default 0.
Returns
-------
ExtendedGreeks
Named tuple with fields:
- **vanna** Δ/σ: sensitivity of delta to a change in vol.
- **volga** ²V/σ² (vomma): sensitivity of vega to a change in vol.
- **charm** Δ/t: daily rate of change in delta (theta of delta).
- **speed** Γ/S: rate of change in gamma with respect to spot.
- **color** Γ/t: daily rate of change in gamma.
Notes
-----
Inputs may be scalars or broadcastable arrays. When arrays are supplied
each field of the returned :class:`ExtendedGreeks` is an ``NDArray``.
Closed-form expressions (BSM, zero-carry)::
vanna = -e^{-qT} · φ(d₁) · d₂ / σ
volga = S · e^{-qT} · φ(d₁) · T · d₁ · d₂ / σ
charm = -e^{-qT} · φ(d₁) · [2(r-q)T - d₂·σ·T] / (2T·σ·T)
speed = -Γ/S · (d₁/(σT) + 1)
color = -Γ · [r-q + d₁·σ/(2T) + (2(r-q)T - d₂·σT)·d₁/(2T·σT)]
"""
option_type = _validate_option_type(option_type)
model = _validate_model(model)
arrays, scalar_mode = _broadcast_inputs(
underlying=underlying,
strike=strike,
rate=rate,
time_to_expiry=time_to_expiry,
volatility=volatility,
carry=carry,
)
try:
if scalar_mode:
vanna, volga, charm, speed, color = _rust_extended_greeks(
float(arrays["underlying"][0]),
float(arrays["strike"][0]),
float(arrays["rate"][0]),
float(arrays["time_to_expiry"][0]),
float(arrays["volatility"][0]),
option_type,
model,
float(arrays["carry"][0]),
)
return ExtendedGreeks(vanna, volga, charm, speed, color)
vanna, volga, charm, speed, color = _rust_extended_greeks_batch(
arrays["underlying"],
arrays["strike"],
arrays["rate"],
arrays["time_to_expiry"],
arrays["volatility"],
option_type,
model,
arrays["carry"],
)
return ExtendedGreeks(
np.asarray(vanna, dtype=np.float64),
np.asarray(volga, dtype=np.float64),
np.asarray(charm, dtype=np.float64),
np.asarray(speed, dtype=np.float64),
np.asarray(color, dtype=np.float64),
)
except ValueError as err:
_normalize_rust_error(err)
def put_call_parity_deviation(
call_price: float,
put_price: float,
spot: float,
strike: float,
rate: float,
time_to_expiry: float,
*,
carry: float = 0.0,
) -> float:
"""Put-call parity deviation: ``C P (S·e^{q·T} K·e^{r·T})``.
At no-arbitrage the deviation is exactly 0. A non-zero result indicates
mispricing, a data error, or a stale quote.
Parameters
----------
call_price:
Market or model price of the call option.
put_price:
Market or model price of the put option.
spot:
Current underlying price.
strike:
Common strike price of the call and put.
rate:
Risk-free rate (annualised, decimal).
time_to_expiry:
Time to expiry in years.
carry:
Continuous dividend yield / carry rate (annualised, decimal).
Returns
-------
float
Signed deviation. Positive call is overpriced relative to put;
negative put is overpriced relative to call.
Examples
--------
>>> from ferro_ta.analysis.options import option_price, put_call_parity_deviation
>>> call = option_price(100, 100, 0.05, 1.0, 0.2, option_type="call")
>>> put = option_price(100, 100, 0.05, 1.0, 0.2, option_type="put")
>>> put_call_parity_deviation(call, put, 100, 100, 0.05, 1.0) # ≈ 0.0
"""
try:
return float(
_rust_put_call_parity_deviation(
float(call_price),
float(put_price),
float(spot),
float(strike),
float(rate),
float(time_to_expiry),
float(carry),
)
)
except ValueError as err:
_normalize_rust_error(err)
def expected_move(
spot: float,
iv: float,
days_to_expiry: float,
trading_days_per_year: float = 252.0,
) -> tuple[float, float]:
"""Expected ±1σ move over *days_to_expiry* calendar days.
Uses the log-normal approximation::
upper_move = spot × e^{+σ(days/trading_days)} spot
lower_move = spot × e^{σ(days/trading_days)} spot
Parameters
----------
spot:
Current underlying price.
iv:
Implied volatility (annualised, decimal e.g. ``0.20`` for 20 %).
days_to_expiry:
Number of calendar days until expiry.
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
tuple[float, float]
``(lower_move, upper_move)`` signed absolute price changes from
``spot``. ``lower_move < 0``, ``upper_move > 0``.
Notes
-----
Because of log-normal skew, ``|upper_move| > |lower_move|``.
Examples
--------
>>> from ferro_ta.analysis.options import expected_move
>>> lower, upper = expected_move(100.0, 0.20, 30)
>>> round(upper, 2)
7.14
"""
try:
lower, upper = _rust_expected_move(
float(spot), float(iv), float(days_to_expiry), float(trading_days_per_year)
)
return float(lower), float(upper)
except ValueError as err:
_normalize_rust_error(err)
# ---------------------------------------------------------------------------
# Digital options — populated once the Rust bridge is built
# ---------------------------------------------------------------------------
def digital_option_price(
underlying: ArrayLike | float,
strike: ArrayLike | float,
rate: ArrayLike | float,
time_to_expiry: ArrayLike | float,
volatility: ArrayLike | float,
*,
option_type: str = "call",
digital_type: str = "cash_or_nothing",
carry: ArrayLike | float = 0.0,
) -> ScalarOrArray:
"""Price a digital (binary) option under BSM.
Parameters
----------
underlying:
Current underlying (spot) price.
strike:
Option strike price.
rate:
Risk-free rate (annualised, decimal).
time_to_expiry:
Time to expiry in years.
volatility:
Implied volatility (annualised, decimal).
option_type:
``"call"`` (default) or ``"put"``.
digital_type:
``"cash_or_nothing"`` (default) pays 1 unit of cash if ITM at
expiry; or ``"asset_or_nothing"`` pays the underlying asset price.
carry:
Continuous carry / dividend yield (annualised, decimal). Default 0.
Returns
-------
float or NDArray[float64]
Option price. Returns a scalar when all inputs are scalars, or an
array when any input is an array.
Notes
-----
Closed-form BSM formulas::
Cash-or-nothing call: e^{rT} · N(d₂)
Cash-or-nothing put: e^{rT} · N(d₂)
Asset-or-nothing call: S · e^{qT} · N(d₁)
Asset-or-nothing put: S · e^{qT} · N(d₁)
Put-call parity for cash-or-nothing: call + put = e^{rT}.
Put-call parity for asset-or-nothing: call + put = S · e^{qT}.
Invalid inputs (non-positive spot/strike, negative time or vol) return NaN.
"""
from ferro_ta._ferro_ta import digital_price as _rust_digital_price
from ferro_ta._ferro_ta import digital_price_batch as _rust_digital_price_batch
option_type = _validate_option_type(option_type)
digital_type = digital_type.lower().replace("-", "_")
if digital_type not in {"cash_or_nothing", "asset_or_nothing"}:
raise FerroTAValueError(
"digital_type must be 'cash_or_nothing' or 'asset_or_nothing'."
)
arrays, scalar_mode = _broadcast_inputs(
underlying=underlying,
strike=strike,
rate=rate,
time_to_expiry=time_to_expiry,
volatility=volatility,
carry=carry,
)
try:
if scalar_mode:
return float(
_rust_digital_price(
float(arrays["underlying"][0]),
float(arrays["strike"][0]),
float(arrays["rate"][0]),
float(arrays["time_to_expiry"][0]),
float(arrays["volatility"][0]),
option_type,
digital_type,
float(arrays["carry"][0]),
)
)
out = _rust_digital_price_batch(
arrays["underlying"],
arrays["strike"],
arrays["rate"],
arrays["time_to_expiry"],
arrays["volatility"],
option_type,
digital_type,
arrays["carry"],
)
return np.asarray(out, dtype=np.float64)
except ValueError as err:
_normalize_rust_error(err)
def digital_option_greeks(
underlying: ArrayLike | float,
strike: ArrayLike | float,
rate: ArrayLike | float,
time_to_expiry: ArrayLike | float,
volatility: ArrayLike | float,
*,
option_type: str = "call",
digital_type: str = "cash_or_nothing",
carry: ArrayLike | float = 0.0,
) -> OptionGreeks:
"""Delta, gamma, and vega for a digital option via numerical bumping.
Uses central finite differences (spot bump ε = spot × 10³ for delta/gamma;
vol bump ε = 10³ for vega). Theta and rho are set to NaN.
Parameters
----------
underlying, strike, rate, time_to_expiry, volatility, option_type, carry:
Same as :func:`digital_option_price`.
digital_type:
``"cash_or_nothing"`` (default) or ``"asset_or_nothing"``.
Returns
-------
OptionGreeks
Named tuple; only ``delta``, ``gamma``, ``vega`` are finite.
``theta`` and ``rho`` are NaN.
"""
from ferro_ta._ferro_ta import digital_greeks as _rust_digital_greeks
from ferro_ta._ferro_ta import digital_greeks_batch as _rust_digital_greeks_batch
option_type = _validate_option_type(option_type)
digital_type = digital_type.lower().replace("-", "_")
if digital_type not in {"cash_or_nothing", "asset_or_nothing"}:
raise FerroTAValueError(
"digital_type must be 'cash_or_nothing' or 'asset_or_nothing'."
)
arrays, scalar_mode = _broadcast_inputs(
underlying=underlying,
strike=strike,
rate=rate,
time_to_expiry=time_to_expiry,
volatility=volatility,
carry=carry,
)
try:
if scalar_mode:
delta, gamma, vega = _rust_digital_greeks(
float(arrays["underlying"][0]),
float(arrays["strike"][0]),
float(arrays["rate"][0]),
float(arrays["time_to_expiry"][0]),
float(arrays["volatility"][0]),
option_type,
digital_type,
float(arrays["carry"][0]),
)
return OptionGreeks(delta, gamma, vega, float("nan"), float("nan"))
delta, gamma, vega = _rust_digital_greeks_batch(
arrays["underlying"],
arrays["strike"],
arrays["rate"],
arrays["time_to_expiry"],
arrays["volatility"],
option_type,
digital_type,
arrays["carry"],
)
nan_arr = np.full_like(delta, float("nan"))
return OptionGreeks(
np.asarray(delta, dtype=np.float64),
np.asarray(gamma, dtype=np.float64),
np.asarray(vega, dtype=np.float64),
nan_arr,
nan_arr,
)
except ValueError as err:
_normalize_rust_error(err)
# ---------------------------------------------------------------------------
# American options — populated once the Rust bridge is built
# ---------------------------------------------------------------------------
def american_option_price(
underlying: ArrayLike | float,
strike: ArrayLike | float,
rate: ArrayLike | float,
time_to_expiry: ArrayLike | float,
volatility: ArrayLike | float,
*,
option_type: str = "call",
carry: ArrayLike | float = 0.0,
) -> ScalarOrArray:
"""American option price using the Barone-Adesi-Whaley (1987) approximation.
Accurate to within a few basis points for standard equity/index parameters.
O(1) per evaluation suitable for batch pricing or calibration.
Parameters
----------
underlying:
Current underlying (spot) price.
strike:
Option strike price.
rate:
Risk-free rate (annualised, decimal).
time_to_expiry:
Time to expiry in years.
volatility:
Implied volatility (annualised, decimal).
option_type:
``"call"`` (default) or ``"put"``.
carry:
Continuous carry / dividend yield (annualised, decimal). Default 0.
For calls with ``carry = 0`` (no dividends) early exercise is never
optimal and the result equals the European BSM price.
Returns
-------
float or NDArray[float64]
American option price European BSM price.
Notes
-----
The BAW approximation uses a quadratic equation to find the critical
exercise boundary S* via Newton-Raphson iteration, then adds the early
exercise premium on top of the European price.
Reference: Barone-Adesi, G. & Whaley, R.E. (1987). "Efficient Analytic
Approximation of American Option Values." *Journal of Finance*, 42(2),
301320.
See Also
--------
early_exercise_premium : Difference between American and European prices.
"""
from ferro_ta._ferro_ta import american_price as _rust_american_price
from ferro_ta._ferro_ta import american_price_batch as _rust_american_price_batch
option_type = _validate_option_type(option_type)
arrays, scalar_mode = _broadcast_inputs(
underlying=underlying,
strike=strike,
rate=rate,
time_to_expiry=time_to_expiry,
volatility=volatility,
carry=carry,
)
try:
if scalar_mode:
return float(
_rust_american_price(
float(arrays["underlying"][0]),
float(arrays["strike"][0]),
float(arrays["rate"][0]),
float(arrays["time_to_expiry"][0]),
float(arrays["volatility"][0]),
option_type,
float(arrays["carry"][0]),
)
)
out = _rust_american_price_batch(
arrays["underlying"],
arrays["strike"],
arrays["rate"],
arrays["time_to_expiry"],
arrays["volatility"],
option_type,
arrays["carry"],
)
return np.asarray(out, dtype=np.float64)
except ValueError as err:
_normalize_rust_error(err)
def early_exercise_premium(
underlying: ArrayLike | float,
strike: ArrayLike | float,
rate: ArrayLike | float,
time_to_expiry: ArrayLike | float,
volatility: ArrayLike | float,
*,
option_type: str = "call",
carry: ArrayLike | float = 0.0,
) -> ScalarOrArray:
"""Early exercise premium: American price European BSM price.
Represents the additional value an American option holder gains from the
right to exercise before expiry. Always 0.
Parameters
----------
underlying, strike, rate, time_to_expiry, volatility, option_type, carry:
Same as :func:`american_option_price`.
Returns
-------
float or NDArray[float64]
Premium 0. Typically 0 for calls with no dividends.
Notes
-----
For equity calls with zero carry (no dividends), early exercise is never
optimal so the premium is 0. For puts (or calls on dividend-paying
underlyings), the premium increases with in-the-moneyness, rate, and
time to expiry.
"""
from ferro_ta._ferro_ta import (
early_exercise_premium as _rust_early_exercise_premium,
)
from ferro_ta._ferro_ta import (
early_exercise_premium_batch as _rust_early_exercise_premium_batch,
)
option_type = _validate_option_type(option_type)
arrays, scalar_mode = _broadcast_inputs(
underlying=underlying,
strike=strike,
rate=rate,
time_to_expiry=time_to_expiry,
volatility=volatility,
carry=carry,
)
try:
if scalar_mode:
return float(
_rust_early_exercise_premium(
float(arrays["underlying"][0]),
float(arrays["strike"][0]),
float(arrays["rate"][0]),
float(arrays["time_to_expiry"][0]),
float(arrays["volatility"][0]),
option_type,
float(arrays["carry"][0]),
)
)
out = _rust_early_exercise_premium_batch(
arrays["underlying"],
arrays["strike"],
arrays["rate"],
arrays["time_to_expiry"],
arrays["volatility"],
option_type,
arrays["carry"],
)
return np.asarray(out, dtype=np.float64)
except ValueError as err:
_normalize_rust_error(err)
# ---------------------------------------------------------------------------
# Historical volatility estimators — populated once the Rust bridge is built
# ---------------------------------------------------------------------------
def close_to_close_vol(
close: ArrayLike,
window: int = 20,
trading_days_per_year: float = 252.0,
) -> NDArray[np.float64]:
"""Rolling close-to-close realized volatility (annualised).
Baseline estimator uses only closing prices. Less efficient than OHLC
estimators but requires only daily close data.
Parameters
----------
close:
Array of closing prices (length window + 1).
window:
Rolling look-back period in bars (default 20).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
NDArray[float64]
Same length as *close*. First ``window`` values are NaN.
Notes
-----
Formula::
σ = ( Σᵢ ln²(Cᵢ/Cᵢ) / window × trading_days_per_year )
No Bessel correction is applied (population variance, not sample variance).
"""
from ferro_ta._ferro_ta import close_to_close_vol as _rust_ctc
try:
arr = _to_f64(close)
return np.asarray(
_rust_ctc(arr, int(window), float(trading_days_per_year)), dtype=np.float64
)
except ValueError as err:
_normalize_rust_error(err)
def parkinson_vol(
high: ArrayLike,
low: ArrayLike,
window: int = 20,
trading_days_per_year: float = 252.0,
) -> NDArray[np.float64]:
"""Rolling Parkinson high-low realized volatility estimator (annualised).
~5× more efficient than close-to-close for diffusion processes.
Does **not** account for drift or overnight gaps.
Parameters
----------
high, low:
Arrays of daily high and low prices (same length, window).
window:
Rolling look-back period in bars (default 20).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
NDArray[float64]
Same length as *high*. First ``window - 1`` values are NaN.
Notes
-----
Formula per window::
σ² = (1 / (4·ln2·window)) · Σ ln²(Hᵢ/Lᵢ) × trading_days_per_year
Reference: Parkinson, M. (1980). "The Extreme Value Method for
Estimating the Variance of the Rate of Return." *Journal of Business*, 53(1).
"""
from ferro_ta._ferro_ta import parkinson_vol as _rust_parkinson
try:
return np.asarray(
_rust_parkinson(
_to_f64(high), _to_f64(low), int(window), float(trading_days_per_year)
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def garman_klass_vol(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
window: int = 20,
trading_days_per_year: float = 252.0,
) -> NDArray[np.float64]:
"""Rolling Garman-Klass OHLC realized volatility estimator (annualised).
Extends Parkinson by incorporating the open-close return. ~7.4× more
efficient than close-to-close. Does **not** handle overnight gaps.
Parameters
----------
open, high, low, close:
Arrays of daily OHLC prices (same length, window).
window:
Rolling look-back period in bars (default 20).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
NDArray[float64]
Same length as *close*. First ``window - 1`` values are NaN.
Notes
-----
Per-bar contribution::
GK = 0.5·ln²(H/L) (2·ln2 1)·ln²(C/O)
Reference: Garman, M.B. & Klass, M.J. (1980). "On the Estimation of
Security Price Volatilities from Historical Data." *Journal of Business*, 53(1).
"""
from ferro_ta._ferro_ta import garman_klass_vol as _rust_gk
try:
return np.asarray(
_rust_gk(
_to_f64(open),
_to_f64(high),
_to_f64(low),
_to_f64(close),
int(window),
float(trading_days_per_year),
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def rogers_satchell_vol(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
window: int = 20,
trading_days_per_year: float = 252.0,
) -> NDArray[np.float64]:
"""Rolling Rogers-Satchell OHLC realized volatility estimator (annualised).
Drift-invariant: unbiased for assets with non-zero expected return.
Does **not** handle overnight gaps.
Parameters
----------
open, high, low, close:
Arrays of daily OHLC prices (same length, window).
window:
Rolling look-back period in bars (default 20).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
NDArray[float64]
Same length as *close*. First ``window - 1`` values are NaN.
Notes
-----
Per-bar contribution (u = ln(H/O), d = ln(L/O), c = ln(C/O))::
RS = u·(u c) + d·(d c)
Reference: Rogers, L.C.G. & Satchell, S.E. (1991). "Estimating Variance
from High, Low and Closing Prices." *Annals of Applied Probability*, 1(4).
"""
from ferro_ta._ferro_ta import rogers_satchell_vol as _rust_rs
try:
return np.asarray(
_rust_rs(
_to_f64(open),
_to_f64(high),
_to_f64(low),
_to_f64(close),
int(window),
float(trading_days_per_year),
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def yang_zhang_vol(
open: ArrayLike,
high: ArrayLike,
low: ArrayLike,
close: ArrayLike,
window: int = 20,
trading_days_per_year: float = 252.0,
) -> NDArray[np.float64]:
"""Rolling Yang-Zhang OHLC realized volatility estimator (annualised).
The most efficient standard estimator (~14× vs close-to-close). Handles
overnight gaps by combining overnight, intraday open-close, and
Rogers-Satchell variance components with an optimal weight *k*.
Parameters
----------
open, high, low, close:
Arrays of daily OHLC prices (same length, window + 1).
window:
Rolling look-back period in bars (default 20).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
NDArray[float64]
Same length as *close*. First ``window`` values are NaN.
Notes
-----
Mixed estimator::
σ²_YZ = σ²_overnight + k·σ²_open_close + (1k)·σ²_RS
where k = 0.34 / (1.34 + (window+1)/(window-1)).
Reference: Yang, D. & Zhang, Q. (2000). "Drift-Independent Volatility
Estimation Based on High, Low, Open, and Close Prices."
*Journal of Business*, 73(3).
"""
from ferro_ta._ferro_ta import yang_zhang_vol as _rust_yz
try:
return np.asarray(
_rust_yz(
_to_f64(open),
_to_f64(high),
_to_f64(low),
_to_f64(close),
int(window),
float(trading_days_per_year),
),
dtype=np.float64,
)
except ValueError as err:
_normalize_rust_error(err)
def vol_cone(
close: ArrayLike,
*,
windows: tuple[int, ...] = (21, 42, 63, 126, 252),
trading_days_per_year: float = 252.0,
) -> VolCone:
"""Historical realised vol distribution across window lengths (volatility cone).
For each window, computes the full history of rolling close-to-close
realised vol, then returns the min / p25 / median / p75 / max distribution.
Contextualises current implied vol: "Is 30 % IV cheap or expensive?"
Parameters
----------
close:
Array of closing prices (length max(windows) + 1).
windows:
Tuple of rolling window sizes in bars. Default ``(21, 42, 63, 126, 252)``
(approx. 1 month, 2 months, 3 months, 6 months, 1 year).
trading_days_per_year:
Annualisation factor (default 252).
Returns
-------
VolCone
Dataclass with arrays ``windows``, ``min``, ``p25``, ``median``,
``p75``, ``max`` one value per element of *windows*.
Notes
-----
Uses close-to-close vol internally. Overlay the current IV on the cone
to see whether it is historically cheap or expensive for each tenor.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.options import vol_cone
>>> rng = np.random.default_rng(0)
>>> close = 100 * np.cumprod(np.exp(rng.normal(0, 0.01, 500)))
>>> cone = vol_cone(close, windows=(21, 63, 252))
>>> cone.median # annualised median realised vol per window
"""
from ferro_ta._ferro_ta import vol_cone as _rust_vol_cone
try:
arr = _to_f64(close)
slices = _rust_vol_cone(arr, list(windows), float(trading_days_per_year))
windows_arr = np.array([s[0] for s in slices], dtype=np.float64)
return VolCone(
windows=windows_arr,
min=np.array([s[1] for s in slices], dtype=np.float64),
p25=np.array([s[2] for s in slices], dtype=np.float64),
median=np.array([s[3] for s in slices], dtype=np.float64),
p75=np.array([s[4] for s in slices], dtype=np.float64),
max=np.array([s[5] for s in slices], dtype=np.float64),
)
except ValueError as err:
_normalize_rust_error(err)
+16 -7
View File
@@ -147,9 +147,9 @@ class SimulationLimits:
@dataclass(frozen=True)
class StrategyLeg:
underlying: str
expiry_selector: ExpirySelector
strike_selector: StrikeSelector
option_type: str
expiry_selector: ExpirySelector | None
strike_selector: StrikeSelector | None
option_type: str | None
side: str = "long"
quantity: int = 1
instrument: str = "option"
@@ -158,12 +158,21 @@ class StrategyLeg:
def __post_init__(self) -> None:
if self.underlying.strip() == "":
raise FerroTAInputError("underlying must not be empty.")
if self.option_type not in {"call", "put"}:
raise FerroTAValueError("option_type must be 'call' or 'put'.")
if self.instrument not in {"option", "future", "stock"}:
raise FerroTAValueError(
"instrument must be 'option', 'future', or 'stock'."
)
if self.instrument == "option":
if self.option_type not in {"call", "put"}:
raise FerroTAValueError(
"option legs require option_type='call' or 'put'."
)
if self.expiry_selector is None:
raise FerroTAInputError("option legs require expiry_selector.")
if self.strike_selector is None:
raise FerroTAInputError("option legs require strike_selector.")
if self.side not in {"long", "short"}:
raise FerroTAValueError("side must be 'long' or 'short'.")
if self.instrument not in {"option", "future"}:
raise FerroTAValueError("instrument must be 'option' or 'future'.")
if self.quantity == 0:
raise FerroTAValueError("quantity must be non-zero.")
if self.premium_limit is not None and self.premium_limit < 0.0:
+61 -4
View File
@@ -131,6 +131,63 @@ 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)
# ---------------------------------------------------------------------------
@@ -154,7 +211,7 @@ def check_timeperiod(value: int, name: str = "timeperiod", minimum: int = 1) ->
If ``value < minimum``.
"""
if value < minimum:
raise FerroTAValueError(
raise InvalidPeriodError(
f"{name} must be >= {minimum}, got {value}",
suggestion=f"Set {name}={minimum} or higher.",
)
@@ -193,7 +250,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 FerroTAInputError(
raise LengthMismatchError(
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.",
@@ -223,7 +280,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 FerroTAInputError(
raise InvalidInputError(
f"{name} contains NaN or Inf values. "
"ferro_ta propagates NaN by default; call check_finite() only "
"when you require all-finite inputs.",
@@ -255,7 +312,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 FerroTAInputError(
raise InsufficientDataError(
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}.",
+109
View File
@@ -12,19 +12,33 @@ 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,
)
@@ -247,6 +261,98 @@ 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",
@@ -257,4 +363,7 @@ __all__ = [
"TSF",
"BETA",
"CORREL",
"DTW",
"DTW_DISTANCE",
"BATCH_DTW",
]
+199 -127
View File
@@ -1,27 +1,40 @@
#!/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
rust_fmt
rust_clippy
rust_core
rust_bench
python_lint
python_typecheck
python_test
docs
wasm
manifest
version changelog manifest
rust_fmt rust_clippy rust_core rust_bench
python_lint python_typecheck python_test
docs wasm
)
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() {
cat <<'EOF'
@@ -30,93 +43,60 @@ Usage:
scripts/pre_push_checks.sh <check> [<check> ...]
scripts/pre_push_checks.sh --list
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.
Environment:
FERRO_FAST=1 Skip docs and wasm (fastest local feedback loop)
EOF
}
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
}
# ---------------------------------------------------------------------------
# Individual check functions
# ---------------------------------------------------------------------------
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() {
@@ -125,69 +105,161 @@ run_docs() {
}
run_wasm() {
need_cmd node
need_cmd wasm-pack
local benchmark_json="../.wasm_benchmark.prepush.json"
need_cmd node; need_cmd wasm-pack
(
cd wasm
trap 'rm -f "$benchmark_json"' EXIT
run_cmd wasm-pack test --node
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() {
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 ;;
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 ;;
python_typecheck) run_python_typecheck ;;
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
;;
python_test) run_python_test ;;
docs) run_docs ;;
wasm) run_wasm ;;
*) echo "Unknown check: $1 — use --list" >&2; exit 1 ;;
esac
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
# ---------------------------------------------------------------------------
# Parallel runner — starts all checks concurrently, collects results
# ---------------------------------------------------------------------------
if [[ "${1:-}" == "--list" ]]; then
list_checks
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; }
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
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"
# ---------------------------------------------------------------------------
# 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
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'
+127
View File
@@ -0,0 +1,127 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use ferro_ta_core::options::american::{
american_price_baw as core_american_price,
early_exercise_premium as core_early_exercise_premium,
};
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = 0.0))]
#[allow(clippy::too_many_arguments)]
pub fn american_price(
underlying: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
volatility: f64,
option_type: &str,
carry: f64,
) -> PyResult<f64> {
let kind = super::parse_option_kind(option_type)?;
Ok(core_american_price(
underlying,
strike,
rate,
carry,
time_to_expiry,
volatility,
kind,
))
}
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = None))]
#[allow(clippy::too_many_arguments)]
pub fn american_price_batch<'py>(
py: Python<'py>,
underlying: PyReadonlyArray1<'py, f64>,
strike: PyReadonlyArray1<'py, f64>,
rate: PyReadonlyArray1<'py, f64>,
time_to_expiry: PyReadonlyArray1<'py, f64>,
volatility: PyReadonlyArray1<'py, f64>,
option_type: &str,
carry: Option<PyReadonlyArray1<'py, f64>>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let kind = super::parse_option_kind(option_type)?;
let underlying = underlying.as_slice()?;
let strike = strike.as_slice()?;
let rate = rate.as_slice()?;
let tte = time_to_expiry.as_slice()?;
let vol = volatility.as_slice()?;
let n = underlying.len();
let carry_vec = match carry {
Some(arr) => arr.as_slice()?.to_vec(),
None => vec![0.0; n],
};
let out: Vec<f64> = underlying
.iter()
.zip(strike.iter())
.zip(rate.iter())
.zip(tte.iter())
.zip(vol.iter())
.zip(carry_vec.iter())
.map(|(((((&u, &k), &r), &t), &v), &c)| core_american_price(u, k, r, c, t, v, kind))
.collect();
Ok(out.into_pyarray(py))
}
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = 0.0))]
#[allow(clippy::too_many_arguments)]
pub fn early_exercise_premium(
underlying: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
volatility: f64,
option_type: &str,
carry: f64,
) -> PyResult<f64> {
let kind = super::parse_option_kind(option_type)?;
Ok(core_early_exercise_premium(
underlying,
strike,
rate,
carry,
time_to_expiry,
volatility,
kind,
))
}
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", carry = None))]
#[allow(clippy::too_many_arguments)]
pub fn early_exercise_premium_batch<'py>(
py: Python<'py>,
underlying: PyReadonlyArray1<'py, f64>,
strike: PyReadonlyArray1<'py, f64>,
rate: PyReadonlyArray1<'py, f64>,
time_to_expiry: PyReadonlyArray1<'py, f64>,
volatility: PyReadonlyArray1<'py, f64>,
option_type: &str,
carry: Option<PyReadonlyArray1<'py, f64>>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let kind = super::parse_option_kind(option_type)?;
let underlying = underlying.as_slice()?;
let strike = strike.as_slice()?;
let rate = rate.as_slice()?;
let tte = time_to_expiry.as_slice()?;
let vol = volatility.as_slice()?;
let n = underlying.len();
let carry_vec = match carry {
Some(arr) => arr.as_slice()?.to_vec(),
None => vec![0.0; n],
};
let out: Vec<f64> = underlying
.iter()
.zip(strike.iter())
.zip(rate.iter())
.zip(tte.iter())
.zip(vol.iter())
.zip(carry_vec.iter())
.map(|(((((&u, &k), &r), &t), &v), &c)| core_early_exercise_premium(u, k, r, c, t, v, kind))
.collect();
Ok(out.into_pyarray(py))
}
+164
View File
@@ -0,0 +1,164 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use ferro_ta_core::options::digital::{
digital_greeks as core_digital_greeks, digital_price as core_digital_price, DigitalKind,
};
fn parse_digital_kind(s: &str) -> PyResult<DigitalKind> {
match s.to_ascii_lowercase().replace('-', "_").as_str() {
"cash_or_nothing" | "cash" => Ok(DigitalKind::CashOrNothing),
"asset_or_nothing" | "asset" => Ok(DigitalKind::AssetOrNothing),
_ => Err(PyValueError::new_err(
"digital_type must be 'cash_or_nothing' or 'asset_or_nothing'",
)),
}
}
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = 0.0))]
#[allow(clippy::too_many_arguments)]
pub fn digital_price(
underlying: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
volatility: f64,
option_type: &str,
digital_type: &str,
carry: f64,
) -> PyResult<f64> {
let kind = super::parse_option_kind(option_type)?;
let dkind = parse_digital_kind(digital_type)?;
Ok(core_digital_price(
underlying,
strike,
rate,
carry,
time_to_expiry,
volatility,
kind,
dkind,
))
}
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = None))]
#[allow(clippy::too_many_arguments)]
pub fn digital_price_batch<'py>(
py: Python<'py>,
underlying: PyReadonlyArray1<'py, f64>,
strike: PyReadonlyArray1<'py, f64>,
rate: PyReadonlyArray1<'py, f64>,
time_to_expiry: PyReadonlyArray1<'py, f64>,
volatility: PyReadonlyArray1<'py, f64>,
option_type: &str,
digital_type: &str,
carry: Option<PyReadonlyArray1<'py, f64>>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let kind = super::parse_option_kind(option_type)?;
let dkind = parse_digital_kind(digital_type)?;
let underlying = underlying.as_slice()?;
let strike = strike.as_slice()?;
let rate = rate.as_slice()?;
let tte = time_to_expiry.as_slice()?;
let vol = volatility.as_slice()?;
let n = underlying.len();
let carry_vec = match carry {
Some(arr) => arr.as_slice()?.to_vec(),
None => vec![0.0; n],
};
let out: Vec<f64> = underlying
.iter()
.zip(strike.iter())
.zip(rate.iter())
.zip(tte.iter())
.zip(vol.iter())
.zip(carry_vec.iter())
.map(|(((((&u, &k), &r), &t), &v), &c)| core_digital_price(u, k, r, c, t, v, kind, dkind))
.collect();
Ok(out.into_pyarray(py))
}
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = 0.0))]
#[allow(clippy::too_many_arguments)]
pub fn digital_greeks(
underlying: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
volatility: f64,
option_type: &str,
digital_type: &str,
carry: f64,
) -> PyResult<(f64, f64, f64)> {
let kind = super::parse_option_kind(option_type)?;
let dkind = parse_digital_kind(digital_type)?;
Ok(core_digital_greeks(
underlying,
strike,
rate,
carry,
time_to_expiry,
volatility,
kind,
dkind,
))
}
type GreekTriple<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
);
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", digital_type = "cash_or_nothing", carry = None))]
#[allow(clippy::too_many_arguments)]
pub fn digital_greeks_batch<'py>(
py: Python<'py>,
underlying: PyReadonlyArray1<'py, f64>,
strike: PyReadonlyArray1<'py, f64>,
rate: PyReadonlyArray1<'py, f64>,
time_to_expiry: PyReadonlyArray1<'py, f64>,
volatility: PyReadonlyArray1<'py, f64>,
option_type: &str,
digital_type: &str,
carry: Option<PyReadonlyArray1<'py, f64>>,
) -> PyResult<GreekTriple<'py>> {
let kind = super::parse_option_kind(option_type)?;
let dkind = parse_digital_kind(digital_type)?;
let underlying = underlying.as_slice()?;
let strike = strike.as_slice()?;
let rate = rate.as_slice()?;
let tte = time_to_expiry.as_slice()?;
let vol = volatility.as_slice()?;
let n = underlying.len();
let carry_vec = match carry {
Some(arr) => arr.as_slice()?.to_vec(),
None => vec![0.0; n],
};
let mut delta = Vec::with_capacity(n);
let mut gamma = Vec::with_capacity(n);
let mut vega = Vec::with_capacity(n);
for (((((&u, &k), &r), &t), &v), &c) in underlying
.iter()
.zip(strike.iter())
.zip(rate.iter())
.zip(tte.iter())
.zip(vol.iter())
.zip(carry_vec.iter())
{
let (d, g, ve) = core_digital_greeks(u, k, r, c, t, v, kind, dkind);
delta.push(d);
gamma.push(g);
vega.push(ve);
}
Ok((
delta.into_pyarray(py),
gamma.into_pyarray(py),
vega.into_pyarray(py),
))
}
+117
View File
@@ -2,6 +2,14 @@ use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
type ExtendedGreekArrays<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
);
type GreekArrays<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
@@ -123,3 +131,112 @@ pub fn option_greeks_batch<'py>(
rho.into_pyarray(py),
))
}
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = 0.0))]
#[allow(clippy::too_many_arguments)]
pub fn extended_greeks(
underlying: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
volatility: f64,
option_type: &str,
model: &str,
carry: f64,
) -> PyResult<(f64, f64, f64, f64, f64)> {
let kind = super::parse_option_kind(option_type)?;
let model = super::parse_pricing_model(model)?;
let eg = ferro_ta_core::options::greeks::model_extended_greeks(
ferro_ta_core::options::OptionEvaluation {
contract: ferro_ta_core::options::OptionContract {
model,
underlying,
strike,
rate,
carry,
time_to_expiry,
kind,
},
volatility,
},
);
Ok((eg.vanna, eg.volga, eg.charm, eg.speed, eg.color))
}
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = None))]
#[allow(clippy::too_many_arguments)]
pub fn extended_greeks_batch<'py>(
py: Python<'py>,
underlying: PyReadonlyArray1<'py, f64>,
strike: PyReadonlyArray1<'py, f64>,
rate: PyReadonlyArray1<'py, f64>,
time_to_expiry: PyReadonlyArray1<'py, f64>,
volatility: PyReadonlyArray1<'py, f64>,
option_type: &str,
model: &str,
carry: Option<PyReadonlyArray1<'py, f64>>,
) -> PyResult<ExtendedGreekArrays<'py>> {
let kind = super::parse_option_kind(option_type)?;
let model = super::parse_pricing_model(model)?;
let underlying = underlying.as_slice()?;
let strike = strike.as_slice()?;
let rate = rate.as_slice()?;
let time_to_expiry = time_to_expiry.as_slice()?;
let volatility = volatility.as_slice()?;
let carry_vec = match carry {
Some(array) => array.as_slice()?.to_vec(),
None => vec![0.0; underlying.len()],
};
validation::validate_equal_length(&[
(underlying.len(), "underlying"),
(strike.len(), "strike"),
(rate.len(), "rate"),
(time_to_expiry.len(), "time_to_expiry"),
(volatility.len(), "volatility"),
(carry_vec.len(), "carry"),
])?;
let mut vanna = Vec::with_capacity(underlying.len());
let mut volga = Vec::with_capacity(underlying.len());
let mut charm = Vec::with_capacity(underlying.len());
let mut speed = Vec::with_capacity(underlying.len());
let mut color = Vec::with_capacity(underlying.len());
for (((((&u, &k), &r), &t), &vol), &c) in underlying
.iter()
.zip(strike.iter())
.zip(rate.iter())
.zip(time_to_expiry.iter())
.zip(volatility.iter())
.zip(carry_vec.iter())
{
let eg = ferro_ta_core::options::greeks::model_extended_greeks(
ferro_ta_core::options::OptionEvaluation {
contract: ferro_ta_core::options::OptionContract {
model,
underlying: u,
strike: k,
rate: r,
carry: c,
time_to_expiry: t,
kind,
},
volatility: vol,
},
);
vanna.push(eg.vanna);
volga.push(eg.volga);
charm.push(eg.charm);
speed.push(eg.speed);
color.push(eg.color);
}
Ok((
vanna.into_pyarray(py),
volga.into_pyarray(py),
charm.into_pyarray(py),
speed.into_pyarray(py),
color.into_pyarray(py),
))
}
+64
View File
@@ -1,10 +1,13 @@
//! PyO3 wrappers for options analytics.
mod american;
mod chain;
mod digital;
mod greeks;
mod iv;
mod payoff;
mod pricing;
mod realized_vol;
mod surface;
use pyo3::exceptions::PyValueError;
@@ -40,11 +43,20 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
self::pricing::black76_price_batch,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::pricing::put_call_parity_deviation,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::greeks::option_greeks, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::greeks::option_greeks_batch,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::greeks::extended_greeks, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::greeks::extended_greeks_batch,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::iv::implied_volatility, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::iv::implied_volatility_batch,
@@ -58,6 +70,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
self::surface::term_structure_slope,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::surface::expected_move, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::chain::moneyness_labels, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::chain::select_strike_offset,
@@ -80,5 +93,56 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
self::payoff::aggregate_greeks_legs,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::payoff::strategy_value_dense,
m
)?)?;
// Digital options
m.add_function(pyo3::wrap_pyfunction!(self::digital::digital_price, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::digital::digital_price_batch,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::digital::digital_greeks, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::digital::digital_greeks_batch,
m
)?)?;
// American options
m.add_function(pyo3::wrap_pyfunction!(self::american::american_price, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::american::american_price_batch,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::american::early_exercise_premium,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::american::early_exercise_premium_batch,
m
)?)?;
// Historical volatility estimators + vol cone
m.add_function(pyo3::wrap_pyfunction!(
self::realized_vol::close_to_close_vol,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::realized_vol::parkinson_vol,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::realized_vol::garman_klass_vol,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::realized_vol::rogers_satchell_vol,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::realized_vol::yang_zhang_vol,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::realized_vol::vol_cone, m)?)?;
Ok(())
}
+59 -7
View File
@@ -7,6 +7,7 @@ use pyo3::types::{PyAny, PyTuple};
enum Instrument {
Option,
Future,
Stock,
}
#[derive(Clone, Copy)]
@@ -34,8 +35,9 @@ fn parse_instrument(v: i64) -> PyResult<Instrument> {
match v {
0 => Ok(Instrument::Option),
1 => Ok(Instrument::Future),
2 => Ok(Instrument::Stock),
_ => Err(PyValueError::new_err(
"instrument must be 0 (option) or 1 (future)",
"instrument must be 0 (option), 1 (future), or 2 (stock)",
)),
}
}
@@ -62,8 +64,9 @@ fn parse_instrument_label(v: &str) -> PyResult<Instrument> {
match v.to_ascii_lowercase().as_str() {
"option" => Ok(Instrument::Option),
"future" => Ok(Instrument::Future),
"stock" => Ok(Instrument::Stock),
_ => Err(PyValueError::new_err(
"instrument must be 'option' or 'future'",
"instrument must be 'option', 'future', or 'stock'",
)),
}
}
@@ -202,7 +205,7 @@ pub fn strategy_payoff_dense<'py>(
total[i] += leg_scale * (intrinsic - p);
}
}
Instrument::Future => {
Instrument::Future | Instrument::Stock => {
let e = entry[leg_idx];
for (i, &s) in grid.iter().enumerate() {
total[i] += leg_scale * (s - e);
@@ -253,9 +256,9 @@ pub fn strategy_payoff_legs<'py>(
total[i] += leg_scale * (intrinsic - premium);
}
}
Instrument::Future => {
Instrument::Future | Instrument::Stock => {
let entry_price = leg_attr_optional_f64(&leg, "entry_price")?.ok_or_else(|| {
PyValueError::new_err("Futures payoff legs require entry_price.")
PyValueError::new_err("Futures/stock payoff legs require entry_price.")
})?;
for (i, &s) in grid.iter().enumerate() {
total[i] += leg_scale * (s - entry_price);
@@ -323,7 +326,7 @@ pub fn aggregate_greeks_dense(
let side_sign = parse_side(side[i])?.sign();
let leg_scale = side_sign * qty[i] * mult[i];
match instrument {
Instrument::Future => {
Instrument::Future | Instrument::Stock => {
delta += leg_scale;
}
Instrument::Option => {
@@ -382,7 +385,7 @@ pub fn aggregate_greeks_legs(
let leg_scale = side_sign * quantity * multiplier;
match instrument {
Instrument::Future => {
Instrument::Future | Instrument::Stock => {
delta += leg_scale;
}
Instrument::Option => {
@@ -441,3 +444,52 @@ pub fn aggregate_greeks_legs(
Ok((delta, gamma, vega, theta, rho))
}
/// Compute BSM-based strategy value over a spot grid (pre-expiry mark-to-market).
///
/// Unlike `strategy_payoff_dense` (which uses intrinsic at expiry), this function
/// values each option leg using the Black-Scholes model price. Futures and stock
/// legs are valued the same as in `strategy_payoff_dense`.
///
/// Delegates to `ferro_ta_core::options::payoff::strategy_value_grid`.
///
/// NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;`
/// for this function to compile.
#[pyfunction]
#[allow(clippy::too_many_arguments)]
pub fn strategy_value_dense<'py>(
py: Python<'py>,
spot_grid: PyReadonlyArray1<'py, f64>,
instruments: PyReadonlyArray1<'py, i64>,
sides: PyReadonlyArray1<'py, i64>,
option_types: PyReadonlyArray1<'py, i64>,
strikes: PyReadonlyArray1<'py, f64>,
premiums: PyReadonlyArray1<'py, f64>,
entry_prices: PyReadonlyArray1<'py, f64>,
quantities: PyReadonlyArray1<'py, f64>,
multipliers: PyReadonlyArray1<'py, f64>,
time_to_expiries: PyReadonlyArray1<'py, f64>,
volatilities: PyReadonlyArray1<'py, f64>,
rates_per_leg: PyReadonlyArray1<'py, f64>,
carries_per_leg: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let grid = spot_grid.as_slice()?;
let inst = instruments.as_slice()?;
let side = sides.as_slice()?;
let opt_t = option_types.as_slice()?;
let strike = strikes.as_slice()?;
let premium = premiums.as_slice()?;
let entry = entry_prices.as_slice()?;
let qty = quantities.as_slice()?;
let mult = multipliers.as_slice()?;
let tte = time_to_expiries.as_slice()?;
let vol = volatilities.as_slice()?;
let rate = rates_per_leg.as_slice()?;
let carry = carries_per_leg.as_slice()?;
let result = ferro_ta_core::options::payoff::strategy_value_grid(
grid, inst, side, opt_t, strike, premium, entry, qty, mult, tte, vol, rate, carry,
);
Ok(result.into_pyarray(py))
}
+23
View File
@@ -89,6 +89,29 @@ pub fn bsm_price_batch<'py>(
Ok(out.into_pyarray(py))
}
#[pyfunction]
#[pyo3(signature = (call_price, put_price, spot, strike, rate, time_to_expiry, carry = 0.0))]
#[allow(clippy::too_many_arguments)]
pub fn put_call_parity_deviation(
call_price: f64,
put_price: f64,
spot: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
carry: f64,
) -> PyResult<f64> {
Ok(ferro_ta_core::options::pricing::put_call_parity_deviation(
call_price,
put_price,
spot,
strike,
rate,
carry,
time_to_expiry,
))
}
#[pyfunction]
#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))]
pub fn black76_price_batch<'py>(
+115
View File
@@ -0,0 +1,115 @@
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
use ferro_ta_core::options::realized_vol as core;
#[pyfunction]
#[pyo3(signature = (close, window, trading_days = 252.0))]
pub fn close_to_close_vol<'py>(
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
window: usize,
trading_days: f64,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
Ok(core::close_to_close_vol(close.as_slice()?, window, trading_days).into_pyarray(py))
}
#[pyfunction]
#[pyo3(signature = (high, low, window, trading_days = 252.0))]
pub fn parkinson_vol<'py>(
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
window: usize,
trading_days: f64,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
Ok(
core::parkinson_vol(high.as_slice()?, low.as_slice()?, window, trading_days)
.into_pyarray(py),
)
}
#[pyfunction]
#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))]
#[allow(clippy::too_many_arguments)]
pub fn garman_klass_vol<'py>(
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
window: usize,
trading_days: f64,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
Ok(core::garman_klass_vol(
open.as_slice()?,
high.as_slice()?,
low.as_slice()?,
close.as_slice()?,
window,
trading_days,
)
.into_pyarray(py))
}
#[pyfunction]
#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))]
#[allow(clippy::too_many_arguments)]
pub fn rogers_satchell_vol<'py>(
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
window: usize,
trading_days: f64,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
Ok(core::rogers_satchell_vol(
open.as_slice()?,
high.as_slice()?,
low.as_slice()?,
close.as_slice()?,
window,
trading_days,
)
.into_pyarray(py))
}
#[pyfunction]
#[pyo3(signature = (open, high, low, close, window, trading_days = 252.0))]
#[allow(clippy::too_many_arguments)]
pub fn yang_zhang_vol<'py>(
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
window: usize,
trading_days: f64,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
Ok(core::yang_zhang_vol(
open.as_slice()?,
high.as_slice()?,
low.as_slice()?,
close.as_slice()?,
window,
trading_days,
)
.into_pyarray(py))
}
/// Returns a list of (window, min, p25, median, p75, max) tuples.
#[allow(clippy::type_complexity)]
#[pyfunction]
#[pyo3(signature = (close, windows, trading_days = 252.0))]
pub fn vol_cone(
close: PyReadonlyArray1<'_, f64>,
windows: Vec<usize>,
trading_days: f64,
) -> PyResult<Vec<(usize, f64, f64, f64, f64, f64)>> {
let slices = core::vol_cone(close.as_slice()?, &windows, trading_days);
Ok(slices
.into_iter()
.map(|s| (s.window, s.min, s.p25, s.median, s.p75, s.max))
.collect())
}
+16
View File
@@ -47,3 +47,19 @@ pub fn term_structure_slope<'py>(
tenors, atm_ivs,
))
}
#[pyfunction]
#[pyo3(signature = (spot, iv, days_to_expiry, trading_days_per_year = 252.0))]
pub fn expected_move(
spot: f64,
iv: f64,
days_to_expiry: f64,
trading_days_per_year: f64,
) -> PyResult<(f64, f64)> {
Ok(ferro_ta_core::options::surface::expected_move(
spot,
iv,
days_to_expiry,
trading_days_per_year,
))
}
+98
View File
@@ -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
View File
@@ -4,6 +4,7 @@
mod beta;
pub(crate) mod common;
mod correl;
mod dtw;
mod linearreg;
mod stddev;
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::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(())
}
+178
View File
@@ -1,10 +1,14 @@
"""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,
@@ -308,3 +312,177 @@ 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
+398
View File
@@ -222,6 +222,404 @@ class TestStrategyAndPayoff:
assert greeks.gamma > 0.0
class TestStockInstrument:
def test_stock_leg_payoff_linear(self):
from ferro_ta.analysis.derivatives_payoff import stock_leg_payoff
spot_grid = np.array([90.0, 100.0, 110.0])
payoff = stock_leg_payoff(spot_grid, entry_price=100.0, side="long")
assert payoff == pytest.approx([-10.0, 0.0, 10.0])
def test_stock_leg_short_side(self):
from ferro_ta.analysis.derivatives_payoff import stock_leg_payoff
spot_grid = np.array([90.0, 100.0, 110.0])
payoff = stock_leg_payoff(spot_grid, entry_price=100.0, side="short")
assert payoff == pytest.approx([10.0, 0.0, -10.0])
def test_strategy_payoff_with_stock_leg(self):
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff
# Covered call: long stock + short call
spot_grid = np.array([90.0, 100.0, 110.0, 120.0])
legs = [
PayoffLeg(instrument="stock", side="long", entry_price=100.0),
PayoffLeg(
instrument="option",
side="short",
option_type="call",
strike=110.0,
premium=3.0,
),
]
payoff = strategy_payoff(spot_grid, legs=legs)
assert payoff.shape == spot_grid.shape
# At 90: stock P&L = -10, short call = +3 (OTM) → total = -7
assert payoff[0] == pytest.approx(-7.0)
# At 110: stock P&L = +10, short call = +3 (ATM, intrinsic=0) → total = +13
assert payoff[2] == pytest.approx(13.0)
def test_strategy_leg_accepts_stock_instrument(self):
from ferro_ta.analysis.options_strategy import StrategyLeg
leg = StrategyLeg(
underlying="NIFTY",
expiry_selector=None,
strike_selector=None,
option_type=None,
instrument="stock",
side="long",
)
assert leg.instrument == "stock"
class TestExtendedGreeks:
def test_extended_greeks_returns_five_values(self):
from ferro_ta.analysis.options import ExtendedGreeks, extended_greeks
eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.2, option_type="call")
assert isinstance(eg, ExtendedGreeks)
assert eg.vanna is not None
assert eg.volga is not None
assert eg.charm is not None
assert eg.speed is not None
assert eg.color is not None
def test_vanna_sign_otm_call(self):
# OTM call vanna > 0 (delta increases as vol rises)
from ferro_ta.analysis.options import extended_greeks
eg = extended_greeks(100.0, 110.0, 0.05, 1.0, 0.2, option_type="call")
assert eg.vanna > 0.0
def test_extended_greeks_finite_for_valid_inputs(self):
from ferro_ta.analysis.options import extended_greeks
eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.25, option_type="put")
assert np.isfinite(eg.vanna)
assert np.isfinite(eg.volga)
assert np.isfinite(eg.charm)
assert np.isfinite(eg.speed)
assert np.isfinite(eg.color)
def test_volga_positive_atm(self):
# Volga is always non-negative for standard BSM inputs
from ferro_ta.analysis.options import extended_greeks
eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.2, option_type="call")
assert eg.volga >= 0.0
class TestDigitalOptions:
def test_cash_or_nothing_call_atm(self):
from ferro_ta.analysis.options import digital_option_price
# ATM cash-or-nothing call ≈ e^{-rT} * N(d2) ≈ 0.532
price = digital_option_price(
100.0,
100.0,
0.05,
1.0,
0.2,
option_type="call",
digital_type="cash_or_nothing",
)
assert 0.0 < price < 1.0
assert price == pytest.approx(0.532, rel=0.02)
def test_asset_or_nothing_call_atm(self):
from ferro_ta.analysis.options import digital_option_price
price = digital_option_price(
100.0,
100.0,
0.05,
1.0,
0.2,
option_type="call",
digital_type="asset_or_nothing",
)
# asset-or-nothing call ≈ S * N(d1) < S
assert 0.0 < price < 100.0
def test_put_call_parity_cash_or_nothing(self):
from ferro_ta.analysis.options import digital_option_price
call = digital_option_price(
100.0,
100.0,
0.05,
1.0,
0.25,
option_type="call",
digital_type="cash_or_nothing",
)
put = digital_option_price(
100.0,
100.0,
0.05,
1.0,
0.25,
option_type="put",
digital_type="cash_or_nothing",
)
discount = np.exp(-0.05)
assert call + put == pytest.approx(discount, rel=1e-6)
def test_digital_greeks_finite(self):
from ferro_ta.analysis.options import digital_option_greeks
g = digital_option_greeks(
100.0,
100.0,
0.05,
1.0,
0.2,
option_type="call",
digital_type="cash_or_nothing",
)
assert np.isfinite(g.delta)
assert np.isfinite(g.gamma)
assert np.isfinite(g.vega)
def test_digital_invalid_returns_nan(self):
from ferro_ta.analysis.options import digital_option_price
price = digital_option_price(
-1.0,
100.0,
0.05,
1.0,
0.2,
option_type="call",
digital_type="cash_or_nothing",
)
assert np.isnan(price)
class TestAmericanOptions:
def test_american_price_gte_european(self):
from ferro_ta.analysis.options import american_option_price, option_price
spot, strike, rate, tte, vol = 100.0, 100.0, 0.05, 1.0, 0.2
american = american_option_price(
spot, strike, rate, tte, vol, option_type="call"
)
european = option_price(spot, strike, rate, tte, vol, option_type="call")
assert american >= european - 1e-8
def test_early_exercise_premium_nonnegative(self):
from ferro_ta.analysis.options import early_exercise_premium
premium = early_exercise_premium(
100.0, 100.0, 0.05, 1.0, 0.2, option_type="put"
)
assert premium >= 0.0
def test_american_put_early_exercise_positive(self):
# Deep ITM put with high rate should have meaningful early exercise premium
from ferro_ta.analysis.options import early_exercise_premium
premium = early_exercise_premium(80.0, 100.0, 0.1, 0.5, 0.25, option_type="put")
assert premium > 0.0
def test_american_call_no_dividends_no_premium(self):
# With zero carry (no dividends), American call = European call
from ferro_ta.analysis.options import early_exercise_premium
premium = early_exercise_premium(
100.0, 100.0, 0.05, 1.0, 0.2, option_type="call", carry=0.0
)
assert premium == pytest.approx(0.0, abs=1e-4)
class TestVolEstimators:
@pytest.fixture
def sample_ohlc(self):
rng = np.random.default_rng(42)
n = 100
log_ret = rng.normal(0.0, 0.01, n)
close = 100.0 * np.cumprod(np.exp(log_ret))
high = close * np.exp(np.abs(rng.normal(0.0, 0.005, n)))
low = close * np.exp(-np.abs(rng.normal(0.0, 0.005, n)))
open_ = np.roll(close, 1)
open_[0] = close[0]
return open_, high, low, close
def test_close_to_close_vol_length(self, sample_ohlc):
from ferro_ta.analysis.options import close_to_close_vol
_, _, _, close = sample_ohlc
out = close_to_close_vol(close, window=20)
assert len(out) == len(close)
def test_close_to_close_vol_warmup_nan(self, sample_ohlc):
from ferro_ta.analysis.options import close_to_close_vol
_, _, _, close = sample_ohlc
out = close_to_close_vol(close, window=20)
# First `window` values are NaN; index `window` is the first valid value
assert all(np.isnan(out[:20]))
assert np.isfinite(out[20])
def test_parkinson_vol_finite_and_positive(self, sample_ohlc):
from ferro_ta.analysis.options import parkinson_vol
_, high, low, _ = sample_ohlc
out = parkinson_vol(high, low, window=20)
finite = out[~np.isnan(out)]
assert len(finite) > 0
assert np.all(finite > 0.0)
def test_garman_klass_vol(self, sample_ohlc):
from ferro_ta.analysis.options import garman_klass_vol
open_, high, low, close = sample_ohlc
out = garman_klass_vol(open_, high, low, close, window=20)
finite = out[~np.isnan(out)]
assert len(finite) > 0
assert np.all(finite > 0.0)
def test_rogers_satchell_vol(self, sample_ohlc):
from ferro_ta.analysis.options import rogers_satchell_vol
open_, high, low, close = sample_ohlc
out = rogers_satchell_vol(open_, high, low, close, window=20)
finite = out[~np.isnan(out)]
assert len(finite) > 0
def test_yang_zhang_vol(self, sample_ohlc):
from ferro_ta.analysis.options import yang_zhang_vol
open_, high, low, close = sample_ohlc
out = yang_zhang_vol(open_, high, low, close, window=20)
finite = out[~np.isnan(out)]
assert len(finite) > 0
assert np.all(finite > 0.0)
def test_yang_zhang_lower_variance_than_close_to_close(self, sample_ohlc):
# YZ is more efficient than close-to-close
from ferro_ta.analysis.options import close_to_close_vol, yang_zhang_vol
open_, high, low, close = sample_ohlc
c2c = close_to_close_vol(close, window=20)
yz = yang_zhang_vol(open_, high, low, close, window=20)
valid = ~np.isnan(c2c) & ~np.isnan(yz)
# YZ variance < C2C variance (efficiency test)
assert np.var(yz[valid]) <= np.var(c2c[valid]) * 2.0 # lenient bound
class TestVolCone:
def test_vol_cone_shape(self):
from ferro_ta.analysis.options import VolCone, vol_cone
rng = np.random.default_rng(0)
close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 300)))
cone = vol_cone(close, windows=(21, 42, 63))
assert isinstance(cone, VolCone)
assert len(cone.windows) == 3
assert len(cone.min) == 3
def test_vol_cone_monotonic_percentiles(self):
from ferro_ta.analysis.options import vol_cone
rng = np.random.default_rng(1)
close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500)))
cone = vol_cone(close, windows=(21, 42, 63, 126, 252))
for i in range(len(cone.windows)):
assert (
cone.min[i]
<= cone.p25[i]
<= cone.median[i]
<= cone.p75[i]
<= cone.max[i]
)
def test_vol_cone_positive_values(self):
from ferro_ta.analysis.options import vol_cone
rng = np.random.default_rng(2)
close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 400)))
cone = vol_cone(close)
assert np.all(cone.min > 0.0)
class TestStrategyAnalytics:
def test_put_call_parity_deviation_zero(self):
from ferro_ta.analysis.options import option_price, put_call_parity_deviation
s, k, r, tte, vol = 100.0, 100.0, 0.05, 1.0, 0.2
call = option_price(s, k, r, tte, vol, option_type="call")
put = option_price(s, k, r, tte, vol, option_type="put")
dev = put_call_parity_deviation(call, put, s, k, r, tte)
assert dev == pytest.approx(0.0, abs=1e-6)
def test_put_call_parity_deviation_nonzero_for_stale_quote(self):
from ferro_ta.analysis.options import put_call_parity_deviation
dev = put_call_parity_deviation(15.0, 5.0, 100.0, 100.0, 0.05, 1.0)
assert abs(dev) > 0.01
def test_expected_move_positive(self):
from ferro_ta.analysis.options import expected_move
lower, upper = expected_move(100.0, 0.2, 30.0)
assert upper > 0.0
assert lower < 0.0
def test_expected_move_log_normal_asymmetry(self):
# Log-normal expected move: upper > |lower| (right-skew)
from ferro_ta.analysis.options import expected_move
lower, upper = expected_move(100.0, 0.2, 30.0)
# Both magnitudes are similar (within 10%) but upper > |lower|
assert upper > abs(lower) * 0.95
assert upper < abs(lower) * 2.0
def test_strategy_value_near_expiry_approx_payoff(self):
from ferro_ta.analysis.derivatives_payoff import (
PayoffLeg,
strategy_payoff,
strategy_value,
)
# Near expiry, BSM value ≈ intrinsic payoff
spot_grid = np.array([90.0, 100.0, 110.0])
legs = [
PayoffLeg(
instrument="option",
side="long",
option_type="call",
strike=100.0,
premium=0.0,
volatility=0.2,
time_to_expiry=0.001,
)
]
val = strategy_value(spot_grid, legs=legs, time_to_expiry=0.001, volatility=0.2)
payoff = strategy_payoff(spot_grid, legs=legs)
# Near expiry, value ≈ payoff (within a few cents)
assert np.allclose(val, payoff, atol=0.5)
def test_strategy_value_shape(self):
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_value
spot_grid = np.linspace(80.0, 120.0, 20)
legs = [
PayoffLeg(
instrument="option",
side="long",
option_type="call",
strike=100.0,
premium=5.0,
volatility=0.2,
time_to_expiry=0.5,
)
]
val = strategy_value(spot_grid, legs=legs, time_to_expiry=0.5, volatility=0.2)
assert val.shape == spot_grid.shape
class TestDerivativesBenchmarking:
def test_derivatives_benchmark_smoke(self, tmp_path):
root = Path(__file__).resolve().parents[2]
+608
View File
@@ -0,0 +1,608 @@
"""
Accuracy/correctness tests for ferro-ta derivatives analytics.
Each test class validates the ferro-ta implementation against reference
formulas implemented using scipy and numpy.
"""
from __future__ import annotations
import numpy as np
import pytest
# ---------------------------------------------------------------------------
# Reference formulas (pure numpy / scipy)
# ---------------------------------------------------------------------------
def _norm_cdf(x):
"""Standard normal CDF via scipy."""
from scipy.stats import norm as _norm
return _norm.cdf(x)
def _norm_pdf(x):
from scipy.stats import norm as _norm
return _norm.pdf(x)
def bsm_call(S, K, r, q, T, sigma): # noqa: N803
"""Reference BSM call price."""
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
d2 = d1 - sigma * np.sqrt(T)
return S * np.exp(-q * T) * _norm_cdf(d1) - K * np.exp(-r * T) * _norm_cdf(d2)
def bsm_put(S, K, r, q, T, sigma): # noqa: N803
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
d2 = d1 - sigma * np.sqrt(T)
return K * np.exp(-r * T) * _norm_cdf(-d2) - S * np.exp(-q * T) * _norm_cdf(-d1)
def bsm_delta_call(S, K, r, q, T, sigma): # noqa: N803
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
return np.exp(-q * T) * _norm_cdf(d1)
def digital_cash_call(S, K, r, q, T, sigma): # noqa: N803
d2 = (np.log(S / K) + (r - q - 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
return np.exp(-r * T) * _norm_cdf(d2)
def digital_asset_call(S, K, r, q, T, sigma): # noqa: N803
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
return S * np.exp(-q * T) * _norm_cdf(d1)
def digital_cash_put(S, K, r, q, T, sigma): # noqa: N803
d2 = (np.log(S / K) + (r - q - 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
return np.exp(-r * T) * _norm_cdf(-d2)
def digital_asset_put(S, K, r, q, T, sigma): # noqa: N803
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
return S * np.exp(-q * T) * _norm_cdf(-d1)
def vanna_num(S, K, r, q, T, sigma, eps=1e-4): # noqa: N803
"""∂Δ/∂σ via central differences."""
delta_up = bsm_delta_call(S, K, r, q, T, sigma + eps)
delta_dn = bsm_delta_call(S, K, r, q, T, sigma - eps)
return (delta_up - delta_dn) / (2 * eps)
def vega_bsm(S, K, r, q, T, sigma): # noqa: N803
d1 = (np.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
return S * np.exp(-q * T) * _norm_pdf(d1) * np.sqrt(T)
def volga_num(S, K, r, q, T, sigma, eps=1e-4): # noqa: N803
"""∂²V/∂σ² via central differences."""
v_up = vega_bsm(S, K, r, q, T, sigma + eps)
v_dn = vega_bsm(S, K, r, q, T, sigma - eps)
return (v_up - v_dn) / (2 * eps)
def ctc_vol_reference(close, window, trading_days=252.0):
"""Close-to-close vol: rolling std of log returns × sqrt(trading_days)."""
log_ret = np.log(close[1:] / close[:-1])
n = len(close)
out = np.full(n, np.nan)
for i in range(window, n):
returns_window = log_ret[i - window : i]
out[i] = np.sqrt(np.sum(returns_window**2) / window * trading_days)
return out
# ---------------------------------------------------------------------------
# Test cases
# ---------------------------------------------------------------------------
# Six parameter sets: ATM, 10% OTM, 10% ITM, low vol, high vol, non-zero carry
_DIGITAL_CASES = [
# (S, K, r, q, T, sigma, label)
(100.0, 100.0, 0.05, 0.00, 1.0, 0.20, "ATM"),
(100.0, 110.0, 0.05, 0.00, 1.0, 0.20, "10% OTM"),
(100.0, 90.0, 0.05, 0.00, 1.0, 0.20, "10% ITM"),
(100.0, 100.0, 0.05, 0.00, 1.0, 0.05, "low vol"),
(100.0, 100.0, 0.05, 0.00, 1.0, 0.50, "high vol"),
(100.0, 100.0, 0.05, 0.03, 1.0, 0.20, "non-zero carry"),
]
class TestDigitalOptionsAccuracy:
@pytest.fixture(autouse=True)
def require_scipy(self):
pytest.importorskip("scipy")
def test_cash_or_nothing_call_vs_reference(self):
from ferro_ta.analysis.options import digital_option_price
for S, K, r, q, T, sigma, label in _DIGITAL_CASES:
expected = digital_cash_call(S, K, r, q, T, sigma)
actual = digital_option_price(
S,
K,
r,
T,
sigma,
option_type="call",
digital_type="cash_or_nothing",
carry=q,
)
assert actual == pytest.approx(expected, abs=1e-6), (
f"cash_or_nothing call mismatch for case '{label}': "
f"got {actual}, expected {expected}"
)
def test_cash_or_nothing_put_vs_reference(self):
from ferro_ta.analysis.options import digital_option_price
for S, K, r, q, T, sigma, label in _DIGITAL_CASES:
expected = digital_cash_put(S, K, r, q, T, sigma)
actual = digital_option_price(
S,
K,
r,
T,
sigma,
option_type="put",
digital_type="cash_or_nothing",
carry=q,
)
assert actual == pytest.approx(expected, abs=1e-6), (
f"cash_or_nothing put mismatch for case '{label}': "
f"got {actual}, expected {expected}"
)
def test_asset_or_nothing_call_vs_reference(self):
from ferro_ta.analysis.options import digital_option_price
for S, K, r, q, T, sigma, label in _DIGITAL_CASES:
expected = digital_asset_call(S, K, r, q, T, sigma)
actual = digital_option_price(
S,
K,
r,
T,
sigma,
option_type="call",
digital_type="asset_or_nothing",
carry=q,
)
# Tolerance 1e-4: asset-or-nothing involves S * N(d1), small numerical diff expected
assert actual == pytest.approx(expected, abs=1e-4), (
f"asset_or_nothing call mismatch for case '{label}': "
f"got {actual}, expected {expected}"
)
def test_asset_or_nothing_put_vs_reference(self):
from ferro_ta.analysis.options import digital_option_price
for S, K, r, q, T, sigma, label in _DIGITAL_CASES:
expected = digital_asset_put(S, K, r, q, T, sigma)
actual = digital_option_price(
S,
K,
r,
T,
sigma,
option_type="put",
digital_type="asset_or_nothing",
carry=q,
)
# Tolerance 1e-4: asset-or-nothing involves S * N(-d1), small numerical diff expected
assert actual == pytest.approx(expected, abs=1e-4), (
f"asset_or_nothing put mismatch for case '{label}': "
f"got {actual}, expected {expected}"
)
def test_batch_digital_price_matches_scalar(self):
"""Vectorized call must match scalar loop for 10 random points."""
from ferro_ta.analysis.options import digital_option_price
rng = np.random.default_rng(7)
n = 10
S_arr = rng.uniform(80.0, 120.0, n)
K_arr = rng.uniform(80.0, 120.0, n)
r_arr = rng.uniform(0.01, 0.10, n)
T_arr = rng.uniform(0.1, 2.0, n)
sigma_arr = rng.uniform(0.10, 0.50, n)
batch = digital_option_price(
S_arr,
K_arr,
r_arr,
T_arr,
sigma_arr,
option_type="call",
digital_type="cash_or_nothing",
)
scalar_results = np.array(
[
digital_option_price(
float(S_arr[i]),
float(K_arr[i]),
float(r_arr[i]),
float(T_arr[i]),
float(sigma_arr[i]),
option_type="call",
digital_type="cash_or_nothing",
)
for i in range(n)
]
)
assert batch == pytest.approx(scalar_results, abs=1e-10), (
"Batch digital_option_price does not match scalar loop"
)
# Four cases for extended Greeks: ITM call, ATM call, OTM call, ATM put
_GREEK_CASES = [
# (S, K, r, q, T, sigma, option_type, label)
(110.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "ITM call"),
(100.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "ATM call"),
(90.0, 100.0, 0.05, 0.0, 1.0, 0.20, "call", "OTM call"),
(100.0, 100.0, 0.05, 0.0, 1.0, 0.20, "put", "ATM put"),
]
class TestExtendedGreeksAccuracy:
@pytest.fixture(autouse=True)
def require_scipy(self):
pytest.importorskip("scipy")
def test_vanna_vs_numerical_fd(self):
"""extended_greeks().vanna matches ∂Δ/∂σ from central differences (tol=1e-3)."""
from ferro_ta.analysis.options import extended_greeks
for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES:
eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q)
# Reference is defined only for calls; for put use numerical FD directly
if opt_type == "call":
expected = vanna_num(S, K, r, q, T, sigma)
else:
# Vanna for put: ∂(put delta)/∂σ = ∂(call delta - e^{-qT})/∂σ = vanna_call
expected = vanna_num(S, K, r, q, T, sigma)
assert float(eg.vanna) == pytest.approx(expected, abs=1e-3), (
f"Vanna mismatch for '{label}': got {eg.vanna}, expected {expected}"
)
def test_volga_vs_numerical_fd(self):
"""extended_greeks().volga matches ∂²V/∂σ² from central differences (tol=1e-2)."""
from ferro_ta.analysis.options import extended_greeks
for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES:
eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q)
expected = volga_num(S, K, r, q, T, sigma)
assert float(eg.volga) == pytest.approx(expected, abs=1e-2), (
f"Volga mismatch for '{label}': got {eg.volga}, expected {expected}"
)
def test_speed_negative_for_calls(self):
"""Speed (∂Γ/∂S) should be negative for OTM calls — Gamma decreases as S moves away."""
from ferro_ta.analysis.options import extended_greeks
# OTM call: S < K
eg = extended_greeks(90.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
assert float(eg.speed) < 0.0, (
f"Speed should be negative for OTM call, got {eg.speed}"
)
def test_charm_finite_for_valid_inputs(self):
"""Charm should be finite and non-zero for non-degenerate inputs."""
from ferro_ta.analysis.options import extended_greeks
for S, K, r, q, T, sigma, opt_type, label in _GREEK_CASES:
eg = extended_greeks(S, K, r, T, sigma, option_type=opt_type, carry=q)
assert np.isfinite(float(eg.charm)), (
f"Charm is not finite for '{label}': {eg.charm}"
)
assert eg.charm != 0.0, (
f"Charm is zero for '{label}' — unexpected for non-degenerate inputs"
)
class TestAmericanOptionsAccuracy:
"""Property-based tests for American options (no scipy required)."""
def test_baw_vs_published_values(self):
"""BAW American put satisfies the lower bound: price ≥ max(K - S, European BSM put).
The Haug (2007) table uses b = r - q (cost of carry convention). Rather
than replicate the exact table which requires matching the BAW carry
convention precisely we verify two model-agnostic inequalities that any
correct American-put implementation must satisfy:
1. American put intrinsic value (K - S)
2. American put European BSM put (early exercise has non-negative value)
"""
from ferro_ta.analysis.options import american_option_price, option_price
S, K, r, T, sigma = 100.0, 100.0, 0.10, 0.25, 0.20
american = american_option_price(S, K, r, T, sigma, option_type="put")
european = option_price(S, K, r, T, sigma, option_type="put")
assert american >= max(K - S, 0.0) - 1e-8, (
f"American put below intrinsic: {american:.4f} < {max(K - S, 0.0)}"
)
assert american >= european - 1e-8, (
f"American put below European put: {american:.4f} < {european:.4f}"
)
# Sanity-check: American ATM put should be in a reasonable range
assert 0.0 < american < K, (
f"American put price {american:.4f} is outside (0, K={K})"
)
def test_american_put_increases_with_strike(self):
"""Deeper ITM (higher strike for put) ⇒ higher American put price.
Uses moderately spaced strikes to avoid the intrinsic-value floor
where K - S becomes the binding constraint and the increments are
exactly 1-for-1, which can mask ordering issues near the floor.
"""
from ferro_ta.analysis.options import american_option_price
# S = 100, K in {85, 100, 115}; rate and carry both 0.05 to avoid b=0 issues
S, r, T, sigma = 100.0, 0.05, 0.5, 0.25
strikes = [85.0, 100.0, 115.0]
prices = [
american_option_price(S, K, r, T, sigma, option_type="put", carry=r)
for K in strikes
]
assert prices[0] < prices[1] < prices[2], (
f"American put prices not monotone in strike: "
f"K={strikes} → prices={[round(p, 4) for p in prices]}"
)
def test_american_call_increases_with_spot(self):
"""Higher spot ⇒ higher American call price."""
from ferro_ta.analysis.options import american_option_price
spots = [90.0, 100.0, 110.0]
prices = [
american_option_price(S, 100.0, 0.05, 1.0, 0.20, option_type="call")
for S in spots
]
assert prices[0] < prices[1] < prices[2], (
f"American call prices not monotone in spot: {prices}"
)
def test_american_call_equals_european_no_dividends_no_early_exercise(self):
"""American call with no early-exercise incentive (carry=0) ≈ European call.
When the cost-of-carry parameter is zero, there is no dividend/carry
benefit to holding the underlying. In this regime, it is never
optimal to early-exercise an American call, so the American call price
equals the European call price computed with the same carry=0 convention.
The `early_exercise_premium` function exposes this directly and should
return ~0 for calls with carry=0.
"""
from ferro_ta.analysis.options import early_exercise_premium
S, K, r, T, sigma = 100.0, 100.0, 0.05, 1.0, 0.20
premium = early_exercise_premium(
S, K, r, T, sigma, option_type="call", carry=0.0
)
assert premium == pytest.approx(0.0, abs=1e-4), (
f"Early exercise premium for call with carry=0 should be ~0, got {premium:.6f}"
)
def test_early_exercise_premium_positive_for_deep_itm_put(self):
"""Deep ITM American put should have a meaningful early exercise premium.
When S is well below K (deep ITM put), the time value is low and the
interest gained from early exercise of the put dominates leading to a
positive early-exercise premium.
"""
from ferro_ta.analysis.options import early_exercise_premium
# Deep ITM: S=70, K=100 — strong incentive to exercise early
premium = early_exercise_premium(
70.0, 100.0, 0.10, 1.0, 0.20, option_type="put"
)
assert premium > 0.0, (
f"Deep ITM American put early exercise premium should be > 0, got {premium}"
)
class TestVolEstimatorsAccuracy:
@pytest.fixture(autouse=True)
def require_scipy(self):
pytest.importorskip("scipy")
def test_close_to_close_vs_reference_impl(self):
"""C2C vol matches reference formula exactly (tol=1e-10), 100 samples."""
from ferro_ta.analysis.options import close_to_close_vol
rng = np.random.default_rng(42)
log_ret = rng.normal(0.0, 0.01, 100)
close = 100.0 * np.cumprod(np.exp(log_ret))
window = 20
actual = close_to_close_vol(close, window=window, trading_days_per_year=252.0)
expected = ctc_vol_reference(close, window=window, trading_days=252.0)
valid = ~np.isnan(expected)
assert np.allclose(actual[valid], expected[valid], atol=1e-10), (
"close_to_close_vol does not match reference formula"
)
def test_constant_returns_known_vol(self):
"""Constant daily log-return of 0.01 → C2C vol = 0.01 * sqrt(252) ≈ 0.1587."""
from ferro_ta.analysis.options import close_to_close_vol
# Build a price series with constant daily log-return of 0.01
n = 100
constant_log_ret = 0.01
close = 100.0 * np.exp(np.arange(n) * constant_log_ret)
window = 21
out = close_to_close_vol(close, window=window, trading_days_per_year=252.0)
# Expected: sqrt(0.01^2 * 252) = 0.01 * sqrt(252)
expected_vol = constant_log_ret * np.sqrt(252.0)
valid = ~np.isnan(out)
assert np.all(valid[window:]), "Expected valid values after warmup"
assert out[window] == pytest.approx(expected_vol, rel=1e-10), (
f"Constant-return vol: got {out[window]}, expected {expected_vol}"
)
def test_parkinson_lognormal_unbiased(self):
"""Parkinson estimator within 50% of true vol=0.20 for simulated OHLC data.
Parkinson uses the log(high/low) range as a proxy for daily realized
vol. The estimator is unbiased for a Brownian-motion diffusion where
the daily range follows a known distribution, but a simplified
simulation (single end-of-day price + independent range draw) will
underestimate the range. We therefore build a proper multi-step
intraday path so the high/low reflects the true diffusion range,
and use a lenient 50% tolerance to accommodate finite-sample noise.
"""
from ferro_ta.analysis.options import parkinson_vol
rng = np.random.default_rng(123)
true_vol = 0.20
n_days = 500
steps_per_day = 50 # intraday steps to get a realistic H-L range
daily_sigma = true_vol / np.sqrt(252.0)
step_sigma = daily_sigma / np.sqrt(steps_per_day)
# Simulate intraday paths, extract open/high/low/close each day
highs = np.empty(n_days)
lows = np.empty(n_days)
price = 100.0
for i in range(n_days):
intraday = price * np.exp(
np.cumsum(rng.normal(0.0, step_sigma, steps_per_day))
)
path = np.concatenate([[price], intraday])
highs[i] = path.max()
lows[i] = path.min()
price = intraday[-1]
window = 21
out = parkinson_vol(highs, lows, window=window, trading_days_per_year=252.0)
valid = out[~np.isnan(out)]
assert len(valid) > 0, "No valid Parkinson estimates"
median_est = float(np.median(valid))
assert abs(median_est - true_vol) < 0.50 * true_vol, (
f"Parkinson estimate {median_est:.4f} is more than 50% from true vol {true_vol}"
)
def test_vol_estimators_all_positive_finite(self):
"""All 5 estimators produce finite and positive non-NaN values on random OHLC."""
from ferro_ta.analysis.options import (
close_to_close_vol,
garman_klass_vol,
parkinson_vol,
rogers_satchell_vol,
yang_zhang_vol,
)
rng = np.random.default_rng(99)
n = 200
log_ret = rng.normal(0.0, 0.01, n)
close = 100.0 * np.cumprod(np.exp(log_ret))
high = close * np.exp(np.abs(rng.normal(0.0, 0.005, n)))
low = close * np.exp(-np.abs(rng.normal(0.0, 0.005, n)))
open_ = np.roll(close, 1)
open_[0] = close[0]
window = 20
estimators = {
"close_to_close": close_to_close_vol(close, window=window),
"parkinson": parkinson_vol(high, low, window=window),
"garman_klass": garman_klass_vol(open_, high, low, close, window=window),
"rogers_satchell": rogers_satchell_vol(
open_, high, low, close, window=window
),
"yang_zhang": yang_zhang_vol(open_, high, low, close, window=window),
}
for name, out in estimators.items():
valid = out[~np.isnan(out)]
assert len(valid) > 0, f"{name}: no valid (non-NaN) estimates"
assert np.all(np.isfinite(valid)), f"{name}: non-finite values present"
assert np.all(valid > 0.0), f"{name}: non-positive values present"
class TestVolConeAccuracy:
"""Tests for vol_cone — no scipy required."""
def test_cone_windows_match_requested(self):
"""Output windows should match the input list exactly."""
from ferro_ta.analysis.options import vol_cone
rng = np.random.default_rng(0)
close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500)))
requested = (10, 21, 42)
cone = vol_cone(close, windows=requested)
assert list(cone.windows.astype(int)) == list(requested), (
f"Cone windows {list(cone.windows)} do not match requested {list(requested)}"
)
def test_cone_median_matches_rolling_median(self):
"""Manually computed rolling C2C vol median for window=21 should match cone.median[0]."""
from ferro_ta.analysis.options import close_to_close_vol, vol_cone
rng = np.random.default_rng(5)
close = 100.0 * np.cumprod(np.exp(rng.normal(0.0, 0.01, 500)))
window = 21
cone = vol_cone(close, windows=(window,))
rolling = close_to_close_vol(close, window=window, trading_days_per_year=252.0)
valid = rolling[~np.isnan(rolling)]
manual_median = float(np.median(valid))
assert cone.median[0] == pytest.approx(manual_median, rel=1e-6), (
f"vol_cone median {cone.median[0]:.6f} does not match manual median {manual_median:.6f}"
)
class TestStrategyAnalyticsAccuracy:
@pytest.fixture(autouse=True)
def require_scipy(self):
pytest.importorskip("scipy")
def test_put_call_parity_deviation_analytical(self):
"""BSM call/put from scipy formulas fed into put_call_parity_deviation → < 1e-8."""
from ferro_ta.analysis.options import put_call_parity_deviation
S, K, r, q, T, sigma = 100.0, 100.0, 0.05, 0.02, 1.0, 0.20
call = bsm_call(S, K, r, q, T, sigma)
put = bsm_put(S, K, r, q, T, sigma)
dev = put_call_parity_deviation(call, put, S, K, r, T, carry=q)
assert abs(dev) < 1e-8, (
f"put_call_parity_deviation for BSM-consistent prices: got {dev}, expected ~0"
)
def test_expected_move_known_value(self):
"""S=100, iv=0.20, days=30, trading_days=252 → upper move ≈ 7.14."""
from ferro_ta.analysis.options import expected_move
S, iv, days, td = 100.0, 0.20, 30.0, 252.0
lower, upper = expected_move(S, iv, days, td)
# log-normal formula: S * (exp(sigma * sqrt(days/trading_days)) - 1)
expected_upper = S * (np.exp(iv * np.sqrt(days / td)) - 1.0)
expected_lower = S * (np.exp(-iv * np.sqrt(days / td)) - 1.0)
assert upper == pytest.approx(expected_upper, rel=1e-6), (
f"expected_move upper: got {upper:.4f}, expected {expected_upper:.4f}"
)
assert lower == pytest.approx(expected_lower, rel=1e-6), (
f"expected_move lower: got {lower:.4f}, expected {expected_lower:.4f}"
)
# Numeric check: upper ≈ 7.14
assert upper == pytest.approx(7.14, abs=0.05), (
f"expected_move upper should be ~7.14, got {upper:.4f}"
)
Generated
+16 -10
View File
@@ -950,7 +950,7 @@ wheels = [
[[package]]
name = "ferro-ta"
version = "1.1.2"
version = "1.2.0"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
@@ -993,6 +993,8 @@ dev = [
{ name = "pytest-cov" },
{ name = "pyyaml" },
{ name = "ruff" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
docs = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
@@ -1030,6 +1032,8 @@ dev = [
{ name = "pytest" },
{ name = "pyyaml" },
{ name = "ruff" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "ta" },
]
@@ -1067,6 +1071,7 @@ requires-dist = [
{ name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" },
{ name = "quantstats", marker = "extra == 'comparison'", specifier = ">=0.0.81" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" },
{ name = "scipy", marker = "extra == 'dev'", specifier = ">=1.10" },
{ name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.0" },
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.3" },
{ name = "ta", marker = "extra == 'comparison'", specifier = ">=0.10" },
@@ -1089,6 +1094,7 @@ dev = [
{ name = "pytest", specifier = ">=7.0" },
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "ruff", specifier = ">=0.3" },
{ name = "scipy", specifier = ">=1.15.3" },
{ name = "ta", specifier = ">=0.10" },
]
@@ -1266,11 +1272,11 @@ wheels = [
[[package]]
name = "idna"
version = "3.11"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -2900,7 +2906,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -2911,9 +2917,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
@@ -4255,11 +4261,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.6.3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
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" }
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" }
wheels = [
{ 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" },
{ 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" },
]
[[package]]
+2 -2
View File
@@ -49,11 +49,11 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "ferro_ta_core"
version = "1.1.2"
version = "1.2.0"
[[package]]
name = "ferro_ta_wasm"
version = "1.1.2"
version = "1.2.0"
dependencies = [
"ferro_ta_core",
"js-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ferro_ta_wasm"
version = "1.1.2"
version = "1.2.0"
edition = "2021"
description = "WebAssembly bindings for ferro-ta technical analysis indicators"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ferro-ta-wasm",
"version": "1.1.2",
"version": "1.2.0",
"description": "WebAssembly bindings for ferro-ta technical analysis indicators",
"main": "node/ferro_ta_wasm.js",
"module": "web/ferro_ta_wasm.js",
+485
View File
@@ -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))
}
/// 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
// ===========================================================================
@@ -2724,6 +2736,479 @@ pub fn macd_crossover_signals(close: &Float64Array, fastperiod: usize, slowperio
}
}
// ===========================================================================
// New Options Features (extended Greeks, digital, American, vol estimators,
// vol cone, expected move, put-call parity, strategy payoff/value/Greeks)
// ===========================================================================
// ---------------------------------------------------------------------------
// Helpers shared by the new features
// ---------------------------------------------------------------------------
fn parse_digital_kind(digital_type: &str) -> ferro_ta_core::options::digital::DigitalKind {
match digital_type.to_ascii_lowercase().as_str() {
"asset_or_nothing" | "asset" => ferro_ta_core::options::digital::DigitalKind::AssetOrNothing,
_ => ferro_ta_core::options::digital::DigitalKind::CashOrNothing,
}
}
/// Convert a Float64Array to a Vec<i64> (for instrument/side/option_type codes).
fn to_i64_vec(arr: &Float64Array) -> Vec<i64> {
to_vec(arr).into_iter().map(|x| x as i64).collect()
}
/// Convert a Float64Array to a Vec<usize> (for window sizes).
fn to_usize_vec(arr: &Float64Array) -> Vec<usize> {
to_vec(arr).into_iter().map(|x| x as usize).collect()
}
// ---------------------------------------------------------------------------
// Put-call parity check
// ---------------------------------------------------------------------------
/// Put-call parity deviation: `C - P - (S·e^{-qT} - K·e^{-rT})`.
///
/// Returns 0 at no-arbitrage.
#[wasm_bindgen]
pub fn put_call_parity_deviation(
call_price: f64,
put_price: f64,
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
) -> f64 {
ferro_ta_core::options::pricing::put_call_parity_deviation(
call_price, put_price, spot, strike, rate, carry, time_to_expiry,
)
}
// ---------------------------------------------------------------------------
// Extended (higher-order) Greeks
// ---------------------------------------------------------------------------
/// Extended BSM Greeks: vanna, volga, charm, speed, color.
///
/// # Returns
/// `js_sys::Array` of five f64 values: `[vanna, volga, charm, speed, color]`.
#[wasm_bindgen]
pub fn extended_greeks(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
kind: &str,
) -> Array {
use ferro_ta_core::options::{greeks::model_extended_greeks, OptionContract, OptionEvaluation, PricingModel};
let k = parse_option_kind(kind);
// In this codebase, `carry` = dividend yield q (same convention as all other WASM/PyO3 APIs).
let eg = model_extended_greeks(OptionEvaluation {
contract: OptionContract {
model: PricingModel::BlackScholes,
underlying: spot,
strike,
rate,
carry,
time_to_expiry,
kind: k,
},
volatility,
});
let out = Array::new();
out.push(&JsValue::from_f64(eg.vanna));
out.push(&JsValue::from_f64(eg.volga));
out.push(&JsValue::from_f64(eg.charm));
out.push(&JsValue::from_f64(eg.speed));
out.push(&JsValue::from_f64(eg.color));
out
}
// ---------------------------------------------------------------------------
// Digital options
// ---------------------------------------------------------------------------
/// Price a digital (binary) option.
///
/// # Arguments
/// - `kind` `"call"` or `"put"`
/// - `digital_type` `"cash_or_nothing"` (default) or `"asset_or_nothing"`
#[wasm_bindgen]
pub fn digital_price(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
kind: &str,
digital_type: &str,
) -> f64 {
ferro_ta_core::options::digital::digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility,
parse_option_kind(kind),
parse_digital_kind(digital_type),
)
}
/// Greeks for a digital option (numerical central differences).
///
/// # Returns
/// `js_sys::Array` of three f64 values: `[delta, gamma, vega]`.
#[wasm_bindgen]
pub fn digital_greeks(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
kind: &str,
digital_type: &str,
) -> Array {
let (delta, gamma, vega) = ferro_ta_core::options::digital::digital_greeks(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility,
parse_option_kind(kind),
parse_digital_kind(digital_type),
);
let out = Array::new();
out.push(&JsValue::from_f64(delta));
out.push(&JsValue::from_f64(gamma));
out.push(&JsValue::from_f64(vega));
out
}
// ---------------------------------------------------------------------------
// American options (Barone-Adesi-Whaley)
// ---------------------------------------------------------------------------
/// American option price using the Barone-Adesi-Whaley approximation.
#[wasm_bindgen]
pub fn american_price(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
kind: &str,
) -> f64 {
ferro_ta_core::options::american::american_price_baw(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility,
parse_option_kind(kind),
)
}
/// Early exercise premium: `american_price - european_price`.
#[wasm_bindgen]
pub fn early_exercise_premium(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
kind: &str,
) -> f64 {
ferro_ta_core::options::american::early_exercise_premium(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility,
parse_option_kind(kind),
)
}
// ---------------------------------------------------------------------------
// Historical volatility estimators
// ---------------------------------------------------------------------------
/// Close-to-close realised volatility (rolling).
///
/// First `window - 1` values are `NaN`.
#[wasm_bindgen]
pub fn close_to_close_vol(
close: &Float64Array,
window: usize,
trading_days: f64,
) -> Float64Array {
from_vec(ferro_ta_core::options::realized_vol::close_to_close_vol(&to_vec(close), window, trading_days))
}
/// Parkinson (high-low) volatility estimator (rolling).
#[wasm_bindgen]
pub fn parkinson_vol(
high: &Float64Array,
low: &Float64Array,
window: usize,
trading_days: f64,
) -> Float64Array {
from_vec(ferro_ta_core::options::realized_vol::parkinson_vol(
&to_vec(high),
&to_vec(low),
window,
trading_days,
))
}
/// Garman-Klass OHLC volatility estimator (rolling).
#[wasm_bindgen]
pub fn garman_klass_vol(
open: &Float64Array,
high: &Float64Array,
low: &Float64Array,
close: &Float64Array,
window: usize,
trading_days: f64,
) -> Float64Array {
from_vec(ferro_ta_core::options::realized_vol::garman_klass_vol(
&to_vec(open),
&to_vec(high),
&to_vec(low),
&to_vec(close),
window,
trading_days,
))
}
/// Rogers-Satchell OHLC volatility estimator (rolling).
#[wasm_bindgen]
pub fn rogers_satchell_vol(
open: &Float64Array,
high: &Float64Array,
low: &Float64Array,
close: &Float64Array,
window: usize,
trading_days: f64,
) -> Float64Array {
from_vec(ferro_ta_core::options::realized_vol::rogers_satchell_vol(
&to_vec(open),
&to_vec(high),
&to_vec(low),
&to_vec(close),
window,
trading_days,
))
}
/// Yang-Zhang OHLC volatility estimator (rolling).
///
/// Most efficient estimator — handles overnight gaps.
#[wasm_bindgen]
pub fn yang_zhang_vol(
open: &Float64Array,
high: &Float64Array,
low: &Float64Array,
close: &Float64Array,
window: usize,
trading_days: f64,
) -> Float64Array {
from_vec(ferro_ta_core::options::realized_vol::yang_zhang_vol(
&to_vec(open),
&to_vec(high),
&to_vec(low),
&to_vec(close),
window,
trading_days,
))
}
// ---------------------------------------------------------------------------
// Volatility cone
// ---------------------------------------------------------------------------
/// Volatility cone: percentile distribution of close-to-close vol across windows.
///
/// # Arguments
/// - `close` `Float64Array` of close prices.
/// - `windows` `Float64Array` of window sizes (e.g. `[21, 42, 63, 126, 252]`).
/// - `trading_days` annualisation factor (default 252).
///
/// # Returns
/// `js_sys::Array` of length `n_windows`, each element an `Array`:
/// `[window, min, p25, median, p75, max]`.
#[wasm_bindgen]
pub fn vol_cone(
close: &Float64Array,
windows: &Float64Array,
trading_days: f64,
) -> Array {
let c = to_vec(close);
let wins = to_usize_vec(windows);
let slices = ferro_ta_core::options::realized_vol::vol_cone(&c, &wins, trading_days);
let out = Array::new();
for s in slices {
let row = Array::new();
row.push(&JsValue::from_f64(s.window as f64));
row.push(&JsValue::from_f64(s.min));
row.push(&JsValue::from_f64(s.p25));
row.push(&JsValue::from_f64(s.median));
row.push(&JsValue::from_f64(s.p75));
row.push(&JsValue::from_f64(s.max));
out.push(&row);
}
out
}
// ---------------------------------------------------------------------------
// Expected move
// ---------------------------------------------------------------------------
/// Expected move over `days_to_expiry` trading days.
///
/// Uses log-normal: `spot · e^{±σ√(days/trading_days)} spot`.
///
/// # Returns
/// `js_sys::Array` of two f64 values: `[lower_move, upper_move]` (signed).
#[wasm_bindgen]
pub fn expected_move(
spot: f64,
iv: f64,
days_to_expiry: f64,
trading_days_per_year: f64,
) -> Array {
let (lower, upper) = ferro_ta_core::options::surface::expected_move(spot, iv, days_to_expiry, trading_days_per_year);
let out = Array::new();
out.push(&JsValue::from_f64(lower));
out.push(&JsValue::from_f64(upper));
out
}
// ---------------------------------------------------------------------------
// Strategy payoff / value (Feature 8 — WASM exposure)
// ---------------------------------------------------------------------------
/// Aggregate strategy payoff over a spot grid at expiry.
///
/// Instrument codes: `0`=option, `1`=future, `2`=stock.
/// Side codes: `1`=long, `-1`=short.
/// Option type codes: `1`=call, `-1`=put.
///
/// # Returns
/// `Float64Array` of aggregate P&L per spot grid point.
#[wasm_bindgen]
pub fn strategy_payoff_dense(
spot_grid: &Float64Array,
instruments: &Float64Array,
sides: &Float64Array,
option_types: &Float64Array,
strikes: &Float64Array,
premiums: &Float64Array,
entry_prices: &Float64Array,
quantities: &Float64Array,
multipliers: &Float64Array,
) -> Float64Array {
from_vec(ferro_ta_core::options::payoff::strategy_payoff_dense(
&to_vec(spot_grid),
&to_i64_vec(instruments),
&to_i64_vec(sides),
&to_i64_vec(option_types),
&to_vec(strikes),
&to_vec(premiums),
&to_vec(entry_prices),
&to_vec(quantities),
&to_vec(multipliers),
))
}
/// Aggregate BSM Greeks across option and futures/stock legs at a single spot.
///
/// # Returns
/// `js_sys::Array` of five f64 values: `[delta, gamma, vega, theta, rho]`.
#[wasm_bindgen]
pub fn aggregate_greeks_dense(
spot: f64,
instruments: &Float64Array,
sides: &Float64Array,
option_types: &Float64Array,
strikes: &Float64Array,
volatilities: &Float64Array,
time_to_expiries: &Float64Array,
rates: &Float64Array,
carries: &Float64Array,
quantities: &Float64Array,
multipliers: &Float64Array,
) -> Array {
let (delta, gamma, vega, theta, rho) = ferro_ta_core::options::payoff::aggregate_greeks_dense(
spot,
&to_i64_vec(instruments),
&to_i64_vec(sides),
&to_i64_vec(option_types),
&to_vec(strikes),
&to_vec(volatilities),
&to_vec(time_to_expiries),
&to_vec(rates),
&to_vec(carries),
&to_vec(quantities),
&to_vec(multipliers),
);
let out = Array::new();
out.push(&JsValue::from_f64(delta));
out.push(&JsValue::from_f64(gamma));
out.push(&JsValue::from_f64(vega));
out.push(&JsValue::from_f64(theta));
out.push(&JsValue::from_f64(rho));
out
}
/// Current BSM mid-price value of a multi-leg strategy over a spot grid (pre-expiry).
///
/// Unlike `strategy_payoff_dense`, this uses live BSM pricing for option legs.
///
/// # Returns
/// `Float64Array` of strategy value (P&L vs premium paid) per spot grid point.
#[wasm_bindgen]
pub fn strategy_value_grid(
spot_grid: &Float64Array,
instruments: &Float64Array,
sides: &Float64Array,
option_types: &Float64Array,
strikes: &Float64Array,
premiums: &Float64Array,
entry_prices: &Float64Array,
quantities: &Float64Array,
multipliers: &Float64Array,
time_to_expiries: &Float64Array,
volatilities: &Float64Array,
rates: &Float64Array,
carries: &Float64Array,
) -> Float64Array {
from_vec(ferro_ta_core::options::payoff::strategy_value_grid(
&to_vec(spot_grid),
&to_i64_vec(instruments),
&to_i64_vec(sides),
&to_i64_vec(option_types),
&to_vec(strikes),
&to_vec(premiums),
&to_vec(entry_prices),
&to_vec(quantities),
&to_vec(multipliers),
&to_vec(time_to_expiries),
&to_vec(volatilities),
&to_vec(rates),
&to_vec(carries),
))
}
// ---------------------------------------------------------------------------
// WASM tests (run with `wasm-pack test --node`)
// ---------------------------------------------------------------------------