Compare commits

...

15 Commits

Author SHA1 Message Date
dependabot[bot] 4075666251 chore(deps): bump actions/github-script from 8 to 9
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 12:52:06 +00:00
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
Pratik Bhadane cc584dbff9 chore: update ferro-ta version to 1.1.2 in Cargo.lock 2026-04-01 23:50:42 +05:30
Pratik Bhadane 58516672d7 chore: bump ferro-ta version to 1.1.2 in uv.lock 2026-04-01 23:50:10 +05:30
Pratik Bhadane 7b560463f6 chore: update version to 1.1.2 and enhance WASM package
- Bumped version numbers across Cargo.toml, Cargo.lock, pyproject.toml, and conda/meta.yaml to 1.1.2.
- Updated .gitignore to include new WASM build directories for Node.js and web.
- Enhanced WASM npm package to support both Node.js and browser builds with conditional exports.
- Improved CI workflows for WASM publishing and testing.
- Updated documentation to reflect new features and full indicator parity in the WASM package.
2026-04-01 23:47:46 +05:30
Pratik Bhadane 6cd1714a9f Merge pull request #7 from pratikbhadane24/wasm-core
Wasm core
2026-04-01 23:08:40 +05:30
Pratik Bhadane 6e45da2636 chore: update ferro-ta version to 1.1.1 in uv.lock 2026-04-01 23:04:07 +05:30
69 changed files with 7664 additions and 709 deletions
+11
View File
@@ -34,3 +34,14 @@ updates:
labels: labels:
- "dependencies" - "dependencies"
- "github-actions" - "github-actions"
# WASM JavaScript package
- package-ecosystem: "npm"
directory: "/wasm"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "javascript"
+113 -43
View File
@@ -87,7 +87,7 @@ jobs:
steps: steps:
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v4 uses: actions/deploy-pages@v5
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# CI gate — all required jobs must pass before this job succeeds. # 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 # Keep release jobs here because trusted-publisher configuration points to
# CI.yml specifically. # CI.yml specifically.
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# One abi3 wheel per (platform, arch). abi3-py310 means a single wheel
# covers CPython 3.10+ on each target, so there is no python-version axis.
# extension-module + abi3 link no libpython, which is what lets the linux
# jobs cross-compile aarch64/musl from an x86_64 runner via maturin-action.
build-wheels-linux: build-wheels-linux:
name: Build wheels (linux / py${{ matrix.python-version }}) name: Build wheels (linux-gnu / ${{ matrix.target }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release) if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"] target: [x86_64, aarch64]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Build wheel - name: Build abi3 manylinux wheel
uses: PyO3/maturin-action@v1 uses: PyO3/maturin-action@v1
with: with:
command: build command: build
args: --release --out dist --compatibility pypi -i python${{ matrix.python-version }} target: ${{ matrix.target }}
args: --release --out dist
manylinux: "2_17" manylinux: "2_17"
- name: Upload wheels as artifact - name: Upload wheels as artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: wheels-linux-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 path: dist/*.whl
build-wheels-macos: build-wheels-macos:
name: Build wheels (macos / py${{ matrix.python-version }}) name: Build wheels (macos / universal2)
runs-on: macos-latest runs-on: macos-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release) if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: "3.10"
- name: Build universal2 wheel - name: Build universal2 abi3 wheel
uses: PyO3/maturin-action@v1 uses: PyO3/maturin-action@v1
with: with:
command: build command: build
args: --release --out dist --compatibility pypi -i python
target: universal2-apple-darwin target: universal2-apple-darwin
args: --release --out dist
- name: Upload wheels as artifact - name: Upload wheels as artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: wheels-macos-py${{ matrix.python-version }} name: wheels-macos-universal2
path: dist/*.whl path: dist/*.whl
build-wheels-windows: build-wheels-windows:
name: Build wheels (windows / py${{ matrix.python-version }}) name: Build wheels (windows / ${{ matrix.platform.arch }})
runs-on: windows-latest runs-on: ${{ matrix.platform.runner }}
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release) if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
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: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: 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 uses: PyO3/maturin-action@v1
with: with:
command: build command: build
args: --release --out dist --compatibility pypi -i python target: ${{ matrix.platform.target }}
args: --release --out dist
- name: Upload wheels as artifact - name: Upload wheels as artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: wheels-windows-py${{ matrix.python-version }} name: wheels-windows-${{ matrix.platform.arch }}
path: dist/*.whl path: dist/*.whl
build-sdist: build-sdist:
@@ -247,6 +282,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- build-wheels-linux - build-wheels-linux
- build-wheels-musllinux
- build-wheels-macos - build-wheels-macos
- build-wheels-windows - build-wheels-windows
- build-sdist - build-sdist
@@ -256,6 +292,8 @@ jobs:
url: https://pypi.org/p/ferro-ta url: https://pypi.org/p/ferro-ta
permissions: permissions:
id-token: write id-token: write
attestations: write
contents: read
steps: steps:
- name: Download all wheels - name: Download all wheels
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
@@ -270,6 +308,11 @@ jobs:
name: sdist name: sdist
path: dist path: dist
- name: Generate SLSA build provenance attestations
uses: actions/attest-build-provenance@v2
with:
subject-path: "dist/*"
- name: Verify distribution coverage - name: Verify distribution coverage
run: | run: |
python3 - <<'PY' python3 - <<'PY'
@@ -283,19 +326,15 @@ jobs:
for name in files: for name in files:
print(f" - {name}") print(f" - {name}")
# One abi3 wheel (cp310-abi3) per platform/arch — covers CPython 3.10+.
expected = [ expected = [
"ferro_ta-*-cp310-cp310-manylinux*_x86_64.whl", "ferro_ta-*-cp310-abi3-manylinux*_x86_64.whl",
"ferro_ta-*-cp311-cp311-manylinux*_x86_64.whl", "ferro_ta-*-cp310-abi3-manylinux*_aarch64.whl",
"ferro_ta-*-cp312-cp312-manylinux*_x86_64.whl", "ferro_ta-*-cp310-abi3-musllinux*_x86_64.whl",
"ferro_ta-*-cp313-cp313-manylinux*_x86_64.whl", "ferro_ta-*-cp310-abi3-musllinux*_aarch64.whl",
"ferro_ta-*-cp310-cp310-win_amd64.whl", "ferro_ta-*-cp310-abi3-macosx*_universal2.whl",
"ferro_ta-*-cp311-cp311-win_amd64.whl", "ferro_ta-*-cp310-abi3-win_amd64.whl",
"ferro_ta-*-cp312-cp312-win_amd64.whl", "ferro_ta-*-cp310-abi3-win_arm64.whl",
"ferro_ta-*-cp313-cp313-win_amd64.whl",
"ferro_ta-*-cp310-cp310-macosx*_universal2.whl",
"ferro_ta-*-cp311-cp311-macosx*_universal2.whl",
"ferro_ta-*-cp312-cp312-macosx*_universal2.whl",
"ferro_ta-*-cp313-cp313-macosx*_universal2.whl",
"ferro_ta-*.tar.gz", "ferro_ta-*.tar.gz",
] ]
@@ -339,11 +378,14 @@ jobs:
sbom: sbom:
name: Generate SBOM (Python + Rust) name: Generate SBOM (Python + Rust)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: publish needs:
- publish
- publish-cratesio
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release) if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
permissions: permissions:
contents: write contents: write
id-token: write id-token: write
attestations: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -378,12 +420,40 @@ jobs:
- name: Generate Rust SBOM (CycloneDX) - name: Generate Rust SBOM (CycloneDX)
run: cargo cyclonedx --format json --override-filename ferro-ta-rust-sbom.cdx run: cargo cyclonedx --format json --override-filename ferro-ta-rust-sbom.cdx
- name: Upload Python SBOM to release - name: Validate SBOMs (schema check)
uses: softprops/action-gh-release@v2 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: 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 - name: Upload Rust SBOM to release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
files: ferro-ta-rust-sbom.cdx.json files: |
ferro-ta-rust-sbom.cdx.json
ferro-ta-rust-sbom.cdx.json.sig
ferro-ta-rust-sbom.cdx.json.pem
+1 -1
View File
@@ -44,6 +44,6 @@ jobs:
- name: Upload GitHub Pages artifact - name: Upload GitHub Pages artifact
if: ${{ inputs.upload-pages-artifact }} if: ${{ inputs.upload-pages-artifact }}
uses: actions/upload-pages-artifact@v4 uses: actions/upload-pages-artifact@v5
with: with:
path: docs/_build/ path: docs/_build/
+94 -92
View File
@@ -7,151 +7,157 @@ permissions:
contents: read contents: read
jobs: jobs:
# -------------------------------------------------------------------------
# Lint — no Rust, no wheel build, very fast
# -------------------------------------------------------------------------
lint: lint:
name: Lint (ruff) name: Lint (ruff)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- run: pip install uv
- run: uv run --with ruff ruff check python/ tests/
- run: uv run --with ruff ruff format --check python/ tests/
- name: Install uv # -------------------------------------------------------------------------
run: pip install uv # Type checking — needs wheel; build once with cache
# -------------------------------------------------------------------------
- name: Run ruff check via uv
run: uv run --with ruff ruff check python/ tests/
- name: Run ruff format check via uv
run: uv run --with ruff ruff format --check python/ tests/
typecheck: typecheck:
name: Type checking (mypy + pyright) name: Type checking (mypy + pyright)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
- run: pip install maturin numpy
- run: maturin build --release --out dist && pip install dist/*.whl
- run: pip install uv
- run: uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
- run: uv run --with pyright python -m pyright python/ferro_ta
- name: Install uv # -------------------------------------------------------------------------
run: pip install uv # Build wheel artifact (once per run, reused by all test matrix entries)
# -------------------------------------------------------------------------
- name: Run mypy on ferro_ta via uv build-wheel:
run: uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary name: Build wheel (ubuntu / Python ${{ matrix.python-version }})
- name: Run pyright on ferro_ta via uv
run: uv run --with pyright python -m pyright python/ferro_ta
test:
name: Test (ubuntu-latest / Python ${{ matrix.python-version }})
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: "wheel-${{ matrix.python-version }}"
- run: pip install maturin numpy
- run: maturin build --release --out dist
- uses: actions/upload-artifact@v7
with:
name: wheel-${{ matrix.python-version }}
path: dist/*.whl
# -------------------------------------------------------------------------
# Test matrix — downloads pre-built wheel, no Rust compilation
# -------------------------------------------------------------------------
test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
needs: build-wheel
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"] python-version: ["3.10", "3.11", "3.12", "3.13"]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- uses: actions/download-artifact@v8
- name: Install maturin and test dependencies with:
name: wheel-${{ matrix.python-version }}
path: dist
- name: Install wheel + test deps
run: | run: |
pip install maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml mcp
- name: Build and install ferro_ta (dev mode)
run: |
maturin build --release --out dist
pip install dist/*.whl pip install dist/*.whl
pip install pytest pytest-cov pandas polars hypothesis pyyaml mcp scipy
- name: Run unit tests with coverage - name: Run tests with coverage
run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65 run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65
- name: Check API manifest is current - name: Check API manifest is current
if: matrix.python-version == '3.12' if: matrix.python-version == '3.12'
run: python scripts/check_api_manifest.py run: python scripts/check_api_manifest.py
- uses: actions/upload-artifact@v7
- name: Upload coverage report
uses: actions/upload-artifact@v7
if: matrix.python-version == '3.12' if: matrix.python-version == '3.12'
with: with:
name: coverage-python-${{ matrix.python-version }} name: coverage-python-${{ matrix.python-version }}
path: coverage.xml path: coverage.xml
# -------------------------------------------------------------------------
# Benchmark vs TA-Lib (downloads wheel from build-wheel job)
# -------------------------------------------------------------------------
benchmark-vs-talib: benchmark-vs-talib:
name: Benchmark vs TA-Lib name: Benchmark vs TA-Lib
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-wheel
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- uses: actions/download-artifact@v8
- name: Install TA-Lib C library (Ubuntu) with:
name: wheel-3.12
path: dist
- name: Install TA-Lib C library
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y build-essential curl sudo apt-get install -y build-essential curl
curl -sL https://sourceforge.net/projects/ta-lib/files/ta-lib/0.4.0/ta-lib-0.4.0-src.tar.gz/download -o ta-lib-0.4.0-src.tar.gz curl -sL https://sourceforge.net/projects/ta-lib/files/ta-lib/0.4.0/ta-lib-0.4.0-src.tar.gz/download -o ta-lib-0.4.0-src.tar.gz
tar -xzf ta-lib-0.4.0-src.tar.gz tar -xzf ta-lib-0.4.0-src.tar.gz
cd ta-lib cd ta-lib && ./configure --prefix=/usr && make && sudo make install && sudo ldconfig
./configure --prefix=/usr - run: pip install dist/*.whl numpy ta-lib
make - run: python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
sudo make install - run: python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
sudo ldconfig - uses: actions/upload-artifact@v7
- name: Install maturin and ta-lib Python package
run: |
pip install maturin numpy
pip install ta-lib
- name: Build and install ferro_ta
run: |
maturin build --release --out dist
pip install dist/*.whl
- name: Run benchmark comparison
run: |
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
- name: Enforce benchmark regression policy
run: python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
- name: Upload benchmark results
uses: actions/upload-artifact@v7
with: with:
name: benchmark-vs-talib name: benchmark-vs-talib
path: benchmark_vs_talib.json path: benchmark_vs_talib.json
# -------------------------------------------------------------------------
# Performance smoke (downloads wheel from build-wheel job)
# -------------------------------------------------------------------------
perf-smoke: perf-smoke:
name: Performance smoke and contracts name: Performance smoke and contracts
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-wheel
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- uses: actions/download-artifact@v8
- name: Install maturin and perf dependencies with:
run: | name: wheel-3.12
pip install maturin numpy pytest path: dist
- run: pip install dist/*.whl numpy pytest
- name: Build and install ferro_ta - run: |
run: |
maturin build --release --out dist
pip install dist/*.whl
- name: Generate reproducible perf artifacts
run: |
python benchmarks/run_perf_contract.py \ python benchmarks/run_perf_contract.py \
--output-dir perf-contract \ --output-dir perf-contract \
--skip-talib \ --skip-talib \
@@ -161,12 +167,8 @@ jobs:
--streaming-bars 20000 \ --streaming-bars 20000 \
--price-bars 20000 \ --price-bars 20000 \
--iv-bars 50000 --iv-bars 50000
- run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
- name: Enforce hotspot regression policy - uses: actions/upload-artifact@v7
run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
- name: Upload perf artifacts
uses: actions/upload-artifact@v7
with: with:
name: perf-contract name: perf-contract
path: perf-contract/ path: perf-contract/
+62 -45
View File
@@ -7,102 +7,119 @@ permissions:
contents: read contents: read
jobs: jobs:
audit: # -------------------------------------------------------------------------
name: Dependency audit (cargo + pip) # cargo-deny — uses the official pre-built action (no cargo install needed)
# -------------------------------------------------------------------------
cargo-deny:
name: cargo deny check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: EmbarkStudios/cargo-deny-action@v2
- name: Install cargo-audit and run cargo audit # -------------------------------------------------------------------------
run: | # pip-audit + uv.lock freshness check (lightweight, no Rust needed)
cargo install cargo-audit # -------------------------------------------------------------------------
cargo audit pip-audit:
name: pip-audit + uv.lock check
- name: Install cargo-deny and run cargo deny check runs-on: ubuntu-latest
run: | steps:
cargo install cargo-deny --locked - uses: actions/checkout@v6
cargo deny check - uses: actions/setup-python@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- run: pip install uv
- run: uv run --with pip-audit pip-audit --skip-editable
- run: uv lock --check
- name: Install uv # -------------------------------------------------------------------------
run: pip install uv # fmt + clippy (with Rust cache so subsequent runs skip recompilation)
# -------------------------------------------------------------------------
- name: Install pip-audit via uv and run pip-audit rust-lint:
run: uv run --with pip-audit pip-audit --skip-editable name: Rust fmt + clippy
- name: Verify uv.lock is up-to-date
run: uv lock --check
rust:
name: Rust (fmt + clippy)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1 - uses: dtolnay/rust-toolchain@v1
with: with:
toolchain: stable toolchain: stable
components: rustfmt, clippy components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check - run: cargo fmt --all -- --check
- run: cargo clippy --release -- -D warnings - run: cargo clippy --release -- -D warnings
- name: Verify benchmarks compile (ferro_ta_core) - name: Verify benchmarks compile
run: cargo bench -p ferro_ta_core --no-run run: cargo bench -p ferro_ta_core --no-run
# -------------------------------------------------------------------------
# Build + test ferro_ta_core (with Rust cache)
# -------------------------------------------------------------------------
rust-core: rust-core:
name: Rust core library (ferro_ta_core) name: Rust core (build + test)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1 - uses: dtolnay/rust-toolchain@v1
with: with:
toolchain: stable toolchain: stable
- name: Build core crate - uses: Swatinem/rust-cache@v2
run: cargo build -p ferro_ta_core - run: cargo build -p ferro_ta_core
- name: Test core crate - run: cargo test -p ferro_ta_core
run: cargo test -p ferro_ta_core
# -------------------------------------------------------------------------
# Coverage (optional, cached)
# -------------------------------------------------------------------------
rust-coverage: rust-coverage:
name: Rust coverage (tarpaulin, optional) name: Rust coverage (tarpaulin)
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1 - uses: dtolnay/rust-toolchain@v1
with: with:
toolchain: stable toolchain: stable
- name: Install cargo-tarpaulin - uses: Swatinem/rust-cache@v2
run: cargo install cargo-tarpaulin --locked - uses: taiki-e/install-action@v2
- name: Collect Rust coverage (ferro_ta_core) with:
run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/ tool: cargo-tarpaulin
- name: Upload Rust coverage artifact - run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/
uses: actions/upload-artifact@v7 - uses: actions/upload-artifact@v7
with: with:
name: rust-coverage name: rust-coverage
path: coverage/ path: coverage/
if-no-files-found: ignore if-no-files-found: ignore
# -------------------------------------------------------------------------
# Fuzz (optional, nightly, cached)
# -------------------------------------------------------------------------
fuzz: fuzz:
name: Fuzz targets (short CI run, optional) name: Fuzz targets (short CI run)
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1 - uses: dtolnay/rust-toolchain@v1
with: with:
toolchain: nightly toolchain: nightly
- name: Install cargo-fuzz targets: x86_64-unknown-linux-gnu
run: cargo install cargo-fuzz --locked - uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
- name: Run fuzz_sma (10000 iterations) - name: Run fuzz_sma (10000 iterations)
working-directory: fuzz working-directory: fuzz
run: cargo fuzz run fuzz_sma -- -runs=10000 -max_len=512 run: cargo fuzz run fuzz_sma --target x86_64-unknown-linux-gnu -- -runs=10000 -max_len=512
- name: Run fuzz_rsi (10000 iterations) - name: Run fuzz_rsi (10000 iterations)
working-directory: fuzz working-directory: fuzz
run: cargo fuzz run fuzz_rsi -- -runs=10000 -max_len=512 run: cargo fuzz run fuzz_rsi --target x86_64-unknown-linux-gnu -- -runs=10000 -max_len=512
- name: Upload fuzz artifacts on crash - uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7
if: always() if: always()
with: with:
name: fuzz-artifacts name: fuzz-artifacts
+21 -26
View File
@@ -8,48 +8,43 @@ permissions:
jobs: jobs:
wasm: wasm:
name: WASM binding (wasm-pack test --node) name: WASM (test + build + bench)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- uses: actions/setup-node@v6
- name: Set up Node.js
uses: actions/setup-node@v4
with: with:
node-version: "20" node-version: "20"
- uses: dtolnay/rust-toolchain@v1
- name: Install Rust (stable)
uses: dtolnay/rust-toolchain@v1
with: with:
toolchain: stable toolchain: stable
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Install wasm-pack - uses: taiki-e/install-action@v2
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh with:
tool: wasm-pack
- name: Build and test WASM binding - name: Test WASM binding
working-directory: wasm working-directory: wasm
run: wasm-pack test --node run: wasm-pack test --node
- name: Build WASM packages (node + web)
- name: Build WASM package (nodejs target)
working-directory: wasm working-directory: wasm
run: wasm-pack build --target nodejs --out-dir pkg run: npm run build
- name: Check API manifest is current - name: Check API manifest is current
run: python3 scripts/check_api_manifest.py run: python3 scripts/check_api_manifest.py
- name: Benchmark WASM
- name: Benchmark WASM package
working-directory: wasm working-directory: wasm
run: node bench.js --json ../wasm_benchmark.json run: node bench.js --json ../wasm_benchmark.json
- uses: actions/upload-artifact@v7
- name: Upload WASM package artifact
uses: actions/upload-artifact@v7
with: with:
name: wasm-pkg name: wasm-pkg-node
path: wasm/pkg/ path: wasm/node/
- uses: actions/upload-artifact@v7
- name: Upload WASM benchmark artifact with:
uses: actions/upload-artifact@v7 name: wasm-pkg-web
path: wasm/web/
- uses: actions/upload-artifact@v7
with: with:
name: wasm-benchmark name: wasm-benchmark
path: wasm_benchmark.json 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@v9
env:
HOTSPOT_OUTCOME: ${{ steps.hotspot.outcome }}
VS_TALIB_OUTCOME: ${{ steps.vs_talib.outcome }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const hotspot = process.env.HOTSPOT_OUTCOME;
const vsTalib = process.env.VS_TALIB_OUTCOME;
const runUrl = process.env.RUN_URL;
const failed = [];
if (hotspot === 'failure') failed.push('hotspot');
if (vsTalib === 'failure') failed.push('vs-TA-Lib');
const title = `Nightly benchmark regression: ${failed.join(' + ')}`;
const body = [
`The nightly benchmark workflow reported a >5% regression in: **${failed.join(', ')}**.`,
'',
`Run: ${runUrl}`,
'',
'Artifacts with the raw JSON are attached to the run above.',
'If this is a known regression, update the baseline JSON and close.',
'If it is unexpected, investigate the last commit on `main` before the nightly ran.',
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['performance', 'regression', 'nightly'],
});
+3 -1
View File
@@ -20,6 +20,7 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.GH_PAT }}
- name: Extract version from tag - name: Extract version from tag
id: version id: version
@@ -51,10 +52,11 @@ jobs:
PY PY
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: v${{ steps.version.outputs.version }} name: v${{ steps.version.outputs.version }}
body: ${{ steps.changelog.outputs.body }} body: ${{ steps.changelog.outputs.body }}
draft: false draft: false
prerelease: ${{ contains(github.ref_name, '-') }} prerelease: ${{ contains(github.ref_name, '-') }}
token: ${{ secrets.GH_PAT }}
+14 -2
View File
@@ -4,6 +4,11 @@ on:
release: release:
types: [published] types: [published]
workflow_dispatch: workflow_dispatch:
inputs:
release:
description: "Publish WASM package to npm"
type: boolean
default: true
permissions: permissions:
contents: read contents: read
@@ -13,13 +18,16 @@ jobs:
publish: publish:
name: Build and publish to npm name: Build and publish to npm
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: >-
(github.event_name == 'release' && github.event.action == 'published')
|| (github.event_name == 'workflow_dispatch' && inputs.release)
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "20"
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- name: Install Rust and wasm-pack - name: Install Rust and wasm-pack
@@ -28,6 +36,10 @@ jobs:
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
- run: cargo install wasm-pack - run: cargo install wasm-pack
- name: Run WASM tests
working-directory: wasm
run: wasm-pack test --node
- name: Build WASM package - name: Build WASM package
run: | run: |
cd wasm cd wasm
@@ -35,4 +47,4 @@ jobs:
- name: Publish to npm - name: Publish to npm
working-directory: wasm working-directory: wasm
run: npm publish --access public run: npm publish --access public --provenance
+2
View File
@@ -37,6 +37,8 @@ env/
# WASM build output # WASM build output
wasm/pkg/ wasm/pkg/
wasm/pkg-web/ wasm/pkg-web/
wasm/node/
wasm/web/
benchmark_vs_talib.json benchmark_vs_talib.json
wasm_benchmark.json wasm_benchmark.json
.wasm_benchmark.prepush.json .wasm_benchmark.prepush.json
+133
View File
@@ -9,6 +9,139 @@ and the project uses [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added
- **Runtime CPU-feature dispatch** for SIMD hot paths via the `multiversion`
crate (`ferro_ta_core/src/simd.rs`). One binary now selects baseline /
AVX2-FMA / AVX-512 / NEON kernels at load time via CPUID instead of being
pinned at build time, so it runs on any CPU of the target architecture with
no illegal-instruction crashes on older chips. The `simd` feature is on by
default; `--no-default-features` gives a pure-scalar build.
- **Broader wheel coverage**: release builds now also produce Linux `aarch64`
(manylinux) and `musllinux` (x86_64 + aarch64) wheels and a Windows `arm64`
wheel, alongside the existing Linux x86_64, macOS universal2, and Windows
x64 wheels.
- **abi3 wheels** (`cp310-abi3`): a single stable-ABI wheel per platform now
covers CPython 3.10+ (including future 3.14+), replacing the per-version
wheel matrix.
- **Dynamic Time Warping** (`DTW`, `DTW_DISTANCE`, `BATCH_DTW`): Euclidean-cost
DTW with optional Sakoe-Chiba band (`window=` parameter). `DTW()` returns
`(distance, path)` with the optimal warping path as an `(N, 2)` index array;
`DTW_DISTANCE()` is the faster distance-only variant; `BATCH_DTW()` computes
distances from each row of a 2-D matrix to a reference series in parallel
via rayon. Distance convention matches `dtaidistance.dtw.distance()`.
- **Indicator-specific exception types** (`FerroTaError`, `InvalidPeriodError`,
`InsufficientDataError`, `LengthMismatchError`, `NumericConvergenceError`,
`InvalidInputError`): finer-grained errors for catching specific failure
modes. All subclass `ValueError` so existing `except ValueError` code keeps
working (backward compatible).
### Changed
- **SIMD is now enabled by default and runtime-dispatched.** Replaced the
compile-time `wide` crate (which was never actually enabled in published
wheels) with `multiversion`. The `wide` Cargo feature is removed; use the
default `simd` feature instead.
- **Dependency bumps.** Rust: `log` 0.4.32, `serde_json` 1.0.150,
`rayon` 1.12.0. API service (`api/requirements.txt`): `uvicorn>=0.49.0`,
`pydantic>=2.13.4`, `ferro-ta>=1.1.4`. CI actions: `actions/deploy-pages` v5,
`actions/upload-pages-artifact` v5, `softprops/action-gh-release` v3.
- Python coverage threshold raised from 65% to 80% and enforced in CI.
### Fixed
- **aarch64 Linux containers can now install ferro-ta.** Previously no Linux
`aarch64` wheel was published, so arm64 images (e.g. AWS Graviton) fell back
to an sdist build that failed without a Rust toolchain. A manylinux/musllinux
aarch64 wheel is now published.
- Published wheels now actually ship SIMD-accelerated kernels; the prior build
enabled no SIMD feature at all.
### Security
- **SLSA build provenance** attestations are now generated for every PyPI
wheel and sdist via `actions/attest-build-provenance`. Verify with
`gh attestation verify <wheel>`.
- **Sigstore keyless signatures** are now published alongside both
CycloneDX/SPDX SBOMs on every GitHub Release (`.sig` + `.pem` files).
- `ferro_ta_core` crate now declares `#![forbid(unsafe_code)]` to prevent
regression — the pure-logic layer has no unsafe code and never will.
- Dependabot now covers the WASM npm package in addition to pip, cargo,
and GitHub Actions.
- **pip-audit fixes**: bumped dev lockfile deps `idna` 3.18, `pytest` 9.1.1,
and `urllib3` 2.7.0 to clear PYSEC-2026-215, CVE-2025-71176, and
PYSEC-2026-141/142.
- **pyo3 advisories triaged**: RUSTSEC-2026-0176 and RUSTSEC-2026-0177 are
ignored in `deny.toml` with rationale — ferro-ta uses neither affected code
path (PyList/PyTuple `nth` iterators; `PyCFunction::new_closure`). The
upstream fix requires pyo3 >=0.29 (a large API migration), tracked for a
follow-up.
## [1.1.3] — 2026-04-02
### 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
- WASM npm package now ships both Node.js (CommonJS) and browser/web worker
(ESM) builds via conditional exports in package.json.
- Fixed wasm-publish.yml: added job condition, workflow_dispatch inputs, and
pre-publish test gate.
- Fixed CI.yml: SBOM job now waits for both PyPI and crates.io publish.
- Aligned Node.js version to 20 across all CI workflows.
- Rewrote wasm/README.md and ferro_ta_core/README.md to reflect full feature
parity (200+ WASM exports, 22 core modules).
## [1.1.1] — 2026-04-01 ## [1.1.1] — 2026-04-01
### Added ### Added
Generated
+61 -58
View File
@@ -34,9 +34,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.0" version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [ dependencies = [
"rustversion", "rustversion",
] ]
@@ -53,12 +53,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]] [[package]]
name = "cast" name = "cast"
version = "0.3.0" version = "0.3.0"
@@ -67,9 +61,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.57" version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@@ -207,7 +201,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]] [[package]]
name = "ferro_ta" name = "ferro_ta"
version = "1.1.1" version = "1.1.4"
dependencies = [ dependencies = [
"criterion", "criterion",
"ferro_ta_core", "ferro_ta_core",
@@ -222,12 +216,12 @@ dependencies = [
[[package]] [[package]]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.1.1" version = "1.1.4"
dependencies = [ dependencies = [
"criterion", "criterion",
"multiversion",
"serde", "serde",
"serde_json", "serde_json",
"wide",
] ]
[[package]] [[package]]
@@ -279,9 +273,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.91" version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"wasm-bindgen", "wasm-bindgen",
@@ -289,15 +283,15 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.183" version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]] [[package]]
name = "matrixmultiply" name = "matrixmultiply"
@@ -324,6 +318,28 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "multiversion"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
dependencies = [
"multiversion-macros",
"target-features",
]
[[package]]
name = "multiversion-macros"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"target-features",
]
[[package]] [[package]]
name = "ndarray" name = "ndarray"
version = "0.16.1" version = "0.16.1"
@@ -546,9 +562,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.11.0" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [ dependencies = [
"either", "either",
"rayon-core", "rayon-core",
@@ -595,9 +611,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.1" version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]] [[package]]
name = "rustversion" name = "rustversion"
@@ -605,15 +621,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "safe_arch"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -655,9 +662,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.149" version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -689,6 +696,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226" checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
[[package]]
name = "target-features"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.13.5" version = "0.13.5"
@@ -729,9 +742,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.114" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@@ -742,9 +755,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.114" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@@ -752,9 +765,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.114" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@@ -765,33 +778,23 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.114" version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.91" version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "wide"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "198f6abc41fab83526d10880fa5c17e2b4ee44e763949b4bb34e2fd1e8ca48e4"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -840,18 +843,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.47" version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.47" version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
+12 -4
View File
@@ -5,7 +5,7 @@ resolver = "2"
[package] [package]
name = "ferro_ta" name = "ferro_ta"
version = "1.1.1" version = "1.1.4"
edition = "2021" edition = "2021"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
license = "MIT" license = "MIT"
@@ -22,7 +22,9 @@ name = "ferro_ta"
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [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" ta = "0.5.0"
numpy = "0.25" numpy = "0.25"
# Must be < 0.17 while numpy 0.25 is used (numpy's IntoPyArray is for its own ndarray only). # Must be < 0.17 while numpy 0.25 is used (numpy's IntoPyArray is for its own ndarray only).
@@ -30,7 +32,11 @@ ndarray = "0.16"
rayon = "1.10" rayon = "1.10"
log = "0.4" log = "0.4"
pyo3-log = "0.12" pyo3-log = "0.12"
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.1", 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.1.4", default-features = false, features = ["serde"] }
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] } criterion = { version = "0.8", features = ["html_reports"] }
@@ -40,5 +46,7 @@ lto = true
codegen-units = 1 codegen-units = 1
[features] [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"] simd = ["ferro_ta_core/simd"]
+1 -1
View File
@@ -71,6 +71,6 @@ pip install ferro-ta --no-binary ferro-ta
## Known limitations ## Known limitations
- WASM binding: only 6 indicators exposed (see `wasm/README.md`). - WASM binding: full feature parity with 200+ exports including all TA-Lib indicators, candlestick patterns, streaming API, options, futures, and backtesting (see `wasm/README.md`).
- Python 3.14+: untested; may work with `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1`. - Python 3.14+: untested; may work with `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1`.
- 32-bit platforms: not officially supported; source builds may succeed. - 32-bit platforms: not officially supported; source builds may succeed.
+22 -7
View File
@@ -8,21 +8,36 @@
# #
# Environment variables (override at runtime): # Environment variables (override at runtime):
# MAX_SERIES_LENGTH=100000 # maximum data-point count per request # MAX_SERIES_LENGTH=100000 # maximum data-point count per request
#
# CPU portability
# ---------------
# This image installs the PRE-BUILT ferro-ta wheel from PyPI — we do NOT
# recompile from sdist with `RUSTFLAGS=-C target-cpu=...`. The wheel is built
# at the manylinux baseline (x86-64-v1) and selects AVX2/AVX-512/NEON kernels
# at RUNTIME via CPU dispatch. One image therefore runs on any node — old or
# new CPU, x86_64 or arm64 — with no illegal-instruction (SIGILL) crashes.
# Pinning a target-cpu would be faster on a uniform fleet but would crash on
# any older/heterogeneous node, which is the opposite of broad coverage.
#
# Build this image for whichever arch your nodes use:
# docker build --platform linux/amd64 -t ferro-ta-api .
# docker build --platform linux/arm64 -t ferro-ta-api . # Graviton/Ampere
# Both resolve a matching manylinux wheel — no Rust toolchain needed here.
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Install system dependencies required to build ferro_ta (Rust is pre-compiled # Copy and install dependencies first (cache layer). No compiler is needed:
# into the wheel, so only pip + wheel tooling is needed at runtime). # ferro-ta, numpy, and pydantic-core all ship prebuilt wheels for linux
RUN apt-get update && apt-get install -y --no-install-recommends \ # x86_64 and aarch64.
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy and install dependencies first (cache layer)
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Fail the build immediately if the wheel did not resolve for this arch
# (e.g. an exotic platform that fell back to an sdist build without Rust).
RUN python -c "import ferro_ta, numpy as np; ferro_ta.SMA(np.arange(10.0), 3); print('ferro_ta', ferro_ta.__version__, 'import OK')"
# Copy API source # Copy API source
COPY main.py ./ COPY main.py ./
+3 -3
View File
@@ -1,6 +1,6 @@
# Runtime dependencies for ferro-ta API # Runtime dependencies for ferro-ta API
ferro_ta>=1.0.0 ferro_ta>=1.1.4
fastapi>=0.110.0 fastapi>=0.110.0
uvicorn[standard]>=0.27.0 uvicorn[standard]>=0.49.0
pydantic>=2.0.0 pydantic>=2.13.4
numpy>=1.20 numpy>=1.20
+4 -1
View File
@@ -55,8 +55,11 @@ def run_simd_benchmark(
iv_bars: int = 50_000, iv_bars: int = 50_000,
window: int = 252, window: int = 252,
) -> dict[str, Any]: ) -> dict[str, Any]:
# `simd` is a default feature, so a pure-scalar baseline must explicitly
# opt out via --no-default-features; otherwise both builds would be
# identical and every reported speedup would collapse to 1.0.
variants = [ variants = [
("portable_release", []), ("portable_release", ["--no-default-features"]),
("simd_release", ["--features", "simd"]), ("simd_release", ["--features", "simd"]),
] ]
reports = { reports = {
+1 -1
View File
@@ -1,5 +1,5 @@
{% set name = "ferro-ta" %} {% set name = "ferro-ta" %}
{% set version = "1.1.1" %} {% set version = "1.1.4" %}
package: package:
name: {{ name|lower }} name: {{ name|lower }}
+10 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.1.1" version = "1.1.4"
edition = "2021" edition = "2021"
description = "Pure Rust core indicator library — no PyO3, no numpy dependency" description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
license = "MIT" license = "MIT"
@@ -16,7 +16,7 @@ name = "ferro_ta_core"
crate-type = ["lib"] crate-type = ["lib"]
[dependencies] [dependencies]
wide = { version = "1.1.1", optional = true } multiversion = { version = "0.8", optional = true }
serde = { version = "1.0", features = ["derive"], optional = true } serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true } serde_json = { version = "1.0", optional = true }
@@ -28,6 +28,12 @@ name = "indicators"
harness = false harness = false
[features] [features]
wide = ["dep:wide"] # Runtime CPU-feature dispatch (multiversion). Default ON so `cargo add
simd = ["wide"] # 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"] serde = ["dep:serde", "dep:serde_json"]
+28 -20
View File
@@ -7,13 +7,13 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
- Rust-native technical analysis workloads - Rust-native technical analysis workloads
- custom services and backtesting engines - custom services and backtesting engines
- future non-Python bindings such as WASM and other FFI layers - non-Python bindings (WASM, FFI)
## Installation ## Installation
```toml ```toml
[dependencies] [dependencies]
ferro_ta_core = "1.1.1" ferro_ta_core = "1.1.4"
``` ```
## Design ## Design
@@ -25,12 +25,31 @@ ferro_ta_core = "1.1.1"
## Modules ## Modules
- `overlap` - moving averages, MACD, Bollinger Bands | Module | Functions | Highlights |
- `momentum` - RSI, MOM |--------|-----------|------------|
- `volatility` - ATR, TRANGE | `overlap` | 20 | SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, BBANDS, MACD, MACDFIX, MACDEXT, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE, MA, MAVP, Hull MA |
- `volume` - OBV | `momentum` | 26 | RSI, MOM, STOCH, STOCHF, ADX, ADXR, DX, +DI, -DI, +DM, -DM, ROC, WILLR, AROON, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC |
- `statistic` - STDDEV | `volatility` | 3 | ATR, NATR, TRANGE |
- `math` - rolling SUM/MAX/MIN helpers | `volume` | 4 | OBV, MFI, AD, ADOSC |
| `pattern` | 61 | All TA-Lib candlestick patterns (CDL2CROWS through CDLXSIDEGAP3METHODS) |
| `statistic` | 9 | STDDEV, VAR, LINEARREG, LINEARREG_SLOPE/INTERCEPT/ANGLE, TSF, BETA, CORREL |
| `math` | 24 | Rolling SUM/MAX/MIN/MAXINDEX/MININDEX, element-wise ADD/SUB/MULT/DIV, 15 transforms (trig, exp, log, sqrt, ceil, floor) |
| `price_transform` | 4 | AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE |
| `cycle` | 7 | Hilbert Transform: TRENDLINE, DCPERIOD, DCPHASE, PHASOR, SINE, TRENDMODE |
| `extended` | 10 | VWAP, VWMA, Supertrend, Donchian, Keltner, Ichimoku, Pivot Points, Hull MA, Chandelier Exit, Choppiness Index |
| `streaming` | 9 | Stateful bar-by-bar: SMA, EMA, RSI, ATR, BBands, MACD, Stoch, VWAP, Supertrend |
| `batch` | 8 | Vectorized multi-column: batch_sma/ema/rsi/atr/stoch/adx, run_close/hlc_indicators |
| `backtest` | 19 | Signal generators, close-only and OHLCV engines, walk-forward, Monte Carlo, performance metrics |
| `options` | 18 | Black-Scholes/Black-76 pricing, Greeks, implied volatility, IV rank/percentile/zscore, smile metrics, chain analytics |
| `futures` | 14 | Basis, annualized basis, carry, roll (weighted/back-adjusted/ratio), curve analysis, synthetic forward/spot |
| `portfolio` | 10 | Beta, correlation matrix, drawdown, relative strength, spread, ratio, z-score, portfolio volatility |
| `signals` | 4 | Rank values, compose rank, top/bottom N indices |
| `alerts` | 3 | Threshold crossings, cross detection, alert bar collection |
| `regime` | 4 | ADX regime, combined regime, CUSUM breaks, variance breaks |
| `aggregation` | 3 | Tick bars, volume bars, time bars from trade data |
| `resampling` | 2 | Volume bars, OHLCV aggregation by label |
| `chunked` | 4 | Trim overlap, stitch chunks, make chunk ranges, forward fill NaN |
| `crypto` | 3 | Funding cumulative PnL, continuous bar labels, session boundaries |
## Example ## Example
@@ -49,18 +68,7 @@ fn main() {
## Relationship To `ferro-ta` ## Relationship To `ferro-ta`
The published Python package: The published Python package (`ferro-ta` on PyPI) wraps this crate with PyO3 bindings and adds NumPy conversion, pandas/polars wrappers, and higher-level Python tooling. The WASM package (`ferro-ta-wasm` on npm) also wraps this crate with full feature parity.
- crate: `ferro_ta`
- PyPI package: `ferro-ta`
wraps this crate with PyO3 bindings and adds:
- NumPy conversion
- pandas/polars wrappers
- streaming classes
- batch helpers
- higher-level Python tooling
If you only need Rust indicator functions, use `ferro_ta_core` directly. If you only need Rust indicator functions, use `ferro_ta_core` directly.
+4
View File
@@ -1,3 +1,5 @@
#![forbid(unsafe_code)]
/*! /*!
ferro_ta_core — Pure Rust indicator library. ferro_ta_core — Pure Rust indicator library.
@@ -49,6 +51,8 @@ pub mod price_transform;
pub mod regime; pub mod regime;
pub mod resampling; pub mod resampling;
pub mod signals; pub mod signals;
/// Runtime-dispatched SIMD reduction primitives (internal).
pub(crate) mod simd;
pub mod statistic; pub mod statistic;
pub mod streaming; pub mod streaming;
pub mod volatility; pub mod volatility;
@@ -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::normal::{cdf, pdf};
use super::pricing::{black_76_price, black_scholes_price}; 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( fn bs_inputs_valid(
underlying: f64, 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)] #[cfg(test)]
mod tests { 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; use crate::options::OptionKind;
#[test] #[test]
@@ -227,4 +312,16 @@ mod tests {
assert!(g.theta.is_finite()); assert!(g.theta.is_finite());
assert!(g.rho.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 //! 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. //! and is used by the PyO3 bridge to build vectorized batch functions.
pub mod american;
pub mod chain; pub mod chain;
pub mod digital;
pub mod greeks; pub mod greeks;
pub mod iv; pub mod iv;
pub mod normal; pub mod normal;
pub mod payoff;
pub mod pricing; pub mod pricing;
pub mod realized_vol;
pub mod surface; pub mod surface;
/// Option side. /// Option side.
@@ -49,6 +53,16 @@ pub struct Greeks {
pub rho: f64, 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. /// Shared contract fields for model-based option analytics.
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct OptionContract { 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. /// Lower no-arbitrage bound for the option price.
pub fn price_lower_bound(contract: OptionContract) -> f64 { pub fn price_lower_bound(contract: OptionContract) -> f64 {
match contract.model { 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) 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)] #[cfg(test)]
mod tests { mod tests {
use super::{atm_iv, smile_metrics, term_structure_slope}; 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; return;
} }
#[cfg(feature = "simd")] // Seed the rolling window with a runtime-dispatched reduction. The O(n)
let window_sum_init = { // streaming recurrence below is inherently sequential, so SIMD only ever
use wide::f64x4; // applies to this initial window sum.
let p_data = &src[..timeperiod]; let mut window_sum = crate::simd::sum(&src[..timeperiod]);
let mut sum = f64x4::splat(0.0);
let mut chunks = p_data.chunks_exact(4);
for chunk in &mut chunks {
sum += f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
}
let arr = sum.to_array();
let mut total = arr[0] + arr[1] + arr[2] + arr[3];
for &v in chunks.remainder() {
total += v;
}
total
};
#[cfg(not(feature = "simd"))]
let window_sum_init: f64 = src[..timeperiod].iter().sum();
let mut window_sum = window_sum_init;
let tp_f64 = timeperiod as f64; let tp_f64 = timeperiod as f64;
dest[dest_offset + timeperiod - 1] = window_sum / tp_f64; dest[dest_offset + timeperiod - 1] = window_sum / tp_f64;
@@ -124,46 +107,9 @@ pub fn wma(close: &[f64], timeperiod: usize) -> Vec<f64> {
let denom: f64 = (timeperiod * (timeperiod + 1) / 2) as f64; let denom: f64 = (timeperiod * (timeperiod + 1) / 2) as f64;
let p = timeperiod as f64; let p = timeperiod as f64;
// Seed: compute T and S for the first window. // Seed: compute T and S for the first window via a runtime-dispatched
#[cfg(feature = "simd")] // reduction (the streaming recurrence below is sequential).
let (mut t, mut s) = { let (mut t, mut s) = crate::simd::wma_seed(&close[..timeperiod]);
use wide::f64x4;
let p_data = &close[..timeperiod];
let mut t_simd = f64x4::splat(0.0);
let mut s_simd = f64x4::splat(0.0);
let mut chunks = p_data.chunks_exact(4);
let mut idx = 1.0;
let step = f64x4::new([0.0, 1.0, 2.0, 3.0]);
for chunk in &mut chunks {
let vals = f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
let mults = f64x4::splat(idx) + step;
t_simd += vals * mults;
s_simd += vals;
idx += 4.0;
}
let t_arr = t_simd.to_array();
let s_arr = s_simd.to_array();
let mut t = t_arr[0] + t_arr[1] + t_arr[2] + t_arr[3];
let mut s = s_arr[0] + s_arr[1] + s_arr[2] + s_arr[3];
for &v in chunks.remainder() {
t += v * idx;
s += v;
idx += 1.0;
}
(t, s)
};
#[cfg(not(feature = "simd"))]
let (mut t, mut s) = {
let t_val: f64 = close[..timeperiod]
.iter()
.enumerate()
.map(|(k, &v)| v * (k + 1) as f64)
.sum();
let s_val: f64 = close[..timeperiod].iter().sum();
(t_val, s_val)
};
result[timeperiod - 1] = t / denom; result[timeperiod - 1] = t / denom;
+161
View File
@@ -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 result
} }
// ---------------------------------------------------------------------------
// Dynamic Time Warping (DTW)
// ---------------------------------------------------------------------------
/// Internal helper: build the full DTW accumulated-cost matrix.
///
/// Local cost: `|s1[i] - s2[j]|` (Euclidean / L1 for 1-D series).
/// This matches the convention used by `dtaidistance.dtw.distance()`.
///
/// Out-of-band cells (Sakoe-Chiba constraint) are set to `f64::INFINITY`.
fn dtw_matrix(s1: &[f64], s2: &[f64], window: Option<usize>) -> Vec<Vec<f64>> {
let n = s1.len();
let m = s2.len();
let mut dp = vec![vec![f64::INFINITY; m]; n];
for i in 0..n {
// Window convention matches dtaidistance: window=w means |i-j| < w.
// None = unconstrained (full matrix).
let (j_lo, j_hi) = match window {
None => (0, m),
Some(w) => {
let lo = i.saturating_sub(w.saturating_sub(1));
let hi = i.saturating_add(w).min(m);
(lo, hi)
}
};
for j in j_lo..j_hi {
// Squared Euclidean local cost — matches dtaidistance convention.
// The final sqrt is applied only once at the top level (not per-step).
let cost = (s1[i] - s2[j]).powi(2);
let prev = if i == 0 && j == 0 {
0.0
} else if i == 0 {
dp[0][j - 1]
} else if j == 0 {
dp[i - 1][0]
} else {
dp[i - 1][j - 1].min(dp[i - 1][j]).min(dp[i][j - 1])
};
dp[i][j] = cost + prev;
}
}
dp
}
/// Compute the Dynamic Time Warping distance between two 1-D series.
///
/// Returns the accumulated Euclidean cost along the optimal warping path.
/// Uses `|s1[i] - s2[j]|` as the local cost, matching `dtaidistance` convention.
///
/// # Arguments
/// * `s1` - First time series.
/// * `s2` - Second time series.
/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained.
///
/// Returns `f64::NAN` if either input is empty.
pub fn dtw_distance(s1: &[f64], s2: &[f64], window: Option<usize>) -> f64 {
if s1.is_empty() || s2.is_empty() {
return f64::NAN;
}
let dp = dtw_matrix(s1, s2, window);
// sqrt applied once at the end — matches dtaidistance.dtw.distance() convention.
dp[s1.len() - 1][s2.len() - 1].sqrt()
}
/// Compute the DTW distance and the optimal warping path between two 1-D series.
///
/// The warping path is a `Vec<(usize, usize)>` of `(i, j)` index pairs,
/// starting at `(0, 0)` and ending at `(n-1, m-1)`, monotonically non-decreasing.
///
/// # Arguments
/// * `s1` - First time series.
/// * `s2` - Second time series.
/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained.
///
/// Returns `(f64::NAN, vec![])` if either input is empty.
pub fn dtw_path(s1: &[f64], s2: &[f64], window: Option<usize>) -> (f64, Vec<(usize, usize)>) {
if s1.is_empty() || s2.is_empty() {
return (f64::NAN, vec![]);
}
let dp = dtw_matrix(s1, s2, window);
let dist = dp[s1.len() - 1][s2.len() - 1].sqrt();
// Backtrace from (n-1, m-1) to (0, 0)
let mut path = Vec::new();
let (mut i, mut j) = (s1.len() - 1, s2.len() - 1);
path.push((i, j));
while i > 0 || j > 0 {
let (ni, nj) = match (i, j) {
(0, _) => (0, j - 1),
(_, 0) => (i - 1, 0),
_ => {
let diag = dp[i - 1][j - 1];
let up = dp[i - 1][j];
let left = dp[i][j - 1];
let best = diag.min(up).min(left);
if best == diag {
(i - 1, j - 1)
} else if best == up {
(i - 1, j)
} else {
(i, j - 1)
}
}
};
i = ni;
j = nj;
path.push((i, j));
}
path.reverse();
(dist, path)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -259,4 +371,122 @@ mod tests {
assert!(v.abs() < 1e-10); assert!(v.abs() < 1e-10);
} }
} }
#[test]
fn dtw_identical_series_is_zero() {
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(dtw_distance(&a, &a, None), 0.0);
}
#[test]
fn dtw_known_shifted_series() {
// [0,1,2] vs [1,2,3]: DTW uses squared Euclidean local cost + final sqrt.
// Optimal path (0,0)→(1,0)→(2,1)→(2,2), accumulated cost = 1+0+0+1 = 2, sqrt(2).
// Matches dtaidistance.dtw.distance([0,1,2],[1,2,3]) = 1.4142...
let a = vec![0.0, 1.0, 2.0];
let b = vec![1.0, 2.0, 3.0];
let expected = 2.0_f64.sqrt();
let result = dtw_distance(&a, &b, None);
assert!(
(result - expected).abs() < 1e-12,
"got {result}, expected {expected}"
);
}
#[test]
fn dtw_known_even_shift() {
// [0,2,4] vs [1,3,5]: diagonal path, squared costs 1+1+1=3, sqrt(3).
// Matches dtaidistance.dtw.distance([0,2,4],[1,3,5]) = 1.7320...
let a = vec![0.0, 2.0, 4.0];
let b = vec![1.0, 3.0, 5.0];
let expected = 3.0_f64.sqrt();
let result = dtw_distance(&a, &b, None);
assert!(
(result - expected).abs() < 1e-12,
"got {result}, expected {expected}"
);
}
#[test]
fn dtw_single_element() {
let a = vec![3.0];
let b = vec![7.0];
assert_eq!(dtw_distance(&a, &b, None), 4.0);
}
#[test]
fn dtw_empty_returns_nan() {
assert!(dtw_distance(&[], &[1.0, 2.0], None).is_nan());
assert!(dtw_distance(&[1.0, 2.0], &[], None).is_nan());
}
#[test]
fn dtw_path_endpoints() {
let a = vec![1.0, 2.0, 3.0, 4.0];
let b = vec![1.5, 2.5, 3.5, 4.5];
let (_, path) = dtw_path(&a, &b, None);
assert_eq!(path.first(), Some(&(0, 0)));
assert_eq!(path.last(), Some(&(3, 3)));
}
#[test]
fn dtw_path_is_monotone() {
let a = vec![1.0, 3.0, 2.0, 5.0, 4.0];
let b = vec![2.0, 1.0, 4.0, 3.0, 6.0];
let (_, path) = dtw_path(&a, &b, None);
for k in 1..path.len() {
assert!(path[k].0 >= path[k - 1].0);
assert!(path[k].1 >= path[k - 1].1);
}
}
#[test]
fn dtw_path_distance_matches_distance_only() {
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0];
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0];
let d1 = dtw_distance(&a, &b, None);
let (d2, _) = dtw_path(&a, &b, None);
assert!((d1 - d2).abs() < 1e-12);
}
#[test]
fn dtw_nan_in_input_propagates() {
// NaN in either input must propagate to the distance (IEEE 754 semantics).
let a = vec![1.0, 2.0, f64::NAN, 4.0];
let b = vec![1.0, 2.0, 3.0, 4.0];
assert!(dtw_distance(&a, &b, None).is_nan());
assert!(dtw_distance(&b, &a, None).is_nan());
}
#[test]
fn dtw_is_symmetric() {
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0, 6.0, 5.0];
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0, 1.0, 9.0];
let d_ab = dtw_distance(&a, &b, None);
let d_ba = dtw_distance(&b, &a, None);
assert!((d_ab - d_ba).abs() < 1e-12);
}
#[test]
fn dtw_path_length_bounded() {
// A valid warp path has length between max(n, m) and n + m - 1.
let a: Vec<f64> = (0..7).map(|x| x as f64).collect();
let b: Vec<f64> = (0..10).map(|x| (x as f64).sin()).collect();
let (_, path) = dtw_path(&a, &b, None);
let n = a.len();
let m = b.len();
assert!(path.len() >= n.max(m));
assert!(path.len() <= n + m - 1);
}
#[test]
fn dtw_window_constrained_ge_unconstrained() {
// window convention matches dtaidistance: Some(w) means |i-j| < w.
// A narrow window restricts warping, so constrained distance >= unconstrained.
let a: Vec<f64> = (0..20).map(|x| x as f64).collect();
let b: Vec<f64> = (0..20).map(|x| x as f64 + 3.0).collect();
let d_full = dtw_distance(&a, &b, None);
let d_narrow = dtw_distance(&a, &b, Some(3));
assert!(d_narrow >= d_full - 1e-12);
}
} }
+13 -1
View File
@@ -53,7 +53,19 @@ skip = []
db-urls = ["https://github.com/rustsec/advisory-db"] db-urls = ["https://github.com/rustsec/advisory-db"]
# Deny known security vulnerabilities # Deny known security vulnerabilities
version = 2 version = 2
ignore = [] ignore = [
# pyo3 0.25 advisories — fix is pyo3 >=0.29, which is a large API
# migration (IntoPy/ToPyObject were removed in 0.26). Ignored here
# because ferro-ta does NOT use either affected code path:
# * RUSTSEC-2026-0176 — OOB read in PyList/PyTuple nth/nth_back
# iterators: the crate has zero PyList/PyTuple iterator usage.
# * RUSTSEC-2026-0177 — missing Sync bound on
# PyCFunction::new_closure: the crate uses #[pyfunction], never
# new_closure.
# Tracked for removal once the pyo3 0.29 upgrade lands.
"RUSTSEC-2026-0176",
"RUSTSEC-2026-0177",
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sources — only allow crates from crates.io and our own path deps # Sources — only allow crates from crates.io and our own path deps
+304 -7
View File
@@ -1,8 +1,8 @@
{ {
"surfaces": { "surfaces": {
"python": { "python": {
"indicator_count": 208, "indicator_count": 211,
"method_count": 447, "method_count": 467,
"categories": [ "categories": [
"aggregation", "aggregation",
"alerts", "alerts",
@@ -131,6 +131,13 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "BATCH_DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "BBANDS", "name": "BBANDS",
"category": "overlap", "category": "overlap",
@@ -656,6 +663,20 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{
"name": "DTW_DISTANCE",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "DX", "name": "DX",
"category": "momentum", "category": "momentum",
@@ -1736,6 +1757,13 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "stock_leg_payoff",
"category": "derivatives_payoff",
"module": "ferro_ta.analysis.derivatives_payoff",
"doc": "",
"params": []
},
{ {
"name": "strategy_payoff", "name": "strategy_payoff",
"category": "derivatives_payoff", "category": "derivatives_payoff",
@@ -1743,6 +1771,13 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "strategy_value",
"category": "derivatives_payoff",
"module": "ferro_ta.analysis.derivatives_payoff",
"doc": "",
"params": []
},
{ {
"name": "CHANDELIER_EXIT", "name": "CHANDELIER_EXIT",
"category": "extended", "category": "extended",
@@ -2289,6 +2324,13 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "ExtendedGreeks",
"category": "options",
"module": "ferro_ta.analysis.options",
"doc": "",
"params": []
},
{ {
"name": "OptionGreeks", "name": "OptionGreeks",
"category": "options", "category": "options",
@@ -2303,6 +2345,20 @@
"doc": "", "doc": "",
"params": [] "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", "name": "black_76_price",
"category": "options", "category": "options",
@@ -2317,6 +2373,55 @@
"doc": "", "doc": "",
"params": [] "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", "name": "greeks",
"category": "options", "category": "options",
@@ -2366,6 +2471,27 @@
"doc": "", "doc": "",
"params": [] "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", "name": "select_strike",
"category": "options", "category": "options",
@@ -2387,6 +2513,20 @@
"doc": "", "doc": "",
"params": [] "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", "name": "DerivativesStrategy",
"category": "options_strategy", "category": "options_strategy",
@@ -3164,6 +3304,13 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "BATCH_DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "BETA", "name": "BETA",
"category": "statistic", "category": "statistic",
@@ -3178,6 +3325,20 @@
"doc": "", "doc": "",
"params": [] "params": []
}, },
{
"name": "DTW",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{
"name": "DTW_DISTANCE",
"category": "statistic",
"module": "ferro_ta.indicators.statistic",
"doc": "",
"params": []
},
{ {
"name": "LINEARREG", "name": "LINEARREG",
"category": "statistic", "category": "statistic",
@@ -4616,7 +4777,7 @@
] ]
}, },
"rust_core": { "rust_core": {
"public_function_count": 331, "public_function_count": 351,
"functions": [ "functions": [
{ {
"module": "aggregation", "module": "aggregation",
@@ -5288,6 +5449,16 @@
"function": "willr", "function": "willr",
"file": "momentum.rs" "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", "module": "options.chain",
"function": "atm_index", "function": "atm_index",
@@ -5308,16 +5479,36 @@
"function": "select_strike_by_offset", "function": "select_strike_by_offset",
"file": "options/chain.rs" "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", "module": "options.greeks",
"function": "black_76_greeks", "function": "black_76_greeks",
"file": "options/greeks.rs" "file": "options/greeks.rs"
}, },
{
"module": "options.greeks",
"function": "black_scholes_extended_greeks",
"file": "options/greeks.rs"
},
{ {
"module": "options.greeks", "module": "options.greeks",
"function": "black_scholes_greeks", "function": "black_scholes_greeks",
"file": "options/greeks.rs" "file": "options/greeks.rs"
}, },
{
"module": "options.greeks",
"function": "model_extended_greeks",
"file": "options/greeks.rs"
},
{ {
"module": "options.greeks", "module": "options.greeks",
"function": "model_greeks", "function": "model_greeks",
@@ -5363,6 +5554,26 @@
"function": "pdf", "function": "pdf",
"file": "options/normal.rs" "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", "module": "options.pricing",
"function": "black_76_price", "function": "black_76_price",
@@ -5388,11 +5599,51 @@
"function": "price_upper_bound", "function": "price_upper_bound",
"file": "options/pricing.rs" "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", "module": "options.surface",
"function": "atm_iv", "function": "atm_iv",
"file": "options/surface.rs" "file": "options/surface.rs"
}, },
{
"module": "options.surface",
"function": "expected_move",
"file": "options/surface.rs"
},
{ {
"module": "options.surface", "module": "options.surface",
"function": "linear_interpolate", "function": "linear_interpolate",
@@ -5978,6 +6229,16 @@
"function": "correl", "function": "correl",
"file": "statistic.rs" "file": "statistic.rs"
}, },
{
"module": "statistic",
"function": "dtw_distance",
"file": "statistic.rs"
},
{
"module": "statistic",
"function": "dtw_path",
"file": "statistic.rs"
},
{ {
"module": "statistic", "module": "statistic",
"function": "linearreg", "function": "linearreg",
@@ -6276,16 +6537,18 @@
] ]
}, },
"wasm_node": { "wasm_node": {
"export_count": 205, "export_count": 222,
"exports": [ "exports": [
"ad", "ad",
"adosc", "adosc",
"adx", "adx",
"adx_all", "adx_all",
"adxr", "adxr",
"aggregate_greeks_dense",
"aggregate_tick_bars", "aggregate_tick_bars",
"aggregate_time_bars", "aggregate_time_bars",
"aggregate_volume_bars_ticks", "aggregate_volume_bars_ticks",
"american_price",
"annualized_basis", "annualized_basis",
"apo", "apo",
"aroon", "aroon",
@@ -6318,6 +6581,7 @@
"check_cross", "check_cross",
"check_threshold", "check_threshold",
"choppiness_index", "choppiness_index",
"close_to_close_vol",
"cmo", "cmo",
"collect_alert_bars", "collect_alert_bars",
"compose_rank", "compose_rank",
@@ -6330,17 +6594,24 @@
"curve_summary", "curve_summary",
"dema", "dema",
"detect_breaks_cusum", "detect_breaks_cusum",
"digital_greeks",
"digital_price",
"donchian", "donchian",
"drawdown_series", "drawdown_series",
"dtw_distance",
"dx", "dx",
"early_exercise_premium",
"ema", "ema",
"exchange_charges_rate", "exchange_charges_rate",
"expected_move",
"extended_greeks",
"extract_trades", "extract_trades",
"fast_period", "fast_period",
"flat_per_order", "flat_per_order",
"forward_fill_nan", "forward_fill_nan",
"funding_cumulative_pnl", "funding_cumulative_pnl",
"futures_basis", "futures_basis",
"garman_klass_vol",
"gst_rate", "gst_rate",
"half_kelly_fraction", "half_kelly_fraction",
"ht_dcperiod", "ht_dcperiod",
@@ -6396,6 +6667,7 @@
"obv", "obv",
"ohlcv_agg", "ohlcv_agg",
"parity_gap", "parity_gap",
"parkinson_vol",
"per_lot", "per_lot",
"period", "period",
"pivot_points", "pivot_points",
@@ -6405,6 +6677,7 @@
"ppo", "ppo",
"price_lower_bound", "price_lower_bound",
"price_upper_bound", "price_upper_bound",
"put_call_parity_deviation",
"rank_series", "rank_series",
"rank_values", "rank_values",
"rate_of_value", "rate_of_value",
@@ -6418,6 +6691,7 @@
"rocp", "rocp",
"rocr", "rocr",
"rocr100", "rocr100",
"rogers_satchell_vol",
"roll_yield", "roll_yield",
"rolling_beta", "rolling_beta",
"rolling_max", "rolling_max",
@@ -6455,6 +6729,8 @@
"stoch", "stoch",
"stochf", "stochf",
"stochrsi", "stochrsi",
"strategy_payoff_dense",
"strategy_value_grid",
"stt_on_buy", "stt_on_buy",
"stt_on_sell", "stt_on_sell",
"stt_rate", "stt_rate",
@@ -6474,6 +6750,7 @@
"typprice", "typprice",
"ultosc", "ultosc",
"var", "var",
"vol_cone",
"volume_bars", "volume_bars",
"vwap", "vwap",
"vwma", "vwma",
@@ -6482,14 +6759,15 @@
"weighted_continuous", "weighted_continuous",
"willr", "willr",
"wma", "wma",
"yang_zhang_vol",
"zscore_series" "zscore_series"
] ]
} }
}, },
"parity_summary": { "parity_summary": {
"python_indicator_count": 207, "python_indicator_count": 210,
"wasm_export_count": 205, "wasm_export_count": 222,
"common_python_wasm_count": 91, "common_python_wasm_count": 92,
"common_python_wasm": [ "common_python_wasm": [
"ad", "ad",
"adosc", "adosc",
@@ -6518,6 +6796,7 @@
"dema", "dema",
"detect_breaks_cusum", "detect_breaks_cusum",
"donchian", "donchian",
"dtw_distance",
"dx", "dx",
"ema", "ema",
"ht_dcperiod", "ht_dcperiod",
@@ -6592,6 +6871,7 @@
"asin", "asin",
"atan", "atan",
"batch_apply", "batch_apply",
"batch_dtw",
"beta", "beta",
"cdl2crows", "cdl2crows",
"cdl3blackcrows", "cdl3blackcrows",
@@ -6661,6 +6941,7 @@
"cosh", "cosh",
"div", "div",
"drawdown", "drawdown",
"dtw",
"exp", "exp",
"feature_matrix", "feature_matrix",
"floor", "floor",
@@ -6703,9 +6984,11 @@
], ],
"wasm_only_vs_python": [ "wasm_only_vs_python": [
"adx_all", "adx_all",
"aggregate_greeks_dense",
"aggregate_tick_bars", "aggregate_tick_bars",
"aggregate_time_bars", "aggregate_time_bars",
"aggregate_volume_bars_ticks", "aggregate_volume_bars_ticks",
"american_price",
"annualized_basis", "annualized_basis",
"atm_index", "atm_index",
"atm_iv", "atm_iv",
@@ -6723,19 +7006,26 @@
"bottom_n_indices", "bottom_n_indices",
"calendar_spreads", "calendar_spreads",
"carry_spread", "carry_spread",
"close_to_close_vol",
"compose_rank", "compose_rank",
"compose_weighted", "compose_weighted",
"compute_performance_metrics", "compute_performance_metrics",
"curve_slope", "curve_slope",
"curve_summary", "curve_summary",
"digital_greeks",
"digital_price",
"drawdown_series", "drawdown_series",
"early_exercise_premium",
"exchange_charges_rate", "exchange_charges_rate",
"expected_move",
"extended_greeks",
"extract_trades", "extract_trades",
"fast_period", "fast_period",
"flat_per_order", "flat_per_order",
"forward_fill_nan", "forward_fill_nan",
"funding_cumulative_pnl", "funding_cumulative_pnl",
"futures_basis", "futures_basis",
"garman_klass_vol",
"gst_rate", "gst_rate",
"half_kelly_fraction", "half_kelly_fraction",
"implied_carry_rate", "implied_carry_rate",
@@ -6763,10 +7053,12 @@
"new", "new",
"ohlcv_agg", "ohlcv_agg",
"parity_gap", "parity_gap",
"parkinson_vol",
"per_lot", "per_lot",
"period", "period",
"price_lower_bound", "price_lower_bound",
"price_upper_bound", "price_upper_bound",
"put_call_parity_deviation",
"rank_series", "rank_series",
"rank_values", "rank_values",
"rate_of_value", "rate_of_value",
@@ -6774,6 +7066,7 @@
"ratio_adjusted_continuous", "ratio_adjusted_continuous",
"regulatory_charges_rate", "regulatory_charges_rate",
"relative_strength", "relative_strength",
"rogers_satchell_vol",
"roll_yield", "roll_yield",
"rolling_beta", "rolling_beta",
"rolling_max", "rolling_max",
@@ -6803,6 +7096,8 @@
"spread", "spread",
"stamp_duty_rate", "stamp_duty_rate",
"stitch_chunks", "stitch_chunks",
"strategy_payoff_dense",
"strategy_value_grid",
"stt_on_buy", "stt_on_buy",
"stt_on_sell", "stt_on_sell",
"stt_rate", "stt_rate",
@@ -6813,8 +7108,10 @@
"trade_stats", "trade_stats",
"trim_overlap", "trim_overlap",
"trix_indicator", "trix_indicator",
"vol_cone",
"walk_forward_indices", "walk_forward_indices",
"weighted_continuous", "weighted_continuous",
"yang_zhang_vol",
"zscore_series" "zscore_series"
] ]
} }
+1 -1
View File
@@ -1,7 +1,7 @@
Release Notes Release Notes
============= =============
These docs track package version ``1.1.1``. These docs track package version ``1.1.4``.
1.1.0-audit (2026-03-28) 1.1.0-audit (2026-03-28)
------------------------ ------------------------
+198 -42
View File
@@ -1,50 +1,187 @@
# Derivatives Analytics # Derivatives Analytics
`ferro-ta` now includes a Rust-backed derivatives analytics layer focused on `ferro-ta` ships a Rust-backed derivatives analytics layer focused on
research, simulation, and risk analysis. research, simulation, and risk analysis. All functions are implemented in
Rust core and exposed to Python (via PyO3) and WebAssembly (via wasm-bindgen).
---
## Modules ## Modules
- `ferro_ta.analysis.options` ### `ferro_ta.analysis.options`
- Black-Scholes-Merton and Black-76 pricing
- Delta, gamma, vega, theta, rho | Category | Functions |
- Implied volatility inversion with guarded Newton + bisection fallback |---|---|
- IV rank / percentile / z-score | **Pricing** | `black_scholes_price`, `black_76_price`, `option_price` |
- Smile metrics: ATM IV, 25-delta risk reversal, butterfly, skew slope, convexity | **Greeks** | `greeks`, `extended_greeks` |
- Chain helpers: moneyness labels and strike selection by offset or delta | **Implied vol** | `implied_volatility`, `iv_rank`, `iv_percentile`, `iv_zscore` |
- `ferro_ta.analysis.futures` | **Digital options** | `digital_option_price`, `digital_option_greeks` |
- Synthetic forwards and parity diagnostics | **American options** | `american_option_price`, `early_exercise_premium` |
- Basis, annualized basis, implied carry, carry spread | **Smile / surface** | `smile_metrics`, `term_structure_slope`, `expected_move` |
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted | **Chain helpers** | `label_moneyness`, `select_strike` |
- Curve analytics: calendar spreads, slope, contango summary | **Realised vol** | `close_to_close_vol`, `parkinson_vol`, `garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol` |
- `ferro_ta.analysis.options_strategy` | **Vol cone** | `vol_cone` |
- Typed strategy schemas for expiry selectors, strike selectors, multi-leg presets, | **Diagnostics** | `put_call_parity_deviation` |
risk controls, cost assumptions, and simulation limits
- `ferro_ta.analysis.derivatives_payoff` ### `ferro_ta.analysis.futures`
- Multi-leg payoff aggregation
- Portfolio-level Greeks aggregation across option and futures legs - 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 conventions
- `model="bsm"` expects the underlying input to be spot and `carry` to represent | Parameter | Convention |
a continuous dividend yield or generic carry term. |---|---|
- `model="black76"` expects the underlying input to be the forward price. | `model="bsm"` | Underlying is spot; `carry` = continuous dividend yield |
- Volatility and rates use decimal units: | `model="black76"` | Underlying is the forward price |
- `0.20` means 20% annualized volatility | `volatility` / `rate` / `carry` | Decimal annual (e.g. `0.20` = 20 %, `0.05` = 5 %) |
- `0.05` means 5% annualized rate | `time_to_expiry` | Years (e.g. `0.25` = 3 months) |
- `time_to_expiry` is expressed in years.
---
## Quick examples ## Quick examples
### BSM pricing and Greeks
```python ```python
from ferro_ta.analysis.options import greeks, implied_volatility, option_price 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") 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") 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") g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
print(price, iv, g.delta) 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 ```python
from ferro_ta.analysis.futures import basis, curve_summary 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])) 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 = [ ## Instrument types in `PayoffLeg` / `StrategyLeg`
PayoffLeg("option", "long", option_type="call", strike=100.0, premium=5.0),
PayoffLeg("future", "long", entry_price=100.0), | `instrument` | Required fields | Payoff |
] |---|---|---|
grid = [90.0, 100.0, 110.0] | `"option"` | `option_type`, `strike`, `expiry_selector`, `strike_selector` | `max(φ(SK), 0) premium` |
print(strategy_payoff(grid, legs=legs)) | `"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 ## Notes
- Existing `iv_rank`, `iv_percentile`, and `iv_zscore` names are preserved. - All existing function names (`iv_rank`, `iv_percentile`, `iv_zscore`, `greeks`,
- The derivatives layer is analytics-only: there is no broker connectivity, `option_price`, etc.) are preserved — fully backward compatible.
order routing, or execution workflow in this API. - 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 Release status
-------------- --------------
These docs track package version ``1.1.1``. These docs track package version ``1.1.4``.
- Release notes by version: :doc:`changelog` - Release notes by version: :doc:`changelog`
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_ - Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
+4 -2
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "ferro-ta" name = "ferro-ta"
version = "1.1.1" version = "1.1.4"
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
@@ -67,6 +67,7 @@ dev = [
"matplotlib>=3.5", "matplotlib>=3.5",
"fastapi>=0.135.1", "fastapi>=0.135.1",
"httpx>=0.24", "httpx>=0.24",
"scipy>=1.10",
] ]
[project.urls] [project.urls]
@@ -83,7 +84,7 @@ omit = ["*/_ferro_ta*", "*/mcp/*"]
[tool.coverage.report] [tool.coverage.report]
show_missing = true show_missing = true
fail_under = 65 fail_under = 80
exclude_lines = [ exclude_lines = [
"pragma: no cover", "pragma: no cover",
"if TYPE_CHECKING:", "if TYPE_CHECKING:",
@@ -162,4 +163,5 @@ dev = [
"pyyaml>=6.0", "pyyaml>=6.0",
"pandas-ta>=0.3; python_version >= '3.12'", "pandas-ta>=0.3; python_version >= '3.12'",
"ta>=0.10", "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 from ferro_ta.core.exceptions import ( # noqa: F401
FerroTAError, FerroTAError,
FerroTaError,
FerroTAInputError, FerroTAInputError,
FerroTAValueError, 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 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_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_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 import OptionGreeks
from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg
from ferro_ta.core.exceptions import ( from ferro_ta.core.exceptions import (
@@ -26,7 +27,9 @@ __all__ = [
"PayoffLeg", "PayoffLeg",
"option_leg_payoff", "option_leg_payoff",
"futures_leg_payoff", "futures_leg_payoff",
"stock_leg_payoff",
"strategy_payoff", "strategy_payoff",
"strategy_value",
"aggregate_greeks", "aggregate_greeks",
] ]
@@ -47,8 +50,10 @@ class PayoffLeg:
multiplier: float = 1.0 multiplier: float = 1.0
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.instrument not in {"option", "future"}: if self.instrument not in {"option", "future", "stock"}:
raise FerroTAValueError("instrument must be 'option' or 'future'.") raise FerroTAValueError(
"instrument must be 'option', 'future', or 'stock'."
)
if self.side not in {"long", "short"}: if self.side not in {"long", "short"}:
raise FerroTAValueError("side must be 'long' or 'short'.") raise FerroTAValueError("side must be 'long' or 'short'.")
if self.instrument == "option": if self.instrument == "option":
@@ -58,8 +63,8 @@ class PayoffLeg:
) )
if self.strike is None: if self.strike is None:
raise FerroTAValueError("option legs require strike.") raise FerroTAValueError("option legs require strike.")
if self.instrument == "future" and self.entry_price is None: if self.instrument in {"future", "stock"} and self.entry_price is None:
raise FerroTAValueError("future legs require entry_price.") raise FerroTAValueError(f"{self.instrument} legs require entry_price.")
def _side_sign(side: str) -> float: 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: def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg:
return PayoffLeg(**mapping) return PayoffLeg(**mapping)
@@ -141,7 +200,9 @@ def _strategy_leg_to_payoff_leg(leg: StrategyLeg) -> PayoffLeg:
side=leg.side, side=leg.side,
quantity=float(leg.quantity), quantity=float(leg.quantity),
option_type=leg.option_type, 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(theta),
float(rho), 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 ( from ferro_ta._ferro_ta import (
bsm_price_batch as _rust_bsm_price_batch, 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 ( from ferro_ta._ferro_ta import (
implied_volatility as _rust_implied_volatility, implied_volatility as _rust_implied_volatility,
) )
@@ -50,6 +59,9 @@ from ferro_ta._ferro_ta import (
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
option_greeks_batch as _rust_option_greeks_batch, 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 ( from ferro_ta._ferro_ta import (
select_strike_delta as _rust_select_strike_delta, select_strike_delta as _rust_select_strike_delta,
) )
@@ -73,11 +85,14 @@ ScalarOrArray: TypeAlias = float | NDArray[np.float64]
__all__ = [ __all__ = [
"OptionGreeks", "OptionGreeks",
"ExtendedGreeks",
"SmileMetrics", "SmileMetrics",
"VolCone",
"black_scholes_price", "black_scholes_price",
"black_76_price", "black_76_price",
"option_price", "option_price",
"greeks", "greeks",
"extended_greeks",
"implied_volatility", "implied_volatility",
"smile_metrics", "smile_metrics",
"term_structure_slope", "term_structure_slope",
@@ -86,9 +101,63 @@ __all__ = [
"iv_rank", "iv_rank",
"iv_percentile", "iv_percentile",
"iv_zscore", "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) @dataclass(frozen=True)
class OptionGreeks: class OptionGreeks:
"""Container for first-order Greeks.""" """Container for first-order Greeks."""
@@ -630,3 +699,897 @@ def select_strike(
except ValueError as err: except ValueError as err:
_normalize_rust_error(err) _normalize_rust_error(err)
return None if strike is None else float(strike) 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) @dataclass(frozen=True)
class StrategyLeg: class StrategyLeg:
underlying: str underlying: str
expiry_selector: ExpirySelector expiry_selector: ExpirySelector | None
strike_selector: StrikeSelector strike_selector: StrikeSelector | None
option_type: str option_type: str | None
side: str = "long" side: str = "long"
quantity: int = 1 quantity: int = 1
instrument: str = "option" instrument: str = "option"
@@ -158,12 +158,21 @@ class StrategyLeg:
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.underlying.strip() == "": if self.underlying.strip() == "":
raise FerroTAInputError("underlying must not be empty.") raise FerroTAInputError("underlying must not be empty.")
if self.option_type not in {"call", "put"}: if self.instrument not in {"option", "future", "stock"}:
raise FerroTAValueError("option_type must be 'call' or 'put'.") 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"}: if self.side not in {"long", "short"}:
raise FerroTAValueError("side must be 'long' or '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: if self.quantity == 0:
raise FerroTAValueError("quantity must be non-zero.") raise FerroTAValueError("quantity must be non-zero.")
if self.premium_limit is not None and self.premium_limit < 0.0: 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" code = "FTERR002"
# ---------------------------------------------------------------------------
# Finer-grained exception subclasses (added in 1.2.0).
#
# These are drop-in compatible with the base classes: every subclass still
# inherits from ``FerroTAError`` and ``ValueError``, so existing user code
# like ``except FerroTAValueError:`` or ``except ValueError:`` keeps working.
# The subclasses exist so users can catch *specific* failure modes without
# string-matching on the error message.
# ---------------------------------------------------------------------------
class InvalidPeriodError(FerroTAValueError):
"""Parameter like ``timeperiod``, ``fastperiod``, ``slowperiod`` is out of range.
Default error code: ``FTERR001``.
"""
class InsufficientDataError(FerroTAInputError):
"""Input array is shorter than the minimum required for the indicator.
Default error code: ``FTERR003``.
"""
code = "FTERR003"
class LengthMismatchError(FerroTAInputError):
"""Two or more input arrays (e.g. OHLC) have different lengths.
Default error code: ``FTERR004``.
"""
code = "FTERR004"
class NumericConvergenceError(FerroTAValueError):
"""An iterative calculation failed to converge within tolerance.
Raised by iterative pricing models (implied volatility root-finding,
Newton-Raphson, etc.) when the maximum iteration count is exhausted.
"""
class InvalidInputError(FerroTAInputError):
"""Input contains NaN/Inf in strict mode, wrong dtype, or wrong shape.
Default error code: ``FTERR005``.
"""
code = "FTERR005"
# Public aliases that match the names documented in the README and
# CHANGELOG [Unreleased] section.
FerroTaError = FerroTAError # type: ignore[misc]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Validation helpers (called by Python wrappers) # Validation helpers (called by Python wrappers)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -154,7 +211,7 @@ def check_timeperiod(value: int, name: str = "timeperiod", minimum: int = 1) ->
If ``value < minimum``. If ``value < minimum``.
""" """
if value < minimum: if value < minimum:
raise FerroTAValueError( raise InvalidPeriodError(
f"{name} must be >= {minimum}, got {value}", f"{name} must be >= {minimum}, got {value}",
suggestion=f"Set {name}={minimum} or higher.", suggestion=f"Set {name}={minimum} or higher.",
) )
@@ -193,7 +250,7 @@ def check_equal_length(**arrays: object) -> None:
if len(set(lengths.values())) > 1: if len(set(lengths.values())) > 1:
detail = ", ".join(f"{k}={v}" for k, v in lengths.items()) detail = ", ".join(f"{k}={v}" for k, v in lengths.items())
raise FerroTAInputError( raise LengthMismatchError(
f"All input arrays must have the same length. Got: {detail}", f"All input arrays must have the same length. Got: {detail}",
code=_CODE_LENGTH_MISMATCH, code=_CODE_LENGTH_MISMATCH,
suggestion="Trim or align your arrays so that open, high, low, close, and volume all have the same number of rows.", suggestion="Trim or align your arrays so that open, high, low, close, and volume all have the same number of rows.",
@@ -223,7 +280,7 @@ def check_finite(arr: object, name: str = "input") -> None:
a = np.asarray(arr, dtype=np.float64) a = np.asarray(arr, dtype=np.float64)
if not np.all(np.isfinite(a)): if not np.all(np.isfinite(a)):
raise FerroTAInputError( raise InvalidInputError(
f"{name} contains NaN or Inf values. " f"{name} contains NaN or Inf values. "
"ferro_ta propagates NaN by default; call check_finite() only " "ferro_ta propagates NaN by default; call check_finite() only "
"when you require all-finite inputs.", "when you require all-finite inputs.",
@@ -255,7 +312,7 @@ def check_min_length(arr: object, min_len: int, name: str = "input") -> None:
elif hasattr(arr, "shape"): elif hasattr(arr, "shape"):
length = arr.shape[0] # type: ignore[union-attr] length = arr.shape[0] # type: ignore[union-attr]
if length < min_len: if length < min_len:
raise FerroTAInputError( raise InsufficientDataError(
f"{name} must have at least {min_len} elements, got {length}", f"{name} must have at least {min_len} elements, got {length}",
code=_CODE_TOO_SHORT, code=_CODE_TOO_SHORT,
suggestion=f"Provide at least {min_len} data points. Current length: {length}.", suggestion=f"Provide at least {min_len} data points. Current length: {length}.",
+109
View File
@@ -12,19 +12,33 @@ LINEARREG_ANGLE — Linear Regression Angle (degrees)
TSF Time Series Forecast TSF Time Series Forecast
BETA Beta BETA Beta
CORREL Pearson's Correlation Coefficient (r) CORREL Pearson's Correlation Coefficient (r)
DTW Dynamic Time Warping (distance + warping path)
DTW_DISTANCE Dynamic Time Warping distance only (faster)
BATCH_DTW Batch DTW: N series vs 1 reference, in parallel
""" """
from __future__ import annotations from __future__ import annotations
from typing import Optional
import numpy as np import numpy as np
from numpy.typing import ArrayLike from numpy.typing import ArrayLike
from ferro_ta._ferro_ta import (
batch_dtw as _batch_dtw,
)
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
beta as _beta, beta as _beta,
) )
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
correl as _correl, correl as _correl,
) )
from ferro_ta._ferro_ta import (
dtw as _dtw,
)
from ferro_ta._ferro_ta import (
dtw_distance as _dtw_distance,
)
from ferro_ta._ferro_ta import ( from ferro_ta._ferro_ta import (
linearreg as _linearreg, linearreg as _linearreg,
) )
@@ -247,6 +261,98 @@ def CORREL(real0: ArrayLike, real1: ArrayLike, timeperiod: int = 30) -> np.ndarr
_normalize_rust_error(e) _normalize_rust_error(e)
def DTW(
series1: ArrayLike,
series2: ArrayLike,
window: Optional[int] = None,
) -> tuple[float, np.ndarray]:
"""Dynamic Time Warping — distance and optimal warping path.
Parameters
----------
series1 : array-like
First time series.
series2 : array-like
Second time series (may differ in length from series1).
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
distance : float
DTW distance (accumulated Euclidean cost along the optimal path).
path : numpy.ndarray, shape (N, 2)
Warping path as ``(i, j)`` index pairs from ``(0, 0)`` to
``(len(series1)-1, len(series2)-1)``.
"""
try:
return _dtw(_to_f64(series1), _to_f64(series2), window)
except ValueError as e:
_normalize_rust_error(e)
def DTW_DISTANCE(
series1: ArrayLike,
series2: ArrayLike,
window: Optional[int] = None,
) -> float:
"""Dynamic Time Warping distance only (faster — no path reconstruction).
Parameters
----------
series1 : array-like
First time series.
series2 : array-like
Second time series (may differ in length from series1).
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
float
DTW distance (accumulated Euclidean cost along the optimal path).
"""
try:
return _dtw_distance(_to_f64(series1), _to_f64(series2), window)
except ValueError as e:
_normalize_rust_error(e)
def BATCH_DTW(
matrix: ArrayLike,
reference: ArrayLike,
window: Optional[int] = None,
) -> np.ndarray:
"""Batch Dynamic Time Warping — N series vs 1 reference, computed in parallel.
Parameters
----------
matrix : array-like, shape (N, L)
N time series of length L. Each row is compared against ``reference``.
reference : array-like, shape (L,)
The reference series.
window : int, optional
Sakoe-Chiba band width. ``None`` (default) = unconstrained.
Returns
-------
numpy.ndarray, shape (N,)
DTW distance from each row of ``matrix`` to ``reference``.
"""
try:
mat = np.ascontiguousarray(matrix, dtype=np.float64)
if mat.ndim != 2:
from ferro_ta.core.exceptions import FerroTAInputError
raise FerroTAInputError(
f"matrix must be a 2-D array, got {mat.ndim}-D.",
suggestion="Pass a 2-D NumPy array of shape (N, L).",
)
return _batch_dtw(mat, _to_f64(reference), window)
except ValueError as e:
_normalize_rust_error(e)
__all__ = [ __all__ = [
"STDDEV", "STDDEV",
"VAR", "VAR",
@@ -257,4 +363,7 @@ __all__ = [
"TSF", "TSF",
"BETA", "BETA",
"CORREL", "CORREL",
"DTW",
"DTW_DISTANCE",
"BATCH_DTW",
] ]
+1 -1
View File
@@ -192,7 +192,7 @@ def _extract_wasm_exports(root: Path) -> list[str]:
return sorted(exports) return sorted(exports)
# Fallback to generated declarations if source parsing did not find exports. # Fallback to generated declarations if source parsing did not find exports.
dts_path = root / "wasm" / "pkg" / "ferro_ta_wasm.d.ts" dts_path = root / "wasm" / "node" / "ferro_ta_wasm.d.ts"
if dts_path.exists(): if dts_path.exists():
for line in dts_path.read_text(encoding="utf-8").splitlines(): for line in dts_path.read_text(encoding="utf-8").splitlines():
line = line.strip() line = line.strip()
+200 -128
View File
@@ -1,27 +1,40 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Pre-push CI gate — runs checks in parallel to minimise wall-clock time.
#
# Usage:
# scripts/pre_push_checks.sh # all checks
# scripts/pre_push_checks.sh rust_clippy wasm # selected checks
# scripts/pre_push_checks.sh --list
# FERRO_FAST=1 scripts/pre_push_checks.sh # skip docs + wasm bench
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
AVAILABLE_CHECKS=( AVAILABLE_CHECKS=(
version version changelog manifest
changelog rust_fmt rust_clippy rust_core rust_bench
rust_fmt python_lint python_typecheck python_test
rust_clippy docs wasm
rust_core
rust_bench
python_lint
python_typecheck
python_test
docs
wasm
manifest
) )
DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}") DEFAULT_CHECKS=("${AVAILABLE_CHECKS[@]}")
python_env_ready=0 # ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
need_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2; exit 1
fi
}
run_cmd() {
printf ' +'
printf ' %q' "$@"
printf '\n'
"$@"
}
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -30,93 +43,60 @@ Usage:
scripts/pre_push_checks.sh <check> [<check> ...] scripts/pre_push_checks.sh <check> [<check> ...]
scripts/pre_push_checks.sh --list scripts/pre_push_checks.sh --list
Runs the repo's basic local CI gate before push. By default it covers: Environment:
version changelog rust_fmt rust_clippy rust_core rust_bench FERRO_FAST=1 Skip docs and wasm (fastest local feedback loop)
python_lint python_typecheck python_test docs wasm manifest
Notes:
- This mirrors the required CI categories we can run locally.
- It intentionally skips the multi-Python test matrix, audit jobs, perf smoke,
and benchmark-regression jobs.
EOF EOF
} }
list_checks() { # ---------------------------------------------------------------------------
printf '%s\n' "${AVAILABLE_CHECKS[@]}" # Individual check functions
} # ---------------------------------------------------------------------------
need_cmd() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "Missing required command: $command_name" >&2
exit 1
fi
}
run_cmd() {
printf ' +'
printf ' %q' "$@"
printf '\n'
"$@"
}
ensure_python_env() {
if [[ "$python_env_ready" -eq 1 ]]; then
return
fi
need_cmd uv
run_cmd uv sync --extra dev --extra docs --extra mcp
run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release
python_env_ready=1
}
run_version() {
need_cmd python3
run_cmd python3 scripts/bump_version.py --check
}
run_changelog() {
need_cmd python3
run_cmd python3 scripts/check_changelog.py
}
run_rust_fmt() {
need_cmd cargo
run_cmd cargo fmt --all -- --check
}
run_rust_clippy() {
need_cmd cargo
run_cmd cargo clippy --release -- -D warnings
}
run_rust_core() {
need_cmd cargo
run_cmd cargo build -p ferro_ta_core
run_cmd cargo test -p ferro_ta_core
}
run_rust_bench() {
need_cmd cargo
run_cmd cargo bench -p ferro_ta_core --no-run
}
run_version() { need_cmd python3; run_cmd python3 scripts/bump_version.py --check; }
run_changelog() { need_cmd python3; run_cmd python3 scripts/check_changelog.py; }
run_manifest() { need_cmd python3; run_cmd python3 scripts/check_api_manifest.py; }
run_rust_fmt() { need_cmd cargo; run_cmd cargo fmt --all -- --check; }
run_python_lint() { run_python_lint() {
need_cmd uv need_cmd uv
run_cmd uv run --with ruff ruff check python/ tests/ run_cmd uv run --with ruff ruff check python/ tests/
run_cmd uv run --with ruff ruff format --check python/ tests/ run_cmd uv run --with ruff ruff format --check python/ tests/
} }
run_rust_clippy() { need_cmd cargo; run_cmd cargo clippy --release -- -D warnings; }
run_rust_core() { need_cmd cargo; run_cmd cargo build -p ferro_ta_core && run_cmd cargo test -p ferro_ta_core; }
run_rust_bench() { need_cmd cargo; run_cmd cargo bench -p ferro_ta_core --no-run; }
run_python_typecheck() { run_python_typecheck() {
need_cmd uv need_cmd uv
run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary run_cmd uv run --with mypy --with numpy python -m mypy python/ferro_ta \
--ignore-missing-imports --no-error-summary
run_cmd uv run --with pyright python -m pyright python/ferro_ta run_cmd uv run --with pyright python -m pyright python/ferro_ta
} }
# python_test and docs both need a compiled extension.
# Use a flag file so only the first concurrent caller runs maturin develop;
# subsequent callers (in parallel background jobs) wait and reuse it.
_MATURIN_LOCK="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.lock"
_MATURIN_FLAG="${TMPDIR:-/tmp}/ferro_ta_maturin_$$.done"
ensure_python_env() {
[[ -f "$_MATURIN_FLAG" ]] && return
(
flock 9
if [[ ! -f "$_MATURIN_FLAG" ]]; then
need_cmd uv
run_cmd uv sync --extra dev --extra docs --extra mcp
run_cmd uv run --extra dev --extra docs --extra mcp maturin develop --release
touch "$_MATURIN_FLAG"
fi
) 9>"$_MATURIN_LOCK"
}
run_python_test() { run_python_test() {
ensure_python_env ensure_python_env
run_cmd uv run --extra dev --extra mcp --with pytest-cov pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65 run_cmd uv run --extra dev --extra mcp --with pytest-cov \
pytest tests/unit/ tests/integration/ \
-v --cov=ferro_ta --cov-report=term-missing --cov-fail-under=65
} }
run_docs() { run_docs() {
@@ -125,69 +105,161 @@ run_docs() {
} }
run_wasm() { run_wasm() {
need_cmd node need_cmd node; need_cmd wasm-pack
need_cmd wasm-pack
local benchmark_json="../.wasm_benchmark.prepush.json"
( (
cd wasm cd wasm
trap 'rm -f "$benchmark_json"' EXIT
run_cmd wasm-pack test --node run_cmd wasm-pack test --node
run_cmd wasm-pack build --target nodejs --out-dir pkg run_cmd npm run build
run_cmd node bench.js --json "$benchmark_json" if [[ "${FERRO_FAST:-0}" != "1" ]]; then
local bj="../.wasm_benchmark.prepush.json"
run_cmd node bench.js --json "$bj"
rm -f "$bj"
fi
) )
} }
run_manifest() {
need_cmd python3
run_cmd python3 scripts/check_api_manifest.py
}
run_check() { run_check() {
local check_name="$1" case "$1" in
case "$check_name" in version) run_version ;;
version) run_version ;; changelog) run_changelog ;;
changelog) run_changelog ;; manifest) run_manifest ;;
rust_fmt) run_rust_fmt ;; rust_fmt) run_rust_fmt ;;
rust_clippy) run_rust_clippy ;; rust_clippy) run_rust_clippy ;;
rust_core) run_rust_core ;; rust_core) run_rust_core ;;
rust_bench) run_rust_bench ;; rust_bench) run_rust_bench ;;
python_lint) run_python_lint ;; python_lint) run_python_lint ;;
python_typecheck) run_python_typecheck ;; python_typecheck) run_python_typecheck ;;
python_test) run_python_test ;; python_test) run_python_test ;;
docs) run_docs ;; docs) run_docs ;;
wasm) run_wasm ;; wasm) run_wasm ;;
manifest) run_manifest ;; *) echo "Unknown check: $1 — use --list" >&2; exit 1 ;;
*)
echo "Unknown check: $check_name" >&2
echo "Use --list to see supported checks." >&2
exit 1
;;
esac esac
} }
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then # ---------------------------------------------------------------------------
usage # Parallel runner — starts all checks concurrently, collects results
exit 0 # ---------------------------------------------------------------------------
fi
if [[ "${1:-}" == "--list" ]]; then run_parallel() {
list_checks local -a checks=("$@")
exit 0 [[ "${#checks[@]}" -eq 0 ]] && return 0
fi
local -a pids logs names
local start
start=$(date +%s)
printf '\nStarting %d checks in parallel: %s\n' "${#checks[@]}" "${checks[*]}"
for check in "${checks[@]}"; do
local log
log=$(mktemp /tmp/ferro_prepush_XXXXXX)
logs+=("$log")
names+=("$check")
run_check "$check" >"$log" 2>&1 &
pids+=($!)
done
local failed=0
local -a failed_names
printf '\n'
for i in "${!pids[@]}"; do
if wait "${pids[$i]}" 2>/dev/null; then
printf ' ✓ %s\n' "${names[$i]}"
else
printf ' ✗ %s\n' "${names[$i]}"
failed_names+=("${names[$i]}")
failed=1
fi
done
# Print logs for failed checks only
if [[ "$failed" -eq 1 ]]; then
for i in "${!names[@]}"; do
local name="${names[$i]}"
if [[ " ${failed_names[*]:-} " == *" $name "* ]]; then
printf '\n'; printf '━%.0s' {1..60}; printf '\nFAILED: %s\n' "$name"; printf '━%.0s' {1..60}; printf '\n'
cat "${logs[$i]}"
fi
done
fi
for log in "${logs[@]}"; do rm -f "$log"; done
rm -f "$_MATURIN_LOCK" "$_MATURIN_FLAG"
printf '\nElapsed: %ds\n' "$(( $(date +%s) - start ))"
return "$failed"
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
[[ "${1:-}" == "--help" || "${1:-}" == "-h" ]] && { usage; exit 0; }
[[ "${1:-}" == "--list" ]] && { printf '%s\n' "${AVAILABLE_CHECKS[@]}"; exit 0; }
selected_checks=() selected_checks=()
if [[ "$#" -gt 0 ]]; then if [[ "$#" -gt 0 ]]; then
selected_checks=("$@") selected_checks=("$@")
else else
selected_checks=("${DEFAULT_CHECKS[@]}") selected_checks=("${DEFAULT_CHECKS[@]}")
if [[ "${FERRO_FAST:-0}" == "1" ]]; then
selected_checks=()
for c in "${DEFAULT_CHECKS[@]}"; do
[[ "$c" == "docs" || "$c" == "wasm" ]] && continue
selected_checks+=("$c")
done
printf 'FERRO_FAST=1: skipping docs + wasm\n'
fi
fi fi
total_checks="${#selected_checks[@]}" # ---------------------------------------------------------------------------
index=0 # Execution strategy:
for check_name in "${selected_checks[@]}"; do # Phase 1 — instant gate (sequential, fail-fast):
index=$((index + 1)) # version, changelog, manifest, python_lint, rust_fmt
printf '\n[%d/%d] %s\n' "$index" "$total_checks" "$check_name" # These are trivial to run and catch the most common mistakes early.
run_check "$check_name" # If any fail here we abort immediately without waiting for slow checks.
#
# Phase 2 — everything else in parallel:
# rust_clippy, rust_core, rust_bench, python_typecheck,
# python_test, docs, wasm
# ---------------------------------------------------------------------------
FAST_CHECKS=(version changelog manifest python_lint rust_fmt)
phase1=()
phase2=()
for c in "${selected_checks[@]}"; do
is_fast=0
for f in "${FAST_CHECKS[@]}"; do [[ "$c" == "$f" ]] && is_fast=1 && break; done
if [[ "$is_fast" -eq 1 ]]; then phase1+=("$c"); else phase2+=("$c"); fi
done done
printf '\nAll selected pre-push checks passed.\n' # Phase 1: fast gate
if [[ "${#phase1[@]}" -gt 0 ]]; then
printf 'Phase 1 — fast gate (%d checks)\n' "${#phase1[@]}"
start1=$(date +%s)
for c in "${phase1[@]}"; do
printf ' [%s] ... ' "$c"
log=$(mktemp /tmp/ferro_prepush_XXXXXX)
if run_check "$c" >"$log" 2>&1; then
printf 'ok\n'
else
printf 'FAILED\n'
cat "$log"
rm -f "$log"
echo "" >&2
echo "Fast gate failed on '$c' — aborting before slow checks." >&2
exit 1
fi
rm -f "$log"
done
printf 'Phase 1 passed (%ds)\n' "$(( $(date +%s) - start1 ))"
fi
# Phase 2: parallel slow checks
if [[ "${#phase2[@]}" -gt 0 ]]; then
printf '\nPhase 2 — parallel slow checks\n'
run_parallel "${phase2[@]}" || exit 1
fi
printf '\nAll pre-push checks passed.\n'
+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 numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*; 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> = ( type GreekArrays<'py> = (
Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>,
@@ -123,3 +131,112 @@ pub fn option_greeks_batch<'py>(
rho.into_pyarray(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. //! PyO3 wrappers for options analytics.
mod american;
mod chain; mod chain;
mod digital;
mod greeks; mod greeks;
mod iv; mod iv;
mod payoff; mod payoff;
mod pricing; mod pricing;
mod realized_vol;
mod surface; mod surface;
use pyo3::exceptions::PyValueError; use pyo3::exceptions::PyValueError;
@@ -40,11 +43,20 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
self::pricing::black76_price_batch, self::pricing::black76_price_batch,
m 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, m)?)?;
m.add_function(pyo3::wrap_pyfunction!( m.add_function(pyo3::wrap_pyfunction!(
self::greeks::option_greeks_batch, self::greeks::option_greeks_batch,
m 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, m)?)?;
m.add_function(pyo3::wrap_pyfunction!( m.add_function(pyo3::wrap_pyfunction!(
self::iv::implied_volatility_batch, self::iv::implied_volatility_batch,
@@ -58,6 +70,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
self::surface::term_structure_slope, self::surface::term_structure_slope,
m 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::moneyness_labels, m)?)?;
m.add_function(pyo3::wrap_pyfunction!( m.add_function(pyo3::wrap_pyfunction!(
self::chain::select_strike_offset, self::chain::select_strike_offset,
@@ -80,5 +93,56 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
self::payoff::aggregate_greeks_legs, self::payoff::aggregate_greeks_legs,
m 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(()) Ok(())
} }
+59 -7
View File
@@ -7,6 +7,7 @@ use pyo3::types::{PyAny, PyTuple};
enum Instrument { enum Instrument {
Option, Option,
Future, Future,
Stock,
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -34,8 +35,9 @@ fn parse_instrument(v: i64) -> PyResult<Instrument> {
match v { match v {
0 => Ok(Instrument::Option), 0 => Ok(Instrument::Option),
1 => Ok(Instrument::Future), 1 => Ok(Instrument::Future),
2 => Ok(Instrument::Stock),
_ => Err(PyValueError::new_err( _ => 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() { match v.to_ascii_lowercase().as_str() {
"option" => Ok(Instrument::Option), "option" => Ok(Instrument::Option),
"future" => Ok(Instrument::Future), "future" => Ok(Instrument::Future),
"stock" => Ok(Instrument::Stock),
_ => Err(PyValueError::new_err( _ => 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); total[i] += leg_scale * (intrinsic - p);
} }
} }
Instrument::Future => { Instrument::Future | Instrument::Stock => {
let e = entry[leg_idx]; let e = entry[leg_idx];
for (i, &s) in grid.iter().enumerate() { for (i, &s) in grid.iter().enumerate() {
total[i] += leg_scale * (s - e); total[i] += leg_scale * (s - e);
@@ -253,9 +256,9 @@ pub fn strategy_payoff_legs<'py>(
total[i] += leg_scale * (intrinsic - premium); 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(|| { 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() { for (i, &s) in grid.iter().enumerate() {
total[i] += leg_scale * (s - entry_price); 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 side_sign = parse_side(side[i])?.sign();
let leg_scale = side_sign * qty[i] * mult[i]; let leg_scale = side_sign * qty[i] * mult[i];
match instrument { match instrument {
Instrument::Future => { Instrument::Future | Instrument::Stock => {
delta += leg_scale; delta += leg_scale;
} }
Instrument::Option => { Instrument::Option => {
@@ -382,7 +385,7 @@ pub fn aggregate_greeks_legs(
let leg_scale = side_sign * quantity * multiplier; let leg_scale = side_sign * quantity * multiplier;
match instrument { match instrument {
Instrument::Future => { Instrument::Future | Instrument::Stock => {
delta += leg_scale; delta += leg_scale;
} }
Instrument::Option => { Instrument::Option => {
@@ -441,3 +444,52 @@ pub fn aggregate_greeks_legs(
Ok((delta, gamma, vega, theta, rho)) 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)) 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] #[pyfunction]
#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))] #[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))]
pub fn black76_price_batch<'py>( 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, 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; mod beta;
pub(crate) mod common; pub(crate) mod common;
mod correl; mod correl;
mod dtw;
mod linearreg; mod linearreg;
mod stddev; mod stddev;
mod var; mod var;
@@ -23,5 +24,8 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(pyo3::wrap_pyfunction!(self::linearreg::tsf, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::linearreg::tsf, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::beta::beta, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::beta::beta, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::correl::correl, m)?)?; m.add_function(pyo3::wrap_pyfunction!(self::correl::correl, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::dtw::dtw, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::dtw::dtw_distance, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::dtw::batch_dtw, m)?)?;
Ok(()) Ok(())
} }
@@ -19,7 +19,7 @@ SCRIPT = WASM_DIR / "conformance_node.js"
def _write_node_conformance_script(path: Path) -> None: def _write_node_conformance_script(path: Path) -> None:
path.write_text( path.write_text(
""" """
const wasm = require("./pkg/ferro_ta_wasm.js"); const wasm = require("./node/ferro_ta_wasm.js");
function toArray(x) { function toArray(x) {
return Array.from(x, (v) => (Number.isNaN(v) ? null : Number(v))); return Array.from(x, (v) => (Number.isNaN(v) ? null : Number(v)));
+178
View File
@@ -1,10 +1,14 @@
"""Unit tests for ferro_ta.indicators.statistic""" """Unit tests for ferro_ta.indicators.statistic"""
import numpy as np import numpy as np
import pytest
from ferro_ta.indicators.statistic import ( from ferro_ta.indicators.statistic import (
BATCH_DTW,
BETA, BETA,
CORREL, CORREL,
DTW,
DTW_DISTANCE,
LINEARREG, LINEARREG,
LINEARREG_ANGLE, LINEARREG_ANGLE,
LINEARREG_INTERCEPT, LINEARREG_INTERCEPT,
@@ -308,3 +312,177 @@ class TestTSF:
expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0) expected = _naive_linearreg(_A, timeperiod=14, x_value=14.0)
result = TSF(_A, timeperiod=14) result = TSF(_A, timeperiod=14)
np.testing.assert_allclose(result, expected, equal_nan=True) np.testing.assert_allclose(result, expected, equal_nan=True)
# ---------------------------------------------------------------------------
# DTW — Dynamic Time Warping
# ---------------------------------------------------------------------------
dtai = pytest.importorskip("dtaidistance", reason="dtaidistance not installed")
_DTW_RNG = np.random.default_rng(42)
class TestDTW:
# --- Validation against dtaidistance (SOTA reference) ---
def test_distance_matches_dtaidistance_random(self):
"""Core correctness: our distance == dtaidistance on 20 random pairs."""
for _ in range(20):
n = int(_DTW_RNG.integers(5, 50))
a = _DTW_RNG.random(n)
b = _DTW_RNG.random(n)
expected = dtai.dtw.distance(a, b)
actual = DTW_DISTANCE(a, b)
np.testing.assert_allclose(
actual, expected, rtol=1e-9, err_msg=f"Mismatch on series length {n}"
)
def test_distance_matches_dtaidistance_unequal_length(self):
"""Handles unequal-length series correctly."""
for _ in range(10):
a = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30)))
b = _DTW_RNG.random(int(_DTW_RNG.integers(5, 30)))
expected = dtai.dtw.distance(a, b)
actual = DTW_DISTANCE(a, b)
np.testing.assert_allclose(actual, expected, rtol=1e-9)
def test_path_distance_matches_dtaidistance(self):
"""DTW() path variant: returned distance matches dtaidistance."""
a = _DTW_RNG.random(20)
b = _DTW_RNG.random(25)
expected = dtai.dtw.distance(a, b)
dist, _ = DTW(a, b)
np.testing.assert_allclose(dist, expected, rtol=1e-9)
def test_path_matches_dtaidistance_warping_path(self):
"""Warping path matches dtaidistance.dtw.warping_path() on same-length series."""
for _ in range(10):
n = int(_DTW_RNG.integers(5, 20))
a = _DTW_RNG.random(n)
b = _DTW_RNG.random(n)
expected_path = dtai.dtw.warping_path(a, b)
_, actual_path = DTW(a, b)
actual_pairs = [tuple(int(x) for x in row) for row in actual_path]
assert actual_pairs == expected_path, (
f"Path mismatch for n={n}:\n ours={actual_pairs}\n dtai={expected_path}"
)
def test_window_constrained_matches_dtaidistance(self):
"""Sakoe-Chiba window matches dtaidistance window parameter."""
a = _DTW_RNG.random(30)
b = _DTW_RNG.random(30)
for w in [3, 8, 15]:
expected = dtai.dtw.distance(a, b, window=w)
actual = DTW_DISTANCE(a, b, window=w)
np.testing.assert_allclose(
actual, expected, rtol=1e-9, err_msg=f"Mismatch at window={w}"
)
def test_batch_matches_dtaidistance(self):
"""BATCH_DTW matches calling dtaidistance per-row."""
ref = _DTW_RNG.random(20)
matrix = _DTW_RNG.random((8, 20))
batch_result = BATCH_DTW(matrix, ref)
for i in range(8):
expected = dtai.dtw.distance(matrix[i], ref)
np.testing.assert_allclose(
batch_result[i],
expected,
rtol=1e-9,
err_msg=f"Batch mismatch at row {i}",
)
# --- Mathematical properties ---
def test_identical_distance_is_zero(self):
a = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
dist, _ = DTW(a, a)
assert dist == pytest.approx(0.0, abs=1e-10)
def test_symmetry(self):
a, b = _DTW_RNG.random(20), _DTW_RNG.random(20)
assert DTW_DISTANCE(a, b) == pytest.approx(DTW_DISTANCE(b, a), rel=1e-10)
def test_triangle_inequality(self):
a, b, c = _DTW_RNG.random(15), _DTW_RNG.random(15), _DTW_RNG.random(15)
assert DTW_DISTANCE(a, c) <= DTW_DISTANCE(a, b) + DTW_DISTANCE(b, c) + 1e-9
# --- Known hardcoded values ---
def test_known_shifted_series(self):
# [0,1,2] vs [1,2,3]: optimal path (0,0)→(1,0)→(2,1)→(2,2)
# Squared costs: 1+0+0+1=2, sqrt(2). Verified against dtaidistance.
a = np.array([0.0, 1.0, 2.0])
b = np.array([1.0, 2.0, 3.0])
np.testing.assert_allclose(DTW_DISTANCE(a, b), np.sqrt(2.0), rtol=1e-9)
def test_known_single_element(self):
# sqrt((3-7)^2) = sqrt(16) = 4.0
np.testing.assert_allclose(
DTW_DISTANCE(np.array([3.0]), np.array([7.0])), 4.0, rtol=1e-9
)
def test_known_constant_series(self):
assert DTW_DISTANCE(np.full(10, 5.0), np.full(10, 5.0)) == pytest.approx(
0.0, abs=1e-12
)
# --- Path structural guarantees ---
def test_path_starts_at_origin(self):
_, path = DTW(_DTW_RNG.random(10), _DTW_RNG.random(10))
assert tuple(int(x) for x in path[0]) == (0, 0)
def test_path_ends_at_corner(self):
_, path = DTW(_DTW_RNG.random(7), _DTW_RNG.random(9))
assert tuple(int(x) for x in path[-1]) == (6, 8)
def test_path_is_monotone(self):
_, path = DTW(_DTW_RNG.random(20), _DTW_RNG.random(20))
for k in range(1, len(path)):
assert path[k][0] >= path[k - 1][0]
assert path[k][1] >= path[k - 1][1]
def test_path_steps_unit_size(self):
_, path = DTW(_DTW_RNG.random(15), _DTW_RNG.random(12))
for k in range(1, len(path)):
di = int(path[k][0]) - int(path[k - 1][0])
dj = int(path[k][1]) - int(path[k - 1][1])
assert di in (0, 1) and dj in (0, 1)
assert not (di == 0 and dj == 0)
# --- DTW_DISTANCE == DTW distance ---
def test_distance_only_matches_full(self):
a, b = _DTW_RNG.random(25), _DTW_RNG.random(25)
d_full, _ = DTW(a, b)
np.testing.assert_allclose(DTW_DISTANCE(a, b), d_full, rtol=1e-10)
# --- Batch ---
def test_batch_single_row(self):
ref = np.array([1.0, 2.0, 3.0])
result = BATCH_DTW(np.array([[1.0, 2.0, 3.0]]), ref)
assert result[0] == pytest.approx(0.0, abs=1e-10)
def test_batch_matches_single_calls(self):
ref = _DTW_RNG.random(20)
matrix = _DTW_RNG.random((8, 20))
batch = BATCH_DTW(matrix, ref)
for i in range(8):
np.testing.assert_allclose(
batch[i], DTW_DISTANCE(matrix[i], ref), rtol=1e-10
)
# --- Edge cases ---
def test_empty_series_raises(self):
with pytest.raises((ValueError, Exception)):
DTW(np.array([]), np.array([1.0, 2.0]))
def test_window_constrained_ge_unconstrained(self):
a, b = _DTW_RNG.random(20), _DTW_RNG.random(20)
d_full = DTW_DISTANCE(a, b)
d_narrow = DTW_DISTANCE(a, b, window=2)
assert d_narrow >= d_full - 1e-9
+398
View File
@@ -222,6 +222,404 @@ class TestStrategyAndPayoff:
assert greeks.gamma > 0.0 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: class TestDerivativesBenchmarking:
def test_derivatives_benchmark_smoke(self, tmp_path): def test_derivatives_benchmark_smoke(self, tmp_path):
root = Path(__file__).resolve().parents[2] 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]] [[package]]
name = "ferro-ta" name = "ferro-ta"
version = "1.1.0" version = "1.1.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy" },
@@ -993,6 +993,8 @@ dev = [
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "ruff" }, { 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 = [ docs = [
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { 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 = "pytest" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "ruff" }, { 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" }, { name = "ta" },
] ]
@@ -1067,6 +1071,7 @@ requires-dist = [
{ name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" },
{ name = "quantstats", marker = "extra == 'comparison'", specifier = ">=0.0.81" }, { name = "quantstats", marker = "extra == 'comparison'", specifier = ">=0.0.81" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" }, { 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", marker = "extra == 'docs'", specifier = ">=7.0" },
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.3" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1.3" },
{ name = "ta", marker = "extra == 'comparison'", specifier = ">=0.10" }, { name = "ta", marker = "extra == 'comparison'", specifier = ">=0.10" },
@@ -1089,6 +1094,7 @@ dev = [
{ name = "pytest", specifier = ">=7.0" }, { name = "pytest", specifier = ">=7.0" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "ruff", specifier = ">=0.3" }, { name = "ruff", specifier = ">=0.3" },
{ name = "scipy", specifier = ">=1.15.3" },
{ name = "ta", specifier = ">=0.10" }, { name = "ta", specifier = ">=0.10" },
] ]
@@ -1266,11 +1272,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.18"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -2900,7 +2906,7 @@ wheels = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" version = "9.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -2911,9 +2917,9 @@ dependencies = [
{ name = "pygments" }, { name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tomli", marker = "python_full_version < '3.11'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/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 = [ 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]] [[package]]
@@ -4255,11 +4261,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.7.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
+2 -2
View File
@@ -49,11 +49,11 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "ferro_ta_core" name = "ferro_ta_core"
version = "1.1.1" version = "1.1.4"
[[package]] [[package]]
name = "ferro_ta_wasm" name = "ferro_ta_wasm"
version = "1.1.1" version = "1.1.4"
dependencies = [ dependencies = [
"ferro_ta_core", "ferro_ta_core",
"js-sys", "js-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ferro_ta_wasm" name = "ferro_ta_wasm"
version = "1.1.1" version = "1.1.4"
edition = "2021" edition = "2021"
description = "WebAssembly bindings for ferro-ta technical analysis indicators" description = "WebAssembly bindings for ferro-ta technical analysis indicators"
license = "MIT" license = "MIT"
+62 -110
View File
@@ -1,53 +1,42 @@
# ferro-ta WASM # ferro-ta WASM
WebAssembly bindings for the [ferro-ta](https://github.com/pratikbhadane24/ferro-ta) technical analysis library. WebAssembly bindings for the [ferro-ta](https://github.com/pratikbhadane24/ferro-ta) technical analysis library. Full feature parity with the Python and Rust core packages.
## Install from npm ## Install from npm
Once published, install the Node.js build from npm:
```bash ```bash
npm install ferro-ta-wasm npm install ferro-ta-wasm
``` ```
```javascript ```javascript
const { sma, ema, wma, rsi, adx, mfi, bbands, atr, obv, macd } = require('ferro-ta-wasm'); const ferro = require('ferro-ta-wasm');
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]); const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]);
const smaOut = sma(close, 3); console.log('SMA:', Array.from(ferro.sma(close, 3)));
console.log('SMA:', Array.from(smaOut)); console.log('RSI:', Array.from(ferro.rsi(close, 14)));
``` ```
> **Decision**: We chose WebAssembly (wasm-bindgen / wasm-pack) as the second binding because it runs in ## Available Indicators (200+ exports)
> browsers *and* Node.js without any native addons, and shares zero unsafe FFI surface with the Python
> build. Node.js users get a pure-JS entry point; browser users get the same `.wasm` file.
## Available Indicators | Category | Functions | Examples |
|----------|-----------|----------|
| Category | Function | Parameters | Returns | | Overlap Studies (20) | Moving averages, bands, SAR | `sma`, `ema`, `wma`, `dema`, `tema`, `trima`, `kama`, `t3`, `bbands`, `macd`, `macdfix`, `macdext`, `sar`, `sarext`, `mama`, `midpoint`, `midprice`, `ma`, `mavp`, `hull_ma` |
|------------|---------------|----------------------------------------------------|---------| | Momentum (26) | Oscillators, directional movement | `rsi`, `mom`, `stoch`, `stochf`, `adx`, `adxr`, `dx`, `plus_di`, `minus_di`, `roc`, `willr`, `aroon`, `aroonosc`, `cci`, `bop`, `stochrsi`, `apo`, `ppo`, `cmo`, `trix_indicator`, `ultosc` |
| Overlap | `sma` | `close: Float64Array, timeperiod: number` | `Float64Array` | | Candlestick Patterns (61) | All TA-Lib patterns | `cdlhammer`, `cdlengulfing`, `cdldoji`, `cdlmorningstar`, `cdlshootingstar`, ... (all 61) |
| Overlap | `ema` | `close: Float64Array, timeperiod: number` | `Float64Array` | | Volatility (3) | True range, ATR | `atr`, `natr`, `trange` |
| Overlap | `wma` | `close: Float64Array, timeperiod: number` | `Float64Array` | | Volume (6) | On-balance volume, accumulation | `obv`, `mfi`, `vwap`, `vwma`, `ad`, `adosc` |
| Overlap | `bbands` | `close, timeperiod, nbdevup, nbdevdn` | `Array[upper, middle, lower]` | | Price Transforms (4) | Synthetic prices | `avgprice`, `medprice`, `typprice`, `wclprice` |
| Momentum | `rsi` | `close: Float64Array, timeperiod: number` | `Float64Array` | | Cycle / Hilbert (6) | Hilbert Transform suite | `ht_trendline`, `ht_dcperiod`, `ht_dcphase`, `ht_phasor`, `ht_sine`, `ht_trendmode` |
| Momentum | `adx` | `high, low, close: Float64Array, timeperiod` | `Float64Array` | | Statistics (10) | Regression, correlation | `stddev`, `var`, `linearreg`, `linearreg_slope`, `linearreg_intercept`, `linearreg_angle`, `tsf`, `beta_rolling`, `correl` |
| Momentum | `macd` | `close, fastperiod, slowperiod, signalperiod` | `Array[macd, signal, hist]` | | Math (19) | Operators and transforms | `math_add`, `math_sub`, `math_mult`, `math_div`, `transform_sin`, `transform_cos`, `transform_exp`, `transform_sqrt`, ... |
| Momentum | `mom` | `close: Float64Array, timeperiod: number` | `Float64Array` | | Extended (10) | Supertrend, channels, Ichimoku | `supertrend`, `donchian`, `keltner_channels`, `ichimoku`, `pivot_points`, `chandelier_exit`, `choppiness_index` |
| Momentum | `stochf` | `high, low, close, fastk_period, fastd_period` | `Array[fastk, fastd]` | | Streaming API (9 classes) | Bar-by-bar stateful | `WasmStreamingSMA`, `WasmStreamingEMA`, `WasmStreamingRSI`, `WasmStreamingATR`, `WasmStreamingBBands`, `WasmStreamingMACD`, `WasmStreamingStoch`, `WasmStreamingVWAP`, `WasmStreamingSupertrend` |
| Volatility | `atr` | `high, low, close: Float64Array, timeperiod` | `Float64Array` | | Options (14) | Pricing, Greeks, IV | `black_scholes_price`, `black_76_price`, `black_scholes_greeks`, `implied_volatility`, `iv_rank`, `smile_metrics`, ... |
| Volume | `obv` | `close: Float64Array, volume: Float64Array` | `Float64Array` | | Futures (12) | Basis, roll, curve | `futures_basis`, `annualized_basis`, `roll_yield`, `weighted_continuous`, `calendar_spreads`, `curve_summary`, ... |
| Volume | `mfi` | `high, low, close, volume: Float64Array, timeperiod` | `Float64Array` | | Backtesting (9) | Signal generation, engines | `backtest_core`, `backtest_ohlcv`, `rsi_threshold_signals`, `macd_crossover_signals`, `walk_forward_indices`, `monte_carlo_bootstrap`, ... |
| Alerts & Regime (7) | Signals and regime detection | `check_threshold`, `check_cross`, `regime_adx`, `regime_combined`, `detect_breaks_cusum` |
### Adding more indicators | Batch & Portfolio (9) | Multi-asset analytics | `batch_sma`, `batch_ema`, `batch_rsi`, `correlation_matrix`, `portfolio_volatility`, `drawdown_series` |
| Aggregation (8) | Tick/volume/time bars | `aggregate_tick_bars`, `aggregate_volume_bars_ticks`, `volume_bars`, `ohlcv_agg` |
WASM exports live in `src/lib.rs` and can either implement logic directly or delegate to `ferro_ta_core`.
To add a new indicator:
1. Add a `#[wasm_bindgen]` export in `src/lib.rs` (prefer delegating to `ferro_ta_core` where possible).
2. Add at least two `#[wasm_bindgen_test]` tests covering output length and a known value.
3. Update this README table.
4. Run `wasm-pack test --node` to verify.
## Prerequisites ## Prerequisites
@@ -57,91 +46,64 @@ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install wasm-pack # Install wasm-pack
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
# OR via cargo:
cargo install wasm-pack
``` ```
## Build ## Build
```bash ```bash
cd wasm/ cd wasm/
wasm-pack build --target nodejs --out-dir pkg
# Build both Node.js and web targets
npm run build
# Or build individually:
npm run build:node # → node/
npm run build:web # → web/
``` ```
This produces a `pkg/` directory containing: This produces two directories:
- `ferro_ta_wasm.js` — JavaScript glue code - `node/` -- CommonJS glue for Node.js (`require()`)
- `ferro_ta_wasm_bg.wasm` — compiled WebAssembly binary - `web/` -- ESM glue for browsers and web workers (`import`)
- `ferro_ta_wasm.d.ts` — TypeScript declarations
For a browser build: Both contain `ferro_ta_wasm.js`, `ferro_ta_wasm_bg.wasm`, and `ferro_ta_wasm.d.ts`.
```bash
wasm-pack build --target web --out-dir pkg-web
```
## Usage (Node.js) ## Usage (Node.js)
```javascript ```javascript
const { sma, ema, wma, rsi, adx, mfi, bbands, atr, obv, macd } = require('./pkg/ferro_ta_wasm.js'); const {
sma, ema, rsi, bbands, macd, atr, adx, obv, mfi,
cdlhammer, cdlengulfing,
WasmStreamingSMA,
} = require('ferro-ta-wasm');
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]); const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10]);
const high = new Float64Array([45.0, 46.0, 47.0, 46.0, 45.0, 44.0, 45.0]);
const low = new Float64Array([43.0, 44.0, 45.0, 44.0, 43.0, 42.0, 43.0]);
// Simple Moving Average (period 3) // Indicators
const smaOut = sma(close, 3); console.log('SMA:', Array.from(sma(close, 3)));
console.log('SMA:', Array.from(smaOut)); // [ NaN, NaN, 44.193, ... ] console.log('RSI:', Array.from(rsi(close, 5)));
// RSI (period 5) // Multi-output
const rsiOut = rsi(close, 5);
console.log('RSI:', Array.from(rsiOut));
// WMA (period 5)
const wmaOut = wma(close, 5);
console.log('WMA:', Array.from(wmaOut));
// Bollinger Bands (period 5, ±2σ) — returns [upper, middle, lower]
const [upper, middle, lower] = bbands(close, 5, 2.0, 2.0); const [upper, middle, lower] = bbands(close, 5, 2.0, 2.0);
console.log('BBANDS upper:', Array.from(upper)); const [macdLine, signal, hist] = macd(close, 3, 5, 2);
// MACD (fast=3, slow=5, signal=2) — returns [macd_line, signal_line, histogram] // Streaming (bar-by-bar)
const [macdLine, signalLine, histogram] = macd(close, 3, 5, 2); const stream = new WasmStreamingSMA(3);
console.log('MACD:', Array.from(macdLine)); for (const price of close) {
console.log('Signal:', Array.from(signalLine)); console.log('streaming SMA:', stream.update(price));
console.log('Histogram:', Array.from(histogram)); }
// ATR (period 3)
const high = new Float64Array([45.0, 46.0, 47.0, 46.0, 45.0, 44.0, 45.0]);
const low = new Float64Array([43.0, 44.0, 45.0, 44.0, 43.0, 42.0, 43.0]);
const atrOut = atr(high, low, close, 3);
console.log('ATR:', Array.from(atrOut));
// ADX (period 3)
const adxOut = adx(high, low, close, 3);
console.log('ADX:', Array.from(adxOut));
// OBV
const volume = new Float64Array([1000, 1200, 900, 1500, 800, 600, 700]);
const obvOut = obv(close, volume);
console.log('OBV:', Array.from(obvOut));
// MFI (period 3)
const mfiOut = mfi(high, low, close, volume, 3);
console.log('MFI:', Array.from(mfiOut));
``` ```
## Usage (Browser) ## Usage (Browser)
```html ```html
<script type="module"> <script type="module">
import init, { sma, macd } from './pkg-web/ferro_ta_wasm.js'; import init, { sma, rsi, macd } from './pkg-web/ferro_ta_wasm.js';
await init(); // loads the .wasm binary await init();
const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33]); const close = new Float64Array([44.34, 44.09, 44.15, 43.61, 44.33]);
const smaOut = sma(close, 3); console.log('SMA:', Array.from(sma(close, 3)));
console.log('SMA:', Array.from(smaOut));
// MACD
const [macdLine, signal, hist] = macd(close, 3, 5, 2);
console.log('MACD line:', Array.from(macdLine));
</script> </script>
``` ```
@@ -152,22 +114,12 @@ cd wasm/
wasm-pack test --node wasm-pack test --node
``` ```
## CI Artifact
Every CI run on `main` builds the WASM package and uploads it as a GitHub Actions
artifact named `wasm-pkg`. To download the latest pre-built package without building
from source:
1. Go to the [Actions tab](https://github.com/pratikbhadane24/ferro-ta/actions).
2. Open the latest successful CI run.
3. Download the `wasm-pkg` artifact from the **Artifacts** section.
4. Unzip and use `pkg/ferro_ta_wasm.js` directly in your project.
## Limitations ## Limitations
- Only 12 indicators are currently exposed (SMA, EMA, WMA, BBANDS, RSI, ADX, MACD, MOM, STOCHF, ATR, OBV, MFI). - Large arrays (> 10M bars) may be slow due to JS-WASM memory copies. For high-throughput use cases prefer the Python (PyO3) binding.
Additional indicators will be added following the same pattern in `src/lib.rs`. - WASM does not support multi-threading natively in browsers (SharedArrayBuffer requires COOP/COEP headers).
- Large arrays (> 10M bars) may be slow due to JS↔WASM memory copies. For high-throughput - The npm package ships both Node.js (`require`) and browser/web worker (`import`) builds. Conditional exports in `package.json` select the right one automatically.
use cases prefer the Python (PyO3) binding.
- WASM does not support multi-threading natively in browsers (SharedArrayBuffer requires ## License
COOP/COEP headers).
MIT
+1 -1
View File
@@ -2,7 +2,7 @@ const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { performance } = require("node:perf_hooks"); const { performance } = require("node:perf_hooks");
const wasm = require("./pkg/ferro_ta_wasm.js"); const wasm = require("./node/ferro_ta_wasm.js");
function parseArgs(argv) { function parseArgs(argv) {
const args = { bars: 100000, json: null }; const args = { bars: 100000, json: null };
+21 -6
View File
@@ -1,14 +1,29 @@
{ {
"name": "ferro-ta-wasm", "name": "ferro-ta-wasm",
"version": "1.1.1", "version": "1.1.4",
"description": "WebAssembly bindings for ferro-ta technical analysis indicators", "description": "WebAssembly bindings for ferro-ta technical analysis indicators",
"main": "pkg/ferro_ta_wasm.js", "main": "node/ferro_ta_wasm.js",
"types": "pkg/ferro_ta_wasm.d.ts", "module": "web/ferro_ta_wasm.js",
"files": ["pkg"], "types": "node/ferro_ta_wasm.d.ts",
"exports": {
".": {
"node": {
"types": "./node/ferro_ta_wasm.d.ts",
"require": "./node/ferro_ta_wasm.js"
},
"default": {
"types": "./web/ferro_ta_wasm.d.ts",
"import": "./web/ferro_ta_wasm.js"
}
}
},
"files": ["node", "web"],
"scripts": { "scripts": {
"build": "wasm-pack build --target nodejs --out-dir pkg", "build": "npm run build:node && npm run build:web",
"build:node": "wasm-pack build --target nodejs --out-dir node && node -e \"require('fs').rmSync('node/.gitignore', { force: true }); require('fs').rmSync('node/package.json', { force: true });\"",
"build:web": "wasm-pack build --target web --out-dir web && node -e \"require('fs').rmSync('web/.gitignore', { force: true }); require('fs').rmSync('web/package.json', { force: true });\"",
"bench": "node bench.js", "bench": "node bench.js",
"prepack": "npm run build && node -e \"require('fs').rmSync('pkg/.gitignore', { force: true })\"", "prepack": "npm run build",
"test": "wasm-pack test --node" "test": "wasm-pack test --node"
}, },
"license": "MIT", "license": "MIT",
+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)) from_vec(ferro_ta_core::statistic::correl(&to_vec(real0), &to_vec(real1), timeperiod))
} }
/// Dynamic Time Warping distance between two series.
///
/// Returns the accumulated Euclidean cost along the optimal warping path.
/// Pass `window` as `0` for unconstrained (no Sakoe-Chiba band).
#[wasm_bindgen]
pub fn dtw_distance(series1: &Float64Array, series2: &Float64Array, window: usize) -> f64 {
let s1 = to_vec(series1);
let s2 = to_vec(series2);
let w = if window == 0 { None } else { Some(window) };
ferro_ta_core::statistic::dtw_distance(&s1, &s2, w)
}
// =========================================================================== // ===========================================================================
// Streaming / Stateful API // Streaming / Stateful API
// =========================================================================== // ===========================================================================
@@ -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`) // WASM tests (run with `wasm-pack test --node`)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------