Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13cb0dc95a | |||
| 0382c4e302 | |||
| 7736effc27 | |||
| 9ec3bc3f7a | |||
| a055b4cf89 | |||
| ee6a0b3570 | |||
| 602d675749 | |||
| 2d5000262f | |||
| 9011250f99 | |||
| ba8019ad27 |
+139
-13
@@ -130,32 +130,108 @@ jobs:
|
||||
# Keep release jobs here because trusted-publisher configuration points to
|
||||
# CI.yml specifically.
|
||||
# -------------------------------------------------------------------------
|
||||
build-wheels:
|
||||
name: Build wheels (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
build-wheels-linux:
|
||||
name: Build wheels (linux / py${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build wheels
|
||||
- name: Build wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: build
|
||||
args: --release --out dist
|
||||
manylinux: auto
|
||||
# Build for both Intel and Apple Silicon on macOS
|
||||
target: ${{ matrix.os == 'macos-latest' && 'universal2-apple-darwin' || '' }}
|
||||
args: --release --out dist --compatibility pypi -i python${{ matrix.python-version }}
|
||||
manylinux: "2_17"
|
||||
|
||||
- name: Upload wheels as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wheels-${{ matrix.os }}
|
||||
path: dist
|
||||
name: wheels-linux-py${{ matrix.python-version }}
|
||||
path: dist/*.whl
|
||||
|
||||
build-wheels-macos:
|
||||
name: Build wheels (macos / py${{ matrix.python-version }})
|
||||
runs-on: macos-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Build universal2 wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: build
|
||||
args: --release --out dist --compatibility pypi -i python
|
||||
target: universal2-apple-darwin
|
||||
|
||||
- name: Upload wheels as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wheels-macos-py${{ matrix.python-version }}
|
||||
path: dist/*.whl
|
||||
|
||||
build-wheels-windows:
|
||||
name: Build wheels (windows / py${{ matrix.python-version }})
|
||||
runs-on: windows-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Build wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: build
|
||||
args: --release --out dist --compatibility pypi -i python
|
||||
|
||||
- name: Upload wheels as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wheels-windows-py${{ matrix.python-version }}
|
||||
path: dist/*.whl
|
||||
|
||||
build-sdist:
|
||||
name: Build source distribution
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: --out dist
|
||||
|
||||
- name: Upload sdist as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sdist
|
||||
path: dist/*.tar.gz
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Publish to PyPI
|
||||
@@ -163,7 +239,11 @@ jobs:
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheels
|
||||
needs:
|
||||
- build-wheels-linux
|
||||
- build-wheels-macos
|
||||
- build-wheels-windows
|
||||
- build-sdist
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
environment:
|
||||
name: pypi
|
||||
@@ -178,6 +258,52 @@ jobs:
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
|
||||
- name: Download source distribution
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: sdist
|
||||
path: dist
|
||||
|
||||
- name: Verify distribution coverage
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import fnmatch
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
dist = Path("dist")
|
||||
files = sorted(p.name for p in dist.iterdir() if p.is_file())
|
||||
print("Distributions:")
|
||||
for name in files:
|
||||
print(f" - {name}")
|
||||
|
||||
expected = [
|
||||
"ferro_ta-*-cp310-cp310-manylinux*_x86_64.whl",
|
||||
"ferro_ta-*-cp311-cp311-manylinux*_x86_64.whl",
|
||||
"ferro_ta-*-cp312-cp312-manylinux*_x86_64.whl",
|
||||
"ferro_ta-*-cp313-cp313-manylinux*_x86_64.whl",
|
||||
"ferro_ta-*-cp310-cp310-win_amd64.whl",
|
||||
"ferro_ta-*-cp311-cp311-win_amd64.whl",
|
||||
"ferro_ta-*-cp312-cp312-win_amd64.whl",
|
||||
"ferro_ta-*-cp313-cp313-win_amd64.whl",
|
||||
"ferro_ta-*-cp310-cp310-macosx*_universal2.whl",
|
||||
"ferro_ta-*-cp311-cp311-macosx*_universal2.whl",
|
||||
"ferro_ta-*-cp312-cp312-macosx*_universal2.whl",
|
||||
"ferro_ta-*-cp313-cp313-macosx*_universal2.whl",
|
||||
"ferro_ta-*.tar.gz",
|
||||
]
|
||||
|
||||
missing = [
|
||||
pattern for pattern in expected
|
||||
if not any(fnmatch.fnmatch(name, pattern) for name in files)
|
||||
]
|
||||
if missing:
|
||||
print("Missing expected distributions:")
|
||||
for pattern in missing:
|
||||
print(f" - {pattern}")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
@@ -207,7 +333,7 @@ jobs:
|
||||
sbom:
|
||||
name: Generate SBOM (Python + Rust)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheels
|
||||
needs: publish
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -125,3 +125,44 @@ jobs:
|
||||
with:
|
||||
name: benchmark-vs-talib
|
||||
path: benchmark_vs_talib.json
|
||||
|
||||
perf-smoke:
|
||||
name: Performance smoke and contracts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install maturin and perf dependencies
|
||||
run: |
|
||||
pip install maturin numpy pytest
|
||||
|
||||
- name: Build and install ferro_ta
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Generate reproducible perf artifacts
|
||||
run: |
|
||||
python benchmarks/run_perf_contract.py \
|
||||
--output-dir perf-contract \
|
||||
--skip-talib \
|
||||
--skip-simd \
|
||||
--batch-samples 20000 \
|
||||
--batch-series 32 \
|
||||
--streaming-bars 20000 \
|
||||
--price-bars 20000 \
|
||||
--iv-bars 50000
|
||||
|
||||
- name: Enforce hotspot regression policy
|
||||
run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
|
||||
|
||||
- name: Upload perf artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: perf-contract
|
||||
path: perf-contract/
|
||||
|
||||
@@ -13,6 +13,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
@@ -30,8 +35,18 @@ jobs:
|
||||
working-directory: wasm
|
||||
run: wasm-pack build --target nodejs --out-dir pkg
|
||||
|
||||
- name: Benchmark WASM package
|
||||
working-directory: wasm
|
||||
run: node bench.js --json ../wasm_benchmark.json
|
||||
|
||||
- name: Upload WASM package artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-pkg
|
||||
path: wasm/pkg/
|
||||
|
||||
- name: Upload WASM benchmark artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-benchmark
|
||||
path: wasm_benchmark.json
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Release
|
||||
|
||||
# Triggered when a version tag is pushed (e.g. v1.0.2).
|
||||
# Creates a GitHub Release marked as "published", which in turn
|
||||
# triggers the build-wheels and publish jobs in CI.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract changelog entry for this version
|
||||
id: changelog
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import re, os, pathlib
|
||||
|
||||
version = os.environ["TAG_NAME"].lstrip("v")
|
||||
text = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
|
||||
pattern = rf"## \[{re.escape(version)}\].*?\n(.*?)(?=\n## |\Z)"
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
body = match.group(1).strip() if match else f"Release {version}"
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
||||
f.write(f"body<<EOF\n{body}\nEOF\n")
|
||||
PY
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: v${{ steps.version.outputs.version }}
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
+45
-1
@@ -9,6 +9,48 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.3] — 2026-03-24
|
||||
|
||||
### Added
|
||||
|
||||
- Public package metadata helpers: `ferro_ta.__version__`, `ferro_ta.about()`,
|
||||
and `ferro_ta.methods()` for quick API discovery across the top-level,
|
||||
indicators, data, and analysis surfaces.
|
||||
- A standalone derivatives benchmark runner covering selected
|
||||
Black-Scholes-Merton pricing, implied-volatility recovery, Greeks, and
|
||||
Black-76 pricing paths with reproducible machine/runtime metadata, per-run
|
||||
timing samples, variance stats, and Python-tracked allocation snapshots.
|
||||
- A one-command version bump helper, `scripts/bump_version.py`, plus `make version
|
||||
VERSION=X.Y.Z` for aligned Cargo, Python, WASM, Conda, and docs release
|
||||
surfaces.
|
||||
|
||||
### Changed
|
||||
|
||||
- Tightened the homepage and docs product narrative so the core Rust-backed
|
||||
Python TA library leads, while adjacent tooling is called out separately.
|
||||
- Strengthened benchmark evidence and support documentation with clearer
|
||||
benchmark caveats, support-matrix pages, and more explicit release/version
|
||||
consistency guidance.
|
||||
|
||||
## [1.0.2] — 2026-03-24
|
||||
|
||||
### Performance
|
||||
|
||||
- Optimized rolling statistical kernels (`CORREL`, `BETA`, `LINEARREG*`, `TSF`)
|
||||
with incremental window math and matching warmup semantics.
|
||||
- Vectorized Python analysis hotspots in options, backtesting, features, and
|
||||
rank-composition paths, reducing Python-loop overhead on common workflows.
|
||||
- Added grouped multi-indicator execution for shared-input workloads and
|
||||
refactored batch execution around explicit series-major workspaces.
|
||||
|
||||
### Added
|
||||
|
||||
- Reproducible perf-contract artifacts for single-series, batch, streaming,
|
||||
SIMD, TA-Lib comparison, and WASM benchmark runs.
|
||||
- Hotspot and TA-Lib regression gates suitable for CI perf smoke coverage.
|
||||
- Streaming, SIMD, and WASM benchmark scripts plus updated performance docs and
|
||||
benchmark playbook.
|
||||
|
||||
## [1.0.1] — 2026-03-24
|
||||
|
||||
### Added
|
||||
@@ -274,6 +316,8 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.1...HEAD
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.3...HEAD
|
||||
[1.0.3]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.2...v1.0.3
|
||||
[1.0.2]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.1...v1.0.2
|
||||
[1.0.1]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.0...v1.0.1
|
||||
[1.0.0]: https://github.com/pratikbhadane24/ferro-ta/releases/tag/v1.0.0
|
||||
|
||||
Generated
+2
-2
@@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ferro_ta_core",
|
||||
@@ -222,7 +222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"wide",
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "ferro_ta"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
@@ -23,7 +23,7 @@ ndarray = "0.16"
|
||||
rayon = "1.10"
|
||||
log = "0.4"
|
||||
pyo3-log = "0.12"
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.1" }
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.3" }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ferro-ta development Makefile
|
||||
# Usage: make <target>
|
||||
|
||||
.PHONY: help dev build test lint typecheck fmt docs clean bench
|
||||
.PHONY: help dev build test lint typecheck fmt docs clean bench version
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -15,6 +15,7 @@ help:
|
||||
@echo " make typecheck Run mypy + pyright type checkers"
|
||||
@echo " make docs Build the Sphinx documentation"
|
||||
@echo " make bench Run Rust criterion benchmarks (ferro_ta_core)"
|
||||
@echo " make version Bump tracked version strings (set VERSION=X.Y.Z)"
|
||||
@echo " make audit Run cargo-audit + pip-audit"
|
||||
@echo " make clean Remove build artefacts"
|
||||
|
||||
@@ -48,6 +49,10 @@ docs:
|
||||
bench:
|
||||
cargo bench -p ferro_ta_core
|
||||
|
||||
version:
|
||||
@test -n "$(VERSION)" || (echo "Usage: make version VERSION=X.Y.Z" && exit 1)
|
||||
python3 scripts/bump_version.py "$(VERSION)"
|
||||
|
||||
audit:
|
||||
cargo audit
|
||||
uv run --with pip-audit pip-audit
|
||||
|
||||
@@ -5,6 +5,15 @@ This document describes how ferro-ta is packaged and published.
|
||||
## PyPI (pip)
|
||||
|
||||
Wheels are built by CI on release (see [RELEASE.md](RELEASE.md)).
|
||||
Release publishing currently targets CPython 3.10, 3.11, 3.12, and 3.13 on:
|
||||
|
||||
- Linux x86_64 (`manylinux_2_17` / `manylinux2014`)
|
||||
- macOS universal2 (covers Intel and Apple Silicon)
|
||||
- Windows x86_64
|
||||
|
||||
Each release also publishes a source distribution (`sdist`) so compatible
|
||||
environments outside the wheel matrix can still build from source.
|
||||
|
||||
Publishing uses PyPI Trusted Publishing via GitHub OIDC; no long-lived PyPI API
|
||||
token is required.
|
||||
|
||||
|
||||
+7
-7
@@ -20,13 +20,14 @@ Pre-compiled wheels are published to PyPI for the following targets:
|
||||
|
||||
| OS | Architecture | Notes |
|
||||
|---------|-----------------|-------|
|
||||
| Linux | x86_64 (manylinux2014 / `manylinux_2_17`) | Default CI runner |
|
||||
| Linux | aarch64 | Built via maturin cross-compilation |
|
||||
| macOS | x86_64 | Intel |
|
||||
| macOS | arm64 | Apple Silicon |
|
||||
| macOS | universal2 | Intel + Apple Silicon fat binary |
|
||||
| Linux | x86_64 (manylinux2014 / `manylinux_2_17`) | Pre-compiled wheel |
|
||||
| macOS | universal2 | One wheel covers Intel + Apple Silicon |
|
||||
| Windows | x86_64 | |
|
||||
|
||||
Wheel releases target CPython 3.10, 3.11, 3.12, and 3.13. A source
|
||||
distribution is also published so other compatible environments can build from
|
||||
source.
|
||||
|
||||
> **Note:** Python 3.14+ is not yet tested. Set
|
||||
> `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1` to attempt a build on a newer
|
||||
> interpreter and report any issues.
|
||||
@@ -39,8 +40,7 @@ Pre-compiled wheels are published to PyPI for the following targets:
|
||||
pip install ferro-ta
|
||||
```
|
||||
|
||||
No C-compiler required — pre-compiled wheels are available for all platforms
|
||||
listed above.
|
||||
No C-compiler required on the wheel targets listed above.
|
||||
|
||||
### conda / conda-forge
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
# ⚡ ferro-ta
|
||||
|
||||
### The Python Technical Analysis Library That Beats TA-Lib — Everywhere
|
||||
### Rust-powered Python technical analysis with a TA-Lib-compatible API
|
||||
|
||||
**Powered by Rust. Driven by O(n) algorithms. Designed for the speed that modern quantitative trading demands.**
|
||||
**Focused on one primary job: fast, reproducible technical analysis for Python users who want TA-Lib-style ergonomics without native build friction.**
|
||||
|
||||
[](https://mybinder.org/v2/gh/pratikbhadane24/ferro-ta/HEAD?labpath=examples%2Fquickstart.ipynb)
|
||||
[](https://colab.research.google.com/github/pratikbhadane24/ferro-ta/blob/main/examples/quickstart.ipynb)
|
||||
@@ -14,95 +14,79 @@
|
||||
|
||||
---
|
||||
|
||||
> **"Same API as TA-Lib. 3–5× faster. No C compiler needed. Drop it in today."**
|
||||
|
||||
ferro-ta is a **Rust-powered, PyO3-compiled** technical analysis library that replaces TA-Lib with a pure-Rust core that runs **3× to 5× faster** on every major indicator. It runs as a pre-compiled Python wheel — no C toolchain, no system dependencies, no compilation headaches.
|
||||
> `ferro-ta` is a Rust-backed Python technical analysis library with a TA-Lib-compatible API for NumPy-first workloads.
|
||||
>
|
||||
> Performance varies by indicator, array layout, warmup, build flags, and machine. Public checked-in runs show `ferro-ta` is often faster on selected indicators, while TA-Lib still wins or ties on others. The benchmark workflow, artifacts, and caveats are published in [`benchmarks/README.md`](benchmarks/README.md).
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Why ferro-ta?
|
||||
## 🚀 What ferro-ta is
|
||||
|
||||
| | TA-Lib | ferro-ta |
|
||||
|---|---|---|
|
||||
| **Speed** | C extension, O(n×period) for STOCH/etc. | Rust + O(n) algorithms for most indicators |
|
||||
| **Installation** | Requires C compiler + system libs | `pip install ferro-ta` — zero deps |
|
||||
| **Platforms** | Linux-only on many CI systems | Windows / macOS (Intel + M-series) / Linux |
|
||||
| **API** | `talib.SMA(close, 20)` | `ferro_ta.SMA(close, 20)` — identical |
|
||||
| **Extra indicators** | — | VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, and 10 more |
|
||||
| **Streaming API** | — | Bar-by-bar stateful classes |
|
||||
| **GPU acceleration** | — | Optional PyTorch backend (CUDA / MPS) |
|
||||
| **WebAssembly** | — | Node.js / Browser via WASM |
|
||||
| **Type stubs** | — | Full `.pyi` + `py.typed` (PEP 561) |
|
||||
| **Primary product** | C-backed Python TA library | Rust-backed Python TA library |
|
||||
| **API shape** | `talib.SMA(close, 20)` | `ferro_ta.SMA(close, 20)` |
|
||||
| **Installation** | Often requires native/system setup | Pre-built wheels on supported targets |
|
||||
| **Performance claim** | Established baseline | Often faster on selected indicators; see reproducible benchmarks |
|
||||
| **Scope** | Technical indicators | Technical indicators first; other tooling is optional and secondary |
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance vs TA-Lib
|
||||
## ⚡ Benchmark evidence
|
||||
|
||||
ferro-ta is optimized for high throughput and often competitive with TA-Lib, thanks to:
|
||||
- **O(n) sliding max/min** (monotonic deque) for STOCH — was O(n×period) in TA-Lib
|
||||
- **Fused TR loop** for ATR — no intermediate allocation, single pass
|
||||
- **Branchless gain/loss** for RSI — `diff.max(0.0)` instead of `if/else`
|
||||
- **O(n) rolling operators** for SMA/WMA/BBANDS — sliding window accumulators
|
||||
- **Fused fast+slow EMA loop** for MACD — single pass for both EMAs
|
||||
- **Zero-copy NumPy bridging** — input arrays read directly from buffer without copying
|
||||
The latest checked-in TA-Lib comparison artifact uses contiguous `float64`
|
||||
arrays at 10k and 100k bars on an `Apple M3 Max`, `CPython 3.13.5`, `Rust
|
||||
1.91.1`, default release profile (`lto = true`, `codegen-units = 1`), with no
|
||||
extra `RUSTFLAGS`:
|
||||
|
||||
### 🏆 Reproducible benchmark workflow
|
||||
- `ferro-ta` is ahead outside the tie band on 6 of 12 indicators at 10k bars and 6 of 12 at 100k bars.
|
||||
- At 100k bars, the stronger public wins are `SMA` (`2.28x`), `BBANDS` (`2.34x`), `MACD` (`1.38x`), `MFI` (`3.04x`), and `WMA` (`2.39x`).
|
||||
- TA-Lib still wins on `STOCH` and `ADX` in the current checked-in 10k and 100k runs, and still wins or ties on `EMA`, `RSI`, `ATR`, and `OBV`.
|
||||
- The published JSON now includes per-run samples, variance stats, and Python-tracked allocation snapshots, not just a single median.
|
||||
|
||||
We publish benchmark methodology and generated tables in [`benchmarks/README.md`](benchmarks/README.md).
|
||||
The point of the benchmark suite is not to claim universal wins. It is to let readers reproduce the results, inspect the raw artifact, and see where each library is stronger.
|
||||
|
||||
- Cross-library speed suite (62 indicators × available libraries): `benchmarks/test_speed.py`
|
||||
- Head-to-head TA-Lib comparison: `benchmarks/bench_vs_talib.py`
|
||||
- Table generation from `results.json`: `benchmarks/benchmark_table.py`
|
||||
### 🏆 Reproduce the public comparison
|
||||
|
||||
- Methodology, artifact format, and result tables: [`benchmarks/README.md`](benchmarks/README.md)
|
||||
- Latest checked-in artifact bundle: [`benchmarks/artifacts/latest/`](benchmarks/artifacts/latest/)
|
||||
- TA-Lib head-to-head script: `benchmarks/bench_vs_talib.py`
|
||||
- Cross-library suite: `benchmarks/test_speed.py`
|
||||
|
||||
```bash
|
||||
# Reproduce these numbers yourself
|
||||
# Reproduce the TA-Lib comparison yourself
|
||||
pip install ferro-ta ta-lib
|
||||
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
# or with uv:
|
||||
|
||||
# or with uv
|
||||
uv run python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
uv run python benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
|
||||
|
||||
# full cross-library speed suite (100k bars):
|
||||
# full cross-library speed suite (100k bars)
|
||||
uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v
|
||||
# generate markdown table from results:
|
||||
|
||||
# generate the comparison table from results.json
|
||||
uv run python benchmarks/benchmark_table.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Features
|
||||
## 🎯 Core capabilities
|
||||
|
||||
- **No C-compiler required** — pre-compiled wheels for Windows, macOS (Intel & Apple Silicon), and Linux
|
||||
- **Drop-in API** compatible with TA-Lib (`SMA`, `EMA`, `RSI`, `MACD`, `BBANDS`, and 155+ more)
|
||||
- **Extended Indicators** beyond TA-Lib: `VWAP`, `SUPERTREND`, `ICHIMOKU`, `DONCHIAN`, `PIVOT_POINTS`, `KELTNER_CHANNELS`, `HULL_MA`, `CHANDELIER_EXIT`, `VWMA`, `CHOPPINESS_INDEX`
|
||||
- **Streaming / Live-Trading API** — bar-by-bar stateful classes (`StreamingSMA`, `StreamingRSI`, etc.)
|
||||
- **NumPy integration** — accepts and returns NumPy arrays; reads input buffers without copying data
|
||||
- **Pandas integration** — transparently accepts `pandas.Series` / `DataFrame` and returns `Series` with original index preserved
|
||||
- **Polars integration** — transparently accepts `polars.Series` and returns `polars.Series`; install with `pip install "ferro-ta[polars]"`
|
||||
- **Indicator pipeline** — compose multiple indicators into a reusable pipeline (`ferro_ta.pipeline.Pipeline`)
|
||||
- **Configuration defaults** — set global parameter defaults, per-indicator overrides, and temporary scopes (`ferro_ta.config`)
|
||||
- **Optional GPU backend** — pass a PyTorch tensor to `ferro_ta.gpu.sma/ema/rsi` and get a tensor back (CUDA or MPS); install with `pip install "ferro-ta[gpu]"`
|
||||
- **Type stubs** (`.pyi`) + `py.typed` (PEP 561) for IDE auto-completion and `mypy`/`pyright` support
|
||||
- **WebAssembly binding** — use ferro-ta in Node.js or the browser via `wasm/` (SMA, EMA, BBANDS, RSI, ATR, OBV, MACD, MOM, STOCHF)
|
||||
- **Backtesting utilities** — minimal vectorized backtester (`ferro_ta.backtest`) with RSI, SMA crossover, and MACD crossover strategies; optional commission and slippage
|
||||
- **Plugin registry** — register and run custom or built-in indicators by name (`ferro_ta.registry`)
|
||||
- **Error model** — custom exception hierarchy (`FerroTAError`, `FerroTAValueError`, `FerroTAInputError`) with input validation helpers
|
||||
- **Sphinx documentation** in `docs/` and Jupyter notebook examples in `examples/`
|
||||
- **OHLCV resampling** — time-based and volume-bar resampling, multi-timeframe API (`ferro_ta.resampling`)
|
||||
- **Tick aggregation** — tick/volume/time bar builders from raw trades (`ferro_ta.aggregation`)
|
||||
- **Strategy DSL** — expression-based strategy evaluation (`ferro_ta.dsl`)
|
||||
- **Signal composition** — weighted/rank composite scores and screening (`ferro_ta.signals`)
|
||||
- **Portfolio analytics** — correlation, volatility, beta, drawdown (`ferro_ta.portfolio`)
|
||||
- **Cross-asset analytics** — relative strength, spread, Z-score, rolling beta (`ferro_ta.cross_asset`)
|
||||
- **Feature matrix** — multi-indicator DataFrame for ML pipelines (`ferro_ta.features`)
|
||||
- **Charting API** — matplotlib and plotly charts with indicator subplots (`ferro_ta.viz`)
|
||||
- **Data adapters** — pluggable adapter interface with CSV and in-memory implementations (`ferro_ta.adapters`)
|
||||
- **Options/IV helpers** — IV rank, IV percentile, IV z-score on any IV series (`ferro_ta.options`)
|
||||
- **Agentic tools** — stable LangChain/agent tool wrappers (`ferro_ta.tools`), end-to-end workflow orchestrator (`ferro_ta.workflow`)
|
||||
- **MCP server** — Model Context Protocol server for Cursor/Claude integration; run with `python -m ferro_ta.mcp`
|
||||
- **Observability / Logging** — `ferro_ta.enable_debug()`, `ferro_ta.log_call()`, `ferro_ta.benchmark()` and `ferro_ta.traced()` decorator for instrumentation
|
||||
- **API discovery** — `ferro_ta.indicators(category=None)` lists all 160+ indicators with metadata; `ferro_ta.info(func)` returns full parameter docs
|
||||
- **Structured error codes** — every `FerroTAError` exception now carries a code (`FTERR001`–`FTERR006`) and an actionable `suggestion` hint
|
||||
- **TA-Lib-style API** for 160+ indicators, including the common `SMA`, `EMA`, `RSI`, `MACD`, and `BBANDS` entry points.
|
||||
- **Pre-built wheels** for supported Python and OS targets, so the common install path stays `pip install ferro-ta`.
|
||||
- **NumPy-first execution** with pandas and polars adapters, plus explicit guidance on contiguous-array fast paths.
|
||||
- **Batch and streaming APIs** for multi-series and bar-by-bar workloads.
|
||||
- **Compatibility and support docs** covering parity status, supported wheels, supported Python versions, and experimental modules.
|
||||
- **Type stubs, error model, API discovery, and examples** for day-to-day library use.
|
||||
|
||||
## 🧪 Adjacent and experimental modules
|
||||
|
||||
These ship in the repo, but they are not the primary product story:
|
||||
|
||||
- **Adjacent analytics:** derivatives helpers, backtesting utilities, portfolio and cross-asset analysis, feature generation, and charting.
|
||||
- **Experimental or optional tooling:** GPU backend, plugin system, WASM package, agent/tool wrappers, and the MCP server.
|
||||
- **Docs posture:** these modules are now called out separately in the docs nav and support matrix so the core TA library remains the main narrative.
|
||||
|
||||
---
|
||||
|
||||
@@ -118,7 +102,7 @@ Optional extras:
|
||||
pip install "ferro-ta[pandas]" # transparent pandas.Series support
|
||||
pip install "ferro-ta[polars]" # transparent polars.Series support
|
||||
pip install "ferro-ta[gpu]" # GPU-accelerated SMA/EMA/RSI via PyTorch (CUDA/MPS)
|
||||
pip install "ferro-ta[options]" # Options/IV helpers (IV rank, percentile, z-score)
|
||||
pip install "ferro-ta[options]" # Derivatives analytics helpers
|
||||
pip install "ferro-ta[mcp]" # MCP server for Cursor/Claude agent integration
|
||||
pip install "ferro-ta[all]" # all optional extras (excluding gpu)
|
||||
```
|
||||
@@ -150,6 +134,28 @@ macd_line, signal, histogram = MACD(close, fastperiod=12, slowperiod=26, signalp
|
||||
upper, middle, lower = BBANDS(close, timeperiod=5, nbdevup=2.0, nbdevdn=2.0)
|
||||
```
|
||||
|
||||
## Δ Derivatives Analytics
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.options import greeks, implied_volatility, option_price
|
||||
from ferro_ta.analysis.futures import basis, curve_summary
|
||||
|
||||
price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call", model="bsm")
|
||||
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call", model="bsm")
|
||||
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call", model="bsm")
|
||||
|
||||
front_basis = basis(100.0, 103.0)
|
||||
curve = curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0])
|
||||
```
|
||||
|
||||
The derivatives layer is analytics-only. It includes:
|
||||
|
||||
- options pricing under Black-Scholes-Merton and Black-76
|
||||
- delta, gamma, vega, theta, and rho
|
||||
- implied volatility inversion and smile metrics
|
||||
- futures basis, carry, curve, and continuous-roll helpers
|
||||
- typed strategy schemas and multi-leg payoff/Greeks aggregation
|
||||
|
||||
**Migrating from TA-Lib?** Just swap the import — the API is identical:
|
||||
|
||||
```python
|
||||
@@ -158,7 +164,7 @@ import talib
|
||||
sma = talib.SMA(close, timeperiod=20)
|
||||
rsi = talib.RSI(close, timeperiod=14)
|
||||
|
||||
# After (ferro-ta — same call signature, faster result)
|
||||
# After (ferro-ta — same call signature)
|
||||
import ferro_ta
|
||||
sma = ferro_ta.SMA(close, timeperiod=20)
|
||||
rsi = ferro_ta.RSI(close, timeperiod=14)
|
||||
@@ -673,7 +679,8 @@ python/ferro_ta/
|
||||
│ # statistic, cycle, pattern, price_transform, math_ops, extended)
|
||||
├── data/ # Streaming, batch, chunked, resampling, aggregation, adapters
|
||||
├── analysis/ # Portfolio, backtest, regime, cross_asset, attribution,
|
||||
│ # signals, features, crypto, options
|
||||
│ # signals, features, crypto, options, futures,
|
||||
│ # options_strategy, derivatives_payoff
|
||||
├── tools/ # Visualisation, alerting, DSL, pipeline, workflow,
|
||||
│ # api_info, GPU support
|
||||
└── mcp/ # Model Context Protocol server
|
||||
|
||||
+48
-9
@@ -15,6 +15,14 @@ For the packaging and release overview, see [PACKAGING.md](PACKAGING.md).
|
||||
| **npm (WASM)** | Workflow `wasm-publish`|
|
||||
| **crates.io** | CI job `publish-cratesio` |
|
||||
|
||||
PyPI releases are expected to include:
|
||||
|
||||
- Wheels for CPython 3.10, 3.11, 3.12, and 3.13
|
||||
- Linux x86_64 (`manylinux_2_17`)
|
||||
- macOS universal2
|
||||
- Windows x86_64
|
||||
- One source distribution (`sdist`)
|
||||
|
||||
---
|
||||
|
||||
## Pre-release checklist
|
||||
@@ -34,6 +42,8 @@ Before starting a release:
|
||||
- [ ] `CHANGELOG.md` has a `## [X.Y.Z]` section (not `[Unreleased]`) with all
|
||||
changes since the last release documented under `### Added`, `### Changed`,
|
||||
`### Fixed`, `### Removed` headings.
|
||||
- [ ] Public docs match the release: `docs/conf.py`, `docs/changelog.rst`, and
|
||||
`docs/support_matrix.rst` reflect the version and current support status.
|
||||
|
||||
---
|
||||
|
||||
@@ -54,26 +64,39 @@ Example: current version is `0.1.0` and you are adding new indicators → new ve
|
||||
|
||||
## Step 2 — Sync version everywhere
|
||||
|
||||
These files must carry **the same version string** (e.g. `0.2.0`). Update all before tagging:
|
||||
These files must carry **the same version string** (e.g. `0.2.0`). The easiest
|
||||
way to do that is:
|
||||
|
||||
```bash
|
||||
python3 scripts/bump_version.py 0.2.0
|
||||
python3 scripts/bump_version.py --check
|
||||
```
|
||||
|
||||
That script updates the tracked release-version carriers for you.
|
||||
|
||||
Files covered by the bump script:
|
||||
|
||||
| File | Location |
|
||||
|------|----------|
|
||||
| `Cargo.toml` | Root (source of truth) |
|
||||
| `crates/ferro_ta_core/Cargo.toml` | Same version for crates.io publish |
|
||||
| `crates/ferro_ta_core/README.md` | Installation snippet should show the current crate version |
|
||||
| `pyproject.toml` | Root |
|
||||
| `wasm/package.json` | `"version": "0.2.0"` |
|
||||
| `wasm/package.json` | Package version |
|
||||
| `conda/meta.yaml` | Conda recipe version |
|
||||
| `docs/conf.py` | Default Sphinx release must resolve to the same version |
|
||||
|
||||
**`Cargo.toml`** (root):
|
||||
```toml
|
||||
[package]
|
||||
name = "ferro_ta"
|
||||
version = "0.2.0" # ← update here
|
||||
version = "X.Y.Z" # ← or use scripts/bump_version.py X.Y.Z
|
||||
```
|
||||
|
||||
**`pyproject.toml`**:
|
||||
```toml
|
||||
[project]
|
||||
version = "0.2.0" # ← must match Cargo.toml exactly
|
||||
version = "X.Y.Z" # ← must match Cargo.toml exactly
|
||||
```
|
||||
|
||||
> **Rule:** `Cargo.toml` is the source of truth. Sync the others to match before tagging.
|
||||
@@ -83,18 +106,24 @@ version = "0.2.0" # ← must match Cargo.toml exactly
|
||||
## Step 3 — Update CHANGELOG.md
|
||||
|
||||
1. Open `CHANGELOG.md`.
|
||||
2. Rename the `[Unreleased]` section to `[0.2.0] — YYYY-MM-DD` (today's date).
|
||||
2. Rename the `[Unreleased]` section to `[X.Y.Z] — YYYY-MM-DD` (today's date).
|
||||
3. Add a fresh empty `[Unreleased]` section at the top.
|
||||
4. Update the comparison links at the bottom:
|
||||
|
||||
```markdown
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v0.2.0...HEAD
|
||||
[0.2.0]: https://github.com/pratikbhadane24/ferro-ta/compare/v0.1.0...v0.2.0
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/vX.Y.Z...HEAD
|
||||
[X.Y.Z]: https://github.com/pratikbhadane24/ferro-ta/compare/vPREVIOUS...vX.Y.Z
|
||||
```
|
||||
|
||||
Follow the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format:
|
||||
`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`.
|
||||
|
||||
Also update the docs-facing release surfaces for the same version:
|
||||
|
||||
- `docs/changelog.rst` with a concise release-notes entry
|
||||
- `docs/support_matrix.rst` if supported versions, tested wheels, or module
|
||||
stability changed
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Commit the version bump
|
||||
@@ -128,7 +157,7 @@ git push origin v0.2.0
|
||||
4. Paste the changelog section for `v0.2.0` into the release notes.
|
||||
5. Click **Publish release**.
|
||||
|
||||
Publishing the release triggers the CI `build-wheels` and `publish` jobs
|
||||
Publishing the release triggers the CI wheel build jobs, `build-sdist`, and `publish`
|
||||
automatically (the workflow responds to `release: published`). The PyPI upload
|
||||
uses Trusted Publishing via GitHub OIDC, so no `PYPI_API_TOKEN` secret is used.
|
||||
|
||||
@@ -136,7 +165,7 @@ uses Trusted Publishing via GitHub OIDC, so no `PYPI_API_TOKEN` secret is used.
|
||||
|
||||
## Step 7 — Monitor CI and verify PyPI
|
||||
|
||||
1. Watch the **Actions** tab: `build-wheels` → `publish` (PyPI), `publish-cratesio` (crates.io), and the **wasm-publish** workflow (npm).
|
||||
1. Watch the **Actions** tab: the release wheel jobs, `build-sdist`, `publish` (PyPI), `publish-cratesio` (crates.io), and the **wasm-publish** workflow (npm).
|
||||
2. After the `publish` job succeeds, verify the package is live:
|
||||
|
||||
```bash
|
||||
@@ -144,6 +173,16 @@ pip install ferro-ta==0.2.0
|
||||
python -c "import ferro_ta; print(ferro_ta.__version__ if hasattr(ferro_ta,'__version__') else 'ok')"
|
||||
```
|
||||
|
||||
For version-specific verification, also check at least one install on each
|
||||
supported Python line, for example:
|
||||
|
||||
```bash
|
||||
uv venv --python 3.13 .venv-313
|
||||
. .venv-313/bin/activate
|
||||
uv pip install ferro-ta==0.2.0
|
||||
python -c "import ferro_ta; print(ferro_ta.SMA([1.0, 2.0, 3.0], 2))"
|
||||
```
|
||||
|
||||
3. If anything fails: fix the issue, bump to a patch version (`0.2.1`), and repeat.
|
||||
|
||||
---
|
||||
|
||||
+23
-6
@@ -21,16 +21,33 @@ Currently supported: **3.10, 3.11, 3.12, 3.13** (see `pyproject.toml`).
|
||||
|
||||
## Release playbook
|
||||
|
||||
1. **Bump the version** in `Cargo.toml` and `pyproject.toml` to the new version
|
||||
(e.g. `1.0.1`).
|
||||
2. **Update `CHANGELOG.md`**: move the `[Unreleased]` block to a new dated section
|
||||
### Fast path
|
||||
|
||||
1. **Bump tracked version files with one command**:
|
||||
```bash
|
||||
python3 scripts/bump_version.py 1.0.3
|
||||
```
|
||||
or:
|
||||
```bash
|
||||
make version VERSION=1.0.3
|
||||
```
|
||||
2. **Verify everything matches**:
|
||||
```bash
|
||||
python3 scripts/bump_version.py --check
|
||||
```
|
||||
3. **Update `CHANGELOG.md`**: move the `[Unreleased]` block to a new dated section
|
||||
`[1.0.1] — YYYY-MM-DD` and open a fresh `[Unreleased]` block.
|
||||
3. **Commit** the version bump and changelog update with message
|
||||
4. **Commit** the version bump and changelog update with message
|
||||
`chore: release v1.0.1`.
|
||||
4. **Create a tag**: `git tag v1.0.1 && git push origin v1.0.1`.
|
||||
5. **Create a GitHub Release** for tag `v1.0.1` — the CI `build-wheels` and
|
||||
5. **Create a tag**: `git tag v1.0.1 && git push origin v1.0.1`.
|
||||
6. **Create a GitHub Release** for tag `v1.0.1` — the CI `build-wheels` and
|
||||
`publish` jobs trigger automatically on `release: published`.
|
||||
|
||||
The bump script updates the tracked release-version carriers that are easy to
|
||||
miss manually: root Cargo, Python packaging, the core crate, the core crate
|
||||
README install snippet, the WASM package, the Conda recipe, and the docs pages
|
||||
that show the current released version.
|
||||
|
||||
## Breaking-change policy
|
||||
|
||||
- Removing an indicator or changing its signature is a **MAJOR** change.
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ MAX_SERIES_LENGTH = int(os.environ.get("MAX_SERIES_LENGTH", "100000"))
|
||||
app = FastAPI(
|
||||
title="ferro-ta API",
|
||||
description="REST API for ferro-ta technical analysis indicators and backtesting.",
|
||||
version="1.0.0",
|
||||
version=ft.__version__,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
+99
-6
@@ -1,14 +1,21 @@
|
||||
# ferro-ta Benchmark Suite
|
||||
|
||||
> **62 indicators × 6 libraries** — accuracy and speed verified on **100,000 bars** (LARGE dataset).
|
||||
> Reproducible speed and accuracy comparisons across 62 indicators and the
|
||||
> libraries available in your environment.
|
||||
|
||||
## Overview
|
||||
|
||||
The benchmark suite compares **ferro-ta** against five popular Python technical-analysis libraries on a common dataset and shared wrappers so timings are directly comparable.
|
||||
The benchmark suite compares **ferro-ta** against other Python
|
||||
technical-analysis libraries on a common dataset and shared wrappers so the
|
||||
results are easier to reproduce and critique.
|
||||
|
||||
It is not designed to prove that ferro-ta wins everywhere. It is designed to
|
||||
show where ferro-ta is faster, where it only ties, and where another library
|
||||
still wins.
|
||||
|
||||
| Library | Notes |
|
||||
|-----------|-------|
|
||||
| **TA-Lib** | C extension; gold standard for accuracy and speed |
|
||||
| **TA-Lib** | C extension; widely used comparison baseline |
|
||||
| **pandas-ta** | Pure Python; broad indicator set |
|
||||
| **ta** | Simple API; some indicators use O(n²) loops and are very slow |
|
||||
| **Tulipy** | C extension; truncated output (no leading NaN padding) |
|
||||
@@ -37,9 +44,47 @@ from benchmarks.data_generator import SMALL, MEDIUM, LARGE
|
||||
|
||||
- **Harness:** [pytest-benchmark](https://pytest-benchmark.readthedocs.io/) with `benchmark.pedantic(..., iterations=5, rounds=20, warmup_rounds=2)`.
|
||||
- **Reported metric:** **Median time per call** in **microseconds (µs)** — lower is better.
|
||||
- **Machine info:** Stored in `benchmarks/results.json` (`machine_info`, `commit_info`) for reproducibility.
|
||||
- **TA-Lib head-to-head JSON:** `benchmarks/bench_vs_talib.py` records per-run samples, variance stats, machine/runtime/build metadata, and Python-tracked peak allocation snapshots.
|
||||
- **Machine info:** Stored in the generated JSON artifacts for reproducibility.
|
||||
- **Libraries:** Only libraries present in the environment are benchmarked; missing ones are skipped.
|
||||
|
||||
## Current checked-in TA-Lib artifact
|
||||
|
||||
The checked-in `benchmarks/artifacts/latest/benchmark_vs_talib.json` artifact
|
||||
uses contiguous `float64` arrays at 10k and 100k bars on an Apple M3 Max,
|
||||
CPython 3.13.5, and Rust 1.91.1 with the default release profile
|
||||
(`lto = true`, `codegen-units = 1`).
|
||||
|
||||
- ferro-ta is ahead outside the tie band on 6 of 12 rows at 10k bars and 6 of 12 rows at 100k bars.
|
||||
- TA-Lib still wins in the current artifact on `STOCH` and `ADX`, and remains close on `EMA`, `RSI`, `ATR`, and `OBV` depending on size.
|
||||
- The public claim should therefore be read as "often faster on selected indicators," not "faster everywhere."
|
||||
- When publishing performance statements, point readers to the raw JSON artifact, not just the summary table.
|
||||
- The artifact now includes per-run samples, variance stats, and Python-tracked allocation snapshots for each compared indicator.
|
||||
|
||||
## Reproducible Perf Artifacts
|
||||
|
||||
Use the perf-contract runner when you want a compact set of machine-readable
|
||||
artifacts for single-series latency, batch throughput, streaming throughput,
|
||||
and hotspot attribution in one directory:
|
||||
|
||||
```bash
|
||||
uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest --skip-talib
|
||||
```
|
||||
|
||||
That command writes:
|
||||
|
||||
- `indicator_latency.json` — canonical-fixture timings for the benchmark suite indicators
|
||||
- `batch.json` — 2-D batch throughput plus grouped multi-indicator timings
|
||||
- `streaming.json` — streaming update throughput vs batch baselines
|
||||
- `runtime_hotspots.json` — ranked hotspot report with reference speedups
|
||||
- `manifest.json` — runtime/git metadata plus hashes for the generated artifacts
|
||||
|
||||
For CI or local guardrails, validate the hotspot report with:
|
||||
|
||||
```bash
|
||||
uv run python benchmarks/check_hotspot_regression.py --input benchmarks/artifacts/latest/runtime_hotspots.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Speed comparison (100k bars, median µs — lower is better)
|
||||
@@ -116,7 +161,7 @@ The speed table includes **all 62 indicators**. **Number** = median µs; **N/A**
|
||||
**Takeaways:**
|
||||
|
||||
- **`ta`** is 20–350× slower on ATR, CCI, ADX, MFI (O(n²) Python loops).
|
||||
- **ferro-ta** is typically 2–4× faster than **pandas-ta** across indicators.
|
||||
- **ferro-ta** is often materially faster than **pandas-ta** on the checked-in 100k-bar table.
|
||||
- **TA-Lib** and **Tulipy** (C extensions) are strong; ferro-ta is competitive and avoids native dependencies.
|
||||
|
||||
---
|
||||
@@ -136,15 +181,63 @@ uv run pytest benchmarks/test_speed.py --benchmark-only -k "test_large_dataset"
|
||||
# Regenerate the Speed Comparison markdown table from results.json
|
||||
uv run python benchmarks/benchmark_table.py
|
||||
|
||||
# TA-Lib head-to-head with machine-readable summary + git/runtime metadata
|
||||
# TA-Lib head-to-head with machine/runtime/build metadata, per-run samples,
|
||||
# variance stats, and Python-tracked allocation snapshots
|
||||
uv run python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
|
||||
# Selected derivatives analytics comparison (BSM price, IV, Greeks, Black-76)
|
||||
# against built-in analytical references plus optional installed libraries
|
||||
uv run python benchmarks/bench_derivatives_compare.py --sizes 1000 10000 --json benchmark_derivatives_compare.json
|
||||
|
||||
# Optional regression check used in CI
|
||||
uv run python benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
|
||||
|
||||
# Batch throughput + grouped multi-indicator calls
|
||||
uv run python benchmarks/bench_batch.py --samples 100000 --series 100 --json batch_benchmark.json
|
||||
|
||||
# Streaming update throughput vs batch baselines
|
||||
uv run python benchmarks/bench_streaming.py --bars 100000 --json streaming_benchmark.json
|
||||
|
||||
# Ranked hotspot attribution against bundled reference implementations
|
||||
uv run python benchmarks/profile_runtime_hotspots.py --json runtime_hotspots.json
|
||||
|
||||
# Portable vs SIMD-enabled build comparison
|
||||
uv run python benchmarks/bench_simd.py --json simd_benchmark.json
|
||||
|
||||
# One-shot perf artifact bundle
|
||||
uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest
|
||||
```
|
||||
|
||||
Without `uv`: use `pytest` and `python` from the same environment where `ferro_ta` and optional libs (e.g. `talib`, `pandas_ta`, `ta`, `tulipy`, `finta`) are installed.
|
||||
|
||||
### Derivatives analytics
|
||||
|
||||
`benchmarks/bench_derivatives_compare.py` focuses on selected options-analytics
|
||||
paths rather than the full surface area:
|
||||
|
||||
- `BSM` call pricing
|
||||
- call implied-volatility recovery
|
||||
- call Greeks
|
||||
- `Black-76` call pricing
|
||||
|
||||
The script always includes two analytical baselines:
|
||||
|
||||
- `reference_numpy` — pure NumPy formulas with vectorized IV bisection
|
||||
- `reference_python_loop` — scalar `math`-based reference for sanity checking
|
||||
|
||||
If `py_vollib` is installed, it is added automatically as an extra baseline.
|
||||
The output JSON includes runtime/build metadata, per-run timing samples,
|
||||
variance stats, and Python-tracked peak allocation snapshots.
|
||||
|
||||
### WASM
|
||||
|
||||
From the `wasm/` directory:
|
||||
|
||||
```bash
|
||||
wasm-pack build --target nodejs --out-dir pkg
|
||||
node bench.js --json ../wasm_benchmark.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Indicator coverage
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "batch",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T20:25:58.345834+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "9011250f992119170242cf17a67834c67b91bcdb",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"n_samples": 100000,
|
||||
"n_series": 100,
|
||||
"total_bars": 10000000,
|
||||
"seed": 42
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"indicator": "SMA",
|
||||
"parallel_ms": 37.86,
|
||||
"sequential_ms": 43.5625,
|
||||
"loop_ms": 17.8136,
|
||||
"parallel_speedup_vs_loop": 0.4705,
|
||||
"sequential_speedup_vs_loop": 0.4089
|
||||
},
|
||||
{
|
||||
"indicator": "RSI",
|
||||
"parallel_ms": 40.9229,
|
||||
"sequential_ms": 79.5345,
|
||||
"loop_ms": 53.3368,
|
||||
"parallel_speedup_vs_loop": 1.3033,
|
||||
"sequential_speedup_vs_loop": 0.6706
|
||||
},
|
||||
{
|
||||
"indicator": "ATR",
|
||||
"parallel_ms": 91.76,
|
||||
"sequential_ms": 130.1404,
|
||||
"loop_ms": 99.5885,
|
||||
"parallel_speedup_vs_loop": 1.0853,
|
||||
"sequential_speedup_vs_loop": 0.7652
|
||||
},
|
||||
{
|
||||
"indicator": "ADX",
|
||||
"parallel_ms": 100.1362,
|
||||
"sequential_ms": 149.3412,
|
||||
"loop_ms": 125.3319,
|
||||
"parallel_speedup_vs_loop": 1.2516,
|
||||
"sequential_speedup_vs_loop": 0.8392
|
||||
}
|
||||
],
|
||||
"grouped_results": [
|
||||
{
|
||||
"case": "close_bundle_3",
|
||||
"grouped_ms": 0.652,
|
||||
"separate_ms": 0.9124,
|
||||
"speedup_vs_separate": 1.3994
|
||||
},
|
||||
{
|
||||
"case": "hlc_bundle_3",
|
||||
"grouped_ms": 1.4784,
|
||||
"separate_ms": 3.3724,
|
||||
"speedup_vs_separate": 2.2811
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "indicator_latency",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T20:25:52.160357+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "9011250f992119170242cf17a67834c67b91bcdb",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"fixtures": [
|
||||
{
|
||||
"path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz",
|
||||
"size_bytes": 75586,
|
||||
"sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c"
|
||||
}
|
||||
],
|
||||
"dataset": {
|
||||
"fixture": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz",
|
||||
"bars": 2000,
|
||||
"rounds": 5
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"name": "VAR_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0229
|
||||
},
|
||||
{
|
||||
"name": "STOCH",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {},
|
||||
"elapsed_ms": 0.0201
|
||||
},
|
||||
{
|
||||
"name": "WILLR_14",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0201
|
||||
},
|
||||
{
|
||||
"name": "CCI_14",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0163
|
||||
},
|
||||
{
|
||||
"name": "ADX_14",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.015
|
||||
},
|
||||
{
|
||||
"name": "MACD",
|
||||
"inputs": "close",
|
||||
"kwargs": {},
|
||||
"elapsed_ms": 0.0135
|
||||
},
|
||||
{
|
||||
"name": "ATR_14",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0105
|
||||
},
|
||||
{
|
||||
"name": "RSI_14",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0098
|
||||
},
|
||||
{
|
||||
"name": "STDDEV_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.009
|
||||
},
|
||||
{
|
||||
"name": "BETA_5",
|
||||
"inputs": "pair_hl",
|
||||
"kwargs": {
|
||||
"timeperiod": 5
|
||||
},
|
||||
"elapsed_ms": 0.0083
|
||||
},
|
||||
{
|
||||
"name": "CORREL_30",
|
||||
"inputs": "pair_hl",
|
||||
"kwargs": {
|
||||
"timeperiod": 30
|
||||
},
|
||||
"elapsed_ms": 0.0076
|
||||
},
|
||||
{
|
||||
"name": "BBANDS_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0055
|
||||
},
|
||||
{
|
||||
"name": "LINEARREG_14",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0055
|
||||
},
|
||||
{
|
||||
"name": "TSF_14",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0054
|
||||
},
|
||||
{
|
||||
"name": "LINEARREG_SLOPE_14",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0052
|
||||
},
|
||||
{
|
||||
"name": "EMA_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.005
|
||||
},
|
||||
{
|
||||
"name": "SMA_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0026
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "perf_contract",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T20:26:40.776130+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "9011250f992119170242cf17a67834c67b91bcdb",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"fixtures": [
|
||||
{
|
||||
"path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz",
|
||||
"size_bytes": 75586,
|
||||
"sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c"
|
||||
}
|
||||
],
|
||||
"output_dir": "benchmarks/artifacts/latest"
|
||||
},
|
||||
"artifacts": {
|
||||
"indicator_latency": {
|
||||
"path": "benchmarks/artifacts/latest/indicator_latency.json",
|
||||
"size_bytes": 3217,
|
||||
"sha256": "43b88a50a4d7f91e30ff8e57dbf859ae5e76ecabaaf05cfcb7d8db67df920f7f"
|
||||
},
|
||||
"batch": {
|
||||
"path": "benchmarks/artifacts/latest/batch.json",
|
||||
"size_bytes": 1701,
|
||||
"sha256": "bc900c885c48ec1903ea4870ca1de8cb9f33c609d2cefa4688cbdd18cb977f11"
|
||||
},
|
||||
"streaming": {
|
||||
"path": "benchmarks/artifacts/latest/streaming.json",
|
||||
"size_bytes": 1944,
|
||||
"sha256": "925ba1be66d0d499daa81dfc148b03ac325ad685ce0c71e28ca1fc6927f16415"
|
||||
},
|
||||
"runtime_hotspots": {
|
||||
"path": "benchmarks/artifacts/latest/runtime_hotspots.json",
|
||||
"size_bytes": 2366,
|
||||
"sha256": "920553b14b545f211b119c099ec59885de8b9e8056271cb2d2ac34c0c69b0906"
|
||||
},
|
||||
"simd": {
|
||||
"path": "benchmarks/artifacts/latest/simd.json",
|
||||
"size_bytes": 7700,
|
||||
"sha256": "d48943a5dfcf4f8d8d2ca42f0004f02f9fc894de7477791b686231da665e3335"
|
||||
},
|
||||
"benchmark_vs_talib": {
|
||||
"path": "benchmarks/artifacts/latest/benchmark_vs_talib.json",
|
||||
"size_bytes": 5923,
|
||||
"sha256": "8a4e847517f1334255353982a5266c0323bf433a1eb78dafeff808d5ad3bf7f0"
|
||||
},
|
||||
"wasm": {
|
||||
"path": "benchmarks/artifacts/latest/wasm.json",
|
||||
"size_bytes": 935,
|
||||
"sha256": "f31fd871990c44e24a2259d618ae40a52866d20b95aa6047af3d38b9371c2ab7"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "runtime_hotspots",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T20:26:02.236710+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "9011250f992119170242cf17a67834c67b91bcdb",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"price_bars": 20000,
|
||||
"iv_bars": 50000,
|
||||
"window": 252
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_zscore",
|
||||
"fast_ms": 35.984,
|
||||
"reference_ms": 944.5804,
|
||||
"speedup_vs_reference": 26.25,
|
||||
"share_of_suite_pct": 77.27
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_percentile",
|
||||
"fast_ms": 7.6624,
|
||||
"reference_ms": 81.581,
|
||||
"speedup_vs_reference": 10.6469,
|
||||
"share_of_suite_pct": 16.45
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_rank",
|
||||
"fast_ms": 2.2905,
|
||||
"reference_ms": 198.2937,
|
||||
"speedup_vs_reference": 86.5738,
|
||||
"share_of_suite_pct": 4.92
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "feature_matrix",
|
||||
"fast_ms": 0.2872,
|
||||
"reference_ms": 0.2377,
|
||||
"speedup_vs_reference": 0.8275,
|
||||
"share_of_suite_pct": 0.62
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "compute_many_close",
|
||||
"fast_ms": 0.1448,
|
||||
"reference_ms": 0.1505,
|
||||
"speedup_vs_reference": 1.0391,
|
||||
"share_of_suite_pct": 0.31
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "BETA",
|
||||
"fast_ms": 0.0637,
|
||||
"reference_ms": 164.1752,
|
||||
"speedup_vs_reference": 2575.2975,
|
||||
"share_of_suite_pct": 0.14
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "CORREL",
|
||||
"fast_ms": 0.0553,
|
||||
"reference_ms": 159.6473,
|
||||
"speedup_vs_reference": 2885.1573,
|
||||
"share_of_suite_pct": 0.12
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "LINEARREG",
|
||||
"fast_ms": 0.0415,
|
||||
"reference_ms": 47.4665,
|
||||
"speedup_vs_reference": 1143.77,
|
||||
"share_of_suite_pct": 0.09
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "TSF",
|
||||
"fast_ms": 0.0414,
|
||||
"reference_ms": 47.921,
|
||||
"speedup_vs_reference": 1157.036,
|
||||
"share_of_suite_pct": 0.09
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "simd",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T20:26:40.566511+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "9011250f992119170242cf17a67834c67b91bcdb",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"price_bars": 20000,
|
||||
"iv_bars": 50000,
|
||||
"window": 252
|
||||
},
|
||||
"variants": [
|
||||
"portable_release",
|
||||
"simd_release"
|
||||
]
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"name": "BETA",
|
||||
"category": "rust_kernel",
|
||||
"portable_ms": 0.0635,
|
||||
"simd_ms": 0.0636,
|
||||
"speedup_simd_vs_portable": 0.9984
|
||||
},
|
||||
{
|
||||
"name": "TSF",
|
||||
"category": "rust_kernel",
|
||||
"portable_ms": 0.0415,
|
||||
"simd_ms": 0.0417,
|
||||
"speedup_simd_vs_portable": 0.9952
|
||||
},
|
||||
{
|
||||
"name": "compute_many_close",
|
||||
"category": "ffi_grouping",
|
||||
"portable_ms": 0.1548,
|
||||
"simd_ms": 0.1572,
|
||||
"speedup_simd_vs_portable": 0.9847
|
||||
},
|
||||
{
|
||||
"name": "iv_zscore",
|
||||
"category": "python_analysis",
|
||||
"portable_ms": 36.0643,
|
||||
"simd_ms": 37.2041,
|
||||
"speedup_simd_vs_portable": 0.9694
|
||||
},
|
||||
{
|
||||
"name": "feature_matrix",
|
||||
"category": "ffi_grouping",
|
||||
"portable_ms": 0.2556,
|
||||
"simd_ms": 0.2667,
|
||||
"speedup_simd_vs_portable": 0.9584
|
||||
},
|
||||
{
|
||||
"name": "iv_percentile",
|
||||
"category": "python_analysis",
|
||||
"portable_ms": 7.7548,
|
||||
"simd_ms": 8.1565,
|
||||
"speedup_simd_vs_portable": 0.9508
|
||||
},
|
||||
{
|
||||
"name": "LINEARREG",
|
||||
"category": "rust_kernel",
|
||||
"portable_ms": 0.0416,
|
||||
"simd_ms": 0.0443,
|
||||
"speedup_simd_vs_portable": 0.9391
|
||||
},
|
||||
{
|
||||
"name": "iv_rank",
|
||||
"category": "python_analysis",
|
||||
"portable_ms": 2.2813,
|
||||
"simd_ms": 2.4386,
|
||||
"speedup_simd_vs_portable": 0.9355
|
||||
},
|
||||
{
|
||||
"name": "CORREL",
|
||||
"category": "rust_kernel",
|
||||
"portable_ms": 0.0552,
|
||||
"simd_ms": 0.0633,
|
||||
"speedup_simd_vs_portable": 0.872
|
||||
}
|
||||
],
|
||||
"reports": {
|
||||
"portable_release": {
|
||||
"metadata": {
|
||||
"suite": "runtime_hotspots",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T20:26:06.920513+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "9011250f992119170242cf17a67834c67b91bcdb",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"price_bars": 20000,
|
||||
"iv_bars": 50000,
|
||||
"window": 252
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_zscore",
|
||||
"fast_ms": 36.0643,
|
||||
"reference_ms": 908.5403,
|
||||
"speedup_vs_reference": 25.1922,
|
||||
"share_of_suite_pct": 77.2
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_percentile",
|
||||
"fast_ms": 7.7548,
|
||||
"reference_ms": 82.2352,
|
||||
"speedup_vs_reference": 10.6045,
|
||||
"share_of_suite_pct": 16.6
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_rank",
|
||||
"fast_ms": 2.2813,
|
||||
"reference_ms": 202.5375,
|
||||
"speedup_vs_reference": 88.7803,
|
||||
"share_of_suite_pct": 4.88
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "feature_matrix",
|
||||
"fast_ms": 0.2556,
|
||||
"reference_ms": 0.2252,
|
||||
"speedup_vs_reference": 0.8812,
|
||||
"share_of_suite_pct": 0.55
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "compute_many_close",
|
||||
"fast_ms": 0.1548,
|
||||
"reference_ms": 0.1508,
|
||||
"speedup_vs_reference": 0.9742,
|
||||
"share_of_suite_pct": 0.33
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "BETA",
|
||||
"fast_ms": 0.0635,
|
||||
"reference_ms": 162.8972,
|
||||
"speedup_vs_reference": 2563.6148,
|
||||
"share_of_suite_pct": 0.14
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "CORREL",
|
||||
"fast_ms": 0.0552,
|
||||
"reference_ms": 163.1357,
|
||||
"speedup_vs_reference": 2952.6826,
|
||||
"share_of_suite_pct": 0.12
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "LINEARREG",
|
||||
"fast_ms": 0.0416,
|
||||
"reference_ms": 48.0097,
|
||||
"speedup_vs_reference": 1153.3863,
|
||||
"share_of_suite_pct": 0.09
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "TSF",
|
||||
"fast_ms": 0.0415,
|
||||
"reference_ms": 47.9395,
|
||||
"speedup_vs_reference": 1155.1696,
|
||||
"share_of_suite_pct": 0.09
|
||||
}
|
||||
]
|
||||
},
|
||||
"simd_release": {
|
||||
"metadata": {
|
||||
"suite": "runtime_hotspots",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T20:26:25.789478+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "9011250f992119170242cf17a67834c67b91bcdb",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"price_bars": 20000,
|
||||
"iv_bars": 50000,
|
||||
"window": 252
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_zscore",
|
||||
"fast_ms": 37.2041,
|
||||
"reference_ms": 930.7842,
|
||||
"speedup_vs_reference": 25.0183,
|
||||
"share_of_suite_pct": 76.81
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_percentile",
|
||||
"fast_ms": 8.1565,
|
||||
"reference_ms": 88.3639,
|
||||
"speedup_vs_reference": 10.8336,
|
||||
"share_of_suite_pct": 16.84
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_rank",
|
||||
"fast_ms": 2.4386,
|
||||
"reference_ms": 221.0389,
|
||||
"speedup_vs_reference": 90.6424,
|
||||
"share_of_suite_pct": 5.03
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "feature_matrix",
|
||||
"fast_ms": 0.2667,
|
||||
"reference_ms": 0.2436,
|
||||
"speedup_vs_reference": 0.9134,
|
||||
"share_of_suite_pct": 0.55
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "compute_many_close",
|
||||
"fast_ms": 0.1572,
|
||||
"reference_ms": 0.1593,
|
||||
"speedup_vs_reference": 1.0135,
|
||||
"share_of_suite_pct": 0.32
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "BETA",
|
||||
"fast_ms": 0.0636,
|
||||
"reference_ms": 172.9198,
|
||||
"speedup_vs_reference": 2717.7961,
|
||||
"share_of_suite_pct": 0.13
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "CORREL",
|
||||
"fast_ms": 0.0633,
|
||||
"reference_ms": 170.0262,
|
||||
"speedup_vs_reference": 2686.3776,
|
||||
"share_of_suite_pct": 0.13
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "LINEARREG",
|
||||
"fast_ms": 0.0443,
|
||||
"reference_ms": 50.5614,
|
||||
"speedup_vs_reference": 1141.5474,
|
||||
"share_of_suite_pct": 0.09
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "TSF",
|
||||
"fast_ms": 0.0417,
|
||||
"reference_ms": 50.9599,
|
||||
"speedup_vs_reference": 1221.8259,
|
||||
"share_of_suite_pct": 0.09
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "streaming",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T20:25:58.628657+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "9011250f992119170242cf17a67834c67b91bcdb",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"n_bars": 100000,
|
||||
"seed": 2026
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"indicator": "StreamingSMA",
|
||||
"inputs": "close",
|
||||
"stream_total_ms": 4.5729,
|
||||
"batch_total_ms": 0.0685,
|
||||
"stream_ns_per_update": 45.73,
|
||||
"batch_ns_per_bar": 0.68,
|
||||
"updates_per_second": 21867879.95,
|
||||
"stream_over_batch_ratio": 66.7989
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingEMA",
|
||||
"inputs": "close",
|
||||
"stream_total_ms": 4.535,
|
||||
"batch_total_ms": 0.1969,
|
||||
"stream_ns_per_update": 45.35,
|
||||
"batch_ns_per_bar": 1.97,
|
||||
"updates_per_second": 22050716.65,
|
||||
"stream_over_batch_ratio": 23.0301
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingRSI",
|
||||
"inputs": "close",
|
||||
"stream_total_ms": 4.6421,
|
||||
"batch_total_ms": 0.4597,
|
||||
"stream_ns_per_update": 46.42,
|
||||
"batch_ns_per_bar": 4.6,
|
||||
"updates_per_second": 21541858.52,
|
||||
"stream_over_batch_ratio": 10.098
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingATR",
|
||||
"inputs": "hlc",
|
||||
"stream_total_ms": 10.2098,
|
||||
"batch_total_ms": 0.4599,
|
||||
"stream_ns_per_update": 102.1,
|
||||
"batch_ns_per_bar": 4.6,
|
||||
"updates_per_second": 9794518.83,
|
||||
"stream_over_batch_ratio": 22.2012
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingVWAP",
|
||||
"inputs": "hlcv",
|
||||
"stream_total_ms": 12.5109,
|
||||
"batch_total_ms": 0.1027,
|
||||
"stream_ns_per_update": 125.11,
|
||||
"batch_ns_per_bar": 1.03,
|
||||
"updates_per_second": 7993046.05,
|
||||
"stream_over_batch_ratio": 121.7603
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "wasm",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T20:15:04.885Z",
|
||||
"node_version": "v25.8.1",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64"
|
||||
},
|
||||
"dataset": {
|
||||
"bars": 100000
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"indicator": "SMA",
|
||||
"elapsed_ms": 0.1702,
|
||||
"ns_per_bar": 1.7,
|
||||
"million_bars_per_second": 587.52
|
||||
},
|
||||
{
|
||||
"indicator": "EMA",
|
||||
"elapsed_ms": 0.2923,
|
||||
"ns_per_bar": 2.92,
|
||||
"million_bars_per_second": 342.08
|
||||
},
|
||||
{
|
||||
"indicator": "RSI",
|
||||
"elapsed_ms": 0.5962,
|
||||
"ns_per_bar": 5.96,
|
||||
"million_bars_per_second": 167.73
|
||||
},
|
||||
{
|
||||
"indicator": "ATR",
|
||||
"elapsed_ms": 0.642,
|
||||
"ns_per_bar": 6.42,
|
||||
"million_bars_per_second": 155.75
|
||||
},
|
||||
{
|
||||
"indicator": "BBANDS",
|
||||
"elapsed_ms": 1.7411,
|
||||
"ns_per_bar": 17.41,
|
||||
"million_bars_per_second": 57.43
|
||||
}
|
||||
]
|
||||
}
|
||||
+206
-50
@@ -1,65 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ferro_ta
|
||||
|
||||
def _time_fn(fn, *args, **kwargs):
|
||||
times = []
|
||||
# Warmup
|
||||
try:
|
||||
from benchmarks.metadata import benchmark_metadata
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution fallback
|
||||
from metadata import benchmark_metadata
|
||||
|
||||
|
||||
def _time_fn(fn, *args, rounds: int = 5, **kwargs) -> float:
|
||||
fn(*args, **kwargs)
|
||||
for _ in range(5):
|
||||
times: list[float] = []
|
||||
for _ in range(rounds):
|
||||
t0 = time.perf_counter()
|
||||
fn(*args, **kwargs)
|
||||
times.append(time.perf_counter() - t0)
|
||||
return min(times)
|
||||
|
||||
def main():
|
||||
n_samples = 100_000
|
||||
n_series = 100
|
||||
print(f"Batch Benchmark: {n_samples} bars, {n_series} series (Total: {n_samples*n_series/1e6:.1f} M bars)")
|
||||
|
||||
np.random.seed(42)
|
||||
# contiguous array in row-major
|
||||
close2d = np.random.uniform(100.0, 200.0, (n_samples, n_series))
|
||||
h2d = close2d + np.random.uniform(0.1, 2.0, (n_samples, n_series))
|
||||
l2d = close2d - np.random.uniform(0.1, 2.0, (n_samples, n_series))
|
||||
|
||||
print("-" * 50)
|
||||
print(f"{'Indicator':<15} {'Batch (ms)':>12} {'Loop (ms)':>12} {'Speedup':>10}")
|
||||
print("-" * 50)
|
||||
|
||||
# 1. SMA
|
||||
kwargs = {"timeperiod": 14}
|
||||
def loop_sma(arr):
|
||||
for j in range(arr.shape[1]):
|
||||
ferro_ta.SMA(arr[:, j], **kwargs)
|
||||
|
||||
t_batch_sma = _time_fn(ferro_ta.batch.batch_sma, close2d, **kwargs)
|
||||
t_loop_sma = _time_fn(loop_sma, close2d)
|
||||
print(f"SMA {t_batch_sma*1000:12.1f} {t_loop_sma*1000:12.1f} {t_loop_sma/t_batch_sma:9.1f}x")
|
||||
def run_batch_benchmark(
|
||||
*,
|
||||
n_samples: int = 100_000,
|
||||
n_series: int = 100,
|
||||
seed: int = 42,
|
||||
) -> dict[str, Any]:
|
||||
rng = np.random.default_rng(seed)
|
||||
close2d = rng.uniform(100.0, 200.0, (n_samples, n_series))
|
||||
high2d = close2d + rng.uniform(0.1, 2.0, (n_samples, n_series))
|
||||
low2d = close2d - rng.uniform(0.1, 2.0, (n_samples, n_series))
|
||||
close1d = close2d[:, 0]
|
||||
high1d = high2d[:, 0]
|
||||
low1d = low2d[:, 0]
|
||||
|
||||
# 2. RSI
|
||||
def loop_rsi(arr):
|
||||
for j in range(arr.shape[1]):
|
||||
ferro_ta.RSI(arr[:, j], **kwargs)
|
||||
t_batch_rsi = _time_fn(ferro_ta.batch.batch_rsi, close2d, **kwargs)
|
||||
t_loop_rsi = _time_fn(loop_rsi, close2d)
|
||||
print(f"RSI {t_batch_rsi*1000:12.1f} {t_loop_rsi*1000:12.1f} {t_loop_rsi/t_batch_rsi:9.1f}x")
|
||||
batch_rows: list[dict[str, Any]] = []
|
||||
grouped_rows: list[dict[str, Any]] = []
|
||||
|
||||
# 3. ATR
|
||||
def loop_atr(h, l, c):
|
||||
for j in range(h.shape[1]):
|
||||
ferro_ta.ATR(h[:, j], l[:, j], c[:, j], **kwargs)
|
||||
t_batch_atr = _time_fn(ferro_ta.batch.batch_atr, h2d, l2d, close2d, **kwargs)
|
||||
t_loop_atr = _time_fn(loop_atr, h2d, l2d, close2d)
|
||||
print(f"ATR {t_batch_atr*1000:12.1f} {t_loop_atr*1000:12.1f} {t_loop_atr/t_batch_atr:9.1f}x")
|
||||
indicators = [
|
||||
(
|
||||
"SMA",
|
||||
lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=True),
|
||||
lambda: ferro_ta.batch.batch_sma(close2d, timeperiod=14, parallel=False),
|
||||
lambda: [ferro_ta.SMA(close2d[:, j], timeperiod=14) for j in range(n_series)],
|
||||
),
|
||||
(
|
||||
"RSI",
|
||||
lambda: ferro_ta.batch.batch_rsi(close2d, timeperiod=14, parallel=True),
|
||||
lambda: ferro_ta.batch.batch_rsi(close2d, timeperiod=14, parallel=False),
|
||||
lambda: [ferro_ta.RSI(close2d[:, j], timeperiod=14) for j in range(n_series)],
|
||||
),
|
||||
(
|
||||
"ATR",
|
||||
lambda: ferro_ta.batch.batch_atr(
|
||||
high2d, low2d, close2d, timeperiod=14, parallel=True
|
||||
),
|
||||
lambda: ferro_ta.batch.batch_atr(
|
||||
high2d, low2d, close2d, timeperiod=14, parallel=False
|
||||
),
|
||||
lambda: [
|
||||
ferro_ta.ATR(high2d[:, j], low2d[:, j], close2d[:, j], timeperiod=14)
|
||||
for j in range(n_series)
|
||||
],
|
||||
),
|
||||
(
|
||||
"ADX",
|
||||
lambda: ferro_ta.batch.batch_adx(
|
||||
high2d, low2d, close2d, timeperiod=14, parallel=True
|
||||
),
|
||||
lambda: ferro_ta.batch.batch_adx(
|
||||
high2d, low2d, close2d, timeperiod=14, parallel=False
|
||||
),
|
||||
lambda: [
|
||||
ferro_ta.ADX(high2d[:, j], low2d[:, j], close2d[:, j], timeperiod=14)
|
||||
for j in range(n_series)
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
# 4. ADX
|
||||
def loop_adx(h, l, c):
|
||||
for j in range(h.shape[1]):
|
||||
ferro_ta.ADX(h[:, j], l[:, j], c[:, j], **kwargs)
|
||||
t_batch_adx = _time_fn(ferro_ta.batch.batch_adx, h2d, l2d, close2d, **kwargs)
|
||||
t_loop_adx = _time_fn(loop_adx, h2d, l2d, close2d)
|
||||
print(f"ADX {t_batch_adx*1000:12.1f} {t_loop_adx*1000:12.1f} {t_loop_adx/t_batch_adx:9.1f}x")
|
||||
for name, parallel_fn, sequential_fn, loop_fn in indicators:
|
||||
batch_parallel_s = _time_fn(parallel_fn)
|
||||
batch_sequential_s = _time_fn(sequential_fn)
|
||||
loop_s = _time_fn(loop_fn)
|
||||
batch_rows.append(
|
||||
{
|
||||
"indicator": name,
|
||||
"parallel_ms": round(batch_parallel_s * 1000, 4),
|
||||
"sequential_ms": round(batch_sequential_s * 1000, 4),
|
||||
"loop_ms": round(loop_s * 1000, 4),
|
||||
"parallel_speedup_vs_loop": round(loop_s / batch_parallel_s, 4),
|
||||
"sequential_speedup_vs_loop": round(loop_s / batch_sequential_s, 4),
|
||||
}
|
||||
)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
grouped_cases = [
|
||||
(
|
||||
"close_bundle_3",
|
||||
lambda: ferro_ta.batch.compute_many(
|
||||
[
|
||||
("SMA", {"timeperiod": 10}),
|
||||
("EMA", {"timeperiod": 12}),
|
||||
("RSI", {"timeperiod": 14}),
|
||||
],
|
||||
close=close1d,
|
||||
),
|
||||
lambda: (
|
||||
ferro_ta.SMA(close1d, timeperiod=10),
|
||||
ferro_ta.EMA(close1d, timeperiod=12),
|
||||
ferro_ta.RSI(close1d, timeperiod=14),
|
||||
),
|
||||
),
|
||||
(
|
||||
"hlc_bundle_3",
|
||||
lambda: ferro_ta.batch.compute_many(
|
||||
[
|
||||
("ATR", {"timeperiod": 14}),
|
||||
("ADX", {"timeperiod": 14}),
|
||||
("CCI", {"timeperiod": 14}),
|
||||
],
|
||||
close=close1d,
|
||||
high=high1d,
|
||||
low=low1d,
|
||||
),
|
||||
lambda: (
|
||||
ferro_ta.ATR(high1d, low1d, close1d, timeperiod=14),
|
||||
ferro_ta.ADX(high1d, low1d, close1d, timeperiod=14),
|
||||
ferro_ta.CCI(high1d, low1d, close1d, timeperiod=14),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
for name, grouped_fn, separate_fn in grouped_cases:
|
||||
grouped_s = _time_fn(grouped_fn)
|
||||
separate_s = _time_fn(separate_fn)
|
||||
grouped_rows.append(
|
||||
{
|
||||
"case": name,
|
||||
"grouped_ms": round(grouped_s * 1000, 4),
|
||||
"separate_ms": round(separate_s * 1000, 4),
|
||||
"speedup_vs_separate": round(separate_s / grouped_s, 4),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"metadata": benchmark_metadata(
|
||||
"batch",
|
||||
extra={
|
||||
"dataset": {
|
||||
"n_samples": n_samples,
|
||||
"n_series": n_series,
|
||||
"total_bars": n_samples * n_series,
|
||||
"seed": seed,
|
||||
}
|
||||
},
|
||||
),
|
||||
"results": batch_rows,
|
||||
"grouped_results": grouped_rows,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Benchmark batch indicator execution.")
|
||||
parser.add_argument("--samples", type=int, default=100_000)
|
||||
parser.add_argument("--series", type=int, default=100)
|
||||
parser.add_argument("--seed", type=int, default=42)
|
||||
parser.add_argument("--json", dest="json_path")
|
||||
args = parser.parse_args()
|
||||
|
||||
payload = run_batch_benchmark(
|
||||
n_samples=args.samples,
|
||||
n_series=args.series,
|
||||
seed=args.seed,
|
||||
)
|
||||
|
||||
dataset = payload["metadata"]["dataset"]
|
||||
print(
|
||||
"Batch Benchmark: "
|
||||
f"{dataset['n_samples']} bars, {dataset['n_series']} series "
|
||||
f"(Total: {dataset['total_bars'] / 1e6:.1f} M bars)"
|
||||
)
|
||||
print("-" * 74)
|
||||
print(
|
||||
f"{'Indicator':<12} {'Parallel (ms)':>14} {'Sequential (ms)':>16} "
|
||||
f"{'Loop (ms)':>12} {'P speedup':>10}"
|
||||
)
|
||||
print("-" * 74)
|
||||
for row in payload["results"]:
|
||||
print(
|
||||
f"{row['indicator']:<12} {row['parallel_ms']:14.1f} "
|
||||
f"{row['sequential_ms']:16.1f} {row['loop_ms']:12.1f} "
|
||||
f"{row['parallel_speedup_vs_loop']:10.2f}x"
|
||||
)
|
||||
|
||||
if payload["grouped_results"]:
|
||||
print("\nGrouped Multi-Indicator Calls")
|
||||
print("-" * 64)
|
||||
print(f"{'Case':<18} {'Grouped (ms)':>14} {'Separate (ms)':>16} {'Speedup':>12}")
|
||||
print("-" * 64)
|
||||
for row in payload["grouped_results"]:
|
||||
print(
|
||||
f"{row['case']:<18} {row['grouped_ms']:14.1f} "
|
||||
f"{row['separate_ms']:16.1f} {row['speedup_vs_separate']:12.2f}x"
|
||||
)
|
||||
|
||||
if args.json_path:
|
||||
json_path = Path(args.json_path)
|
||||
json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote JSON results to {json_path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from benchmarks.metadata import benchmark_metadata
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution fallback
|
||||
from metadata import benchmark_metadata
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, cwd: Path = ROOT) -> None:
|
||||
subprocess.run(cmd, cwd=cwd, check=True)
|
||||
|
||||
|
||||
def _profile_variant(
|
||||
*,
|
||||
label: str,
|
||||
maturin_args: list[str],
|
||||
price_bars: int,
|
||||
iv_bars: int,
|
||||
window: int,
|
||||
) -> dict[str, Any]:
|
||||
_run([sys.executable, "-m", "maturin", "develop", "--release", *maturin_args])
|
||||
with tempfile.TemporaryDirectory(prefix=f"ferro_ta_{label}_") as tmp_dir:
|
||||
json_path = Path(tmp_dir) / "runtime_hotspots.json"
|
||||
_run(
|
||||
[
|
||||
sys.executable,
|
||||
"benchmarks/profile_runtime_hotspots.py",
|
||||
"--price-bars",
|
||||
str(price_bars),
|
||||
"--iv-bars",
|
||||
str(iv_bars),
|
||||
"--window",
|
||||
str(window),
|
||||
"--json",
|
||||
str(json_path),
|
||||
]
|
||||
)
|
||||
payload = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
return payload
|
||||
|
||||
|
||||
def run_simd_benchmark(
|
||||
*,
|
||||
price_bars: int = 20_000,
|
||||
iv_bars: int = 50_000,
|
||||
window: int = 252,
|
||||
) -> dict[str, Any]:
|
||||
variants = [
|
||||
("portable_release", []),
|
||||
("simd_release", ["--features", "simd"]),
|
||||
]
|
||||
reports = {
|
||||
label: _profile_variant(
|
||||
label=label,
|
||||
maturin_args=args,
|
||||
price_bars=price_bars,
|
||||
iv_bars=iv_bars,
|
||||
window=window,
|
||||
)
|
||||
for label, args in variants
|
||||
}
|
||||
|
||||
portable_rows = {
|
||||
row["name"]: row for row in reports["portable_release"]["results"]
|
||||
}
|
||||
simd_rows = {row["name"]: row for row in reports["simd_release"]["results"]}
|
||||
|
||||
comparison: list[dict[str, Any]] = []
|
||||
for name in sorted(portable_rows):
|
||||
portable = portable_rows[name]
|
||||
simd = simd_rows.get(name)
|
||||
if simd is None:
|
||||
continue
|
||||
portable_ms = float(portable["fast_ms"])
|
||||
simd_ms = float(simd["fast_ms"])
|
||||
comparison.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": portable["category"],
|
||||
"portable_ms": round(portable_ms, 4),
|
||||
"simd_ms": round(simd_ms, 4),
|
||||
"speedup_simd_vs_portable": round(
|
||||
portable_ms / simd_ms if simd_ms > 0.0 else float("inf"), 4
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
comparison.sort(
|
||||
key=lambda row: float(row["speedup_simd_vs_portable"]), reverse=True
|
||||
)
|
||||
|
||||
# Restore the default portable editable build so the workspace ends in the
|
||||
# distributable configuration.
|
||||
_run([sys.executable, "-m", "maturin", "develop", "--release"])
|
||||
|
||||
return {
|
||||
"metadata": benchmark_metadata(
|
||||
"simd",
|
||||
extra={
|
||||
"dataset": {
|
||||
"price_bars": price_bars,
|
||||
"iv_bars": iv_bars,
|
||||
"window": window,
|
||||
},
|
||||
"variants": [label for label, _ in variants],
|
||||
},
|
||||
),
|
||||
"results": comparison,
|
||||
"reports": reports,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Benchmark portable vs SIMD-enabled ferro-ta builds."
|
||||
)
|
||||
parser.add_argument("--price-bars", type=int, default=20_000)
|
||||
parser.add_argument("--iv-bars", type=int, default=50_000)
|
||||
parser.add_argument("--window", type=int, default=252)
|
||||
parser.add_argument("--json", dest="json_path")
|
||||
args = parser.parse_args()
|
||||
|
||||
payload = run_simd_benchmark(
|
||||
price_bars=args.price_bars,
|
||||
iv_bars=args.iv_bars,
|
||||
window=args.window,
|
||||
)
|
||||
|
||||
print(
|
||||
f"{'Case':<20} {'Portable (ms)':>14} {'SIMD (ms)':>12} {'SIMD speedup':>14}"
|
||||
)
|
||||
print("-" * 64)
|
||||
for row in payload["results"]:
|
||||
print(
|
||||
f"{row['name']:<20} {row['portable_ms']:14.4f} "
|
||||
f"{row['simd_ms']:12.4f} {row['speedup_simd_vs_portable']:14.2f}x"
|
||||
)
|
||||
|
||||
if args.json_path:
|
||||
path = Path(args.json_path)
|
||||
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote JSON results to {path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ferro_ta as ft
|
||||
|
||||
try:
|
||||
from benchmarks.metadata import benchmark_metadata
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution fallback
|
||||
from metadata import benchmark_metadata
|
||||
|
||||
|
||||
def _time_min(fn: Callable[[], object], rounds: int = 5) -> float:
|
||||
fn()
|
||||
samples: list[float] = []
|
||||
for _ in range(rounds):
|
||||
t0 = time.perf_counter()
|
||||
fn()
|
||||
samples.append(time.perf_counter() - t0)
|
||||
return min(samples)
|
||||
|
||||
|
||||
def _stream_close(close: np.ndarray, factory: Callable[[], Any]) -> float:
|
||||
streamer = factory()
|
||||
last = np.nan
|
||||
for value in close:
|
||||
last = streamer.update(float(value))
|
||||
return float(last) if not np.isnan(last) else np.nan
|
||||
|
||||
|
||||
def _stream_hlc(
|
||||
high: np.ndarray,
|
||||
low: np.ndarray,
|
||||
close: np.ndarray,
|
||||
factory: Callable[[], Any],
|
||||
) -> float:
|
||||
streamer = factory()
|
||||
last = np.nan
|
||||
for high_value, low_value, close_value in zip(high, low, close):
|
||||
last = streamer.update(float(high_value), float(low_value), float(close_value))
|
||||
return float(last) if not np.isnan(last) else np.nan
|
||||
|
||||
|
||||
def _stream_hlcv(
|
||||
high: np.ndarray,
|
||||
low: np.ndarray,
|
||||
close: np.ndarray,
|
||||
volume: np.ndarray,
|
||||
factory: Callable[[], Any],
|
||||
) -> float:
|
||||
streamer = factory()
|
||||
last = np.nan
|
||||
for high_value, low_value, close_value, volume_value in zip(high, low, close, volume):
|
||||
last = streamer.update(
|
||||
float(high_value),
|
||||
float(low_value),
|
||||
float(close_value),
|
||||
float(volume_value),
|
||||
)
|
||||
return float(last) if not np.isnan(last) else np.nan
|
||||
|
||||
|
||||
def run_streaming_benchmark(
|
||||
*,
|
||||
n_bars: int = 100_000,
|
||||
seed: int = 2026,
|
||||
) -> dict[str, Any]:
|
||||
rng = np.random.default_rng(seed)
|
||||
close = 100.0 + np.cumsum(rng.normal(0.0, 1.0, n_bars)).astype(np.float64)
|
||||
high = close + rng.uniform(0.1, 2.0, n_bars)
|
||||
low = close - rng.uniform(0.1, 2.0, n_bars)
|
||||
volume = rng.uniform(1_000.0, 100_000.0, n_bars)
|
||||
|
||||
cases = [
|
||||
(
|
||||
"StreamingSMA",
|
||||
"close",
|
||||
lambda: _stream_close(close, lambda: ft.StreamingSMA(period=20)),
|
||||
lambda: ft.SMA(close, timeperiod=20),
|
||||
),
|
||||
(
|
||||
"StreamingEMA",
|
||||
"close",
|
||||
lambda: _stream_close(close, lambda: ft.StreamingEMA(period=20)),
|
||||
lambda: ft.EMA(close, timeperiod=20),
|
||||
),
|
||||
(
|
||||
"StreamingRSI",
|
||||
"close",
|
||||
lambda: _stream_close(close, lambda: ft.StreamingRSI(period=14)),
|
||||
lambda: ft.RSI(close, timeperiod=14),
|
||||
),
|
||||
(
|
||||
"StreamingATR",
|
||||
"hlc",
|
||||
lambda: _stream_hlc(
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
lambda: ft.StreamingATR(period=14),
|
||||
),
|
||||
lambda: ft.ATR(high, low, close, timeperiod=14),
|
||||
),
|
||||
(
|
||||
"StreamingVWAP",
|
||||
"hlcv",
|
||||
lambda: _stream_hlcv(
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume,
|
||||
lambda: ft.StreamingVWAP(),
|
||||
),
|
||||
lambda: ft.VWAP(high, low, close, volume),
|
||||
),
|
||||
]
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for name, input_kind, stream_fn, batch_fn in cases:
|
||||
stream_s = _time_min(stream_fn)
|
||||
batch_s = _time_min(batch_fn)
|
||||
rows.append(
|
||||
{
|
||||
"indicator": name,
|
||||
"inputs": input_kind,
|
||||
"stream_total_ms": round(stream_s * 1000.0, 4),
|
||||
"batch_total_ms": round(batch_s * 1000.0, 4),
|
||||
"stream_ns_per_update": round(stream_s * 1e9 / n_bars, 2),
|
||||
"batch_ns_per_bar": round(batch_s * 1e9 / n_bars, 2),
|
||||
"updates_per_second": round(n_bars / stream_s, 2),
|
||||
"stream_over_batch_ratio": round(stream_s / batch_s, 4),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"metadata": benchmark_metadata(
|
||||
"streaming",
|
||||
extra={
|
||||
"dataset": {
|
||||
"n_bars": n_bars,
|
||||
"seed": seed,
|
||||
}
|
||||
},
|
||||
),
|
||||
"results": rows,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Benchmark streaming indicator execution.")
|
||||
parser.add_argument("--bars", type=int, default=100_000)
|
||||
parser.add_argument("--seed", type=int, default=2026)
|
||||
parser.add_argument("--json", dest="json_path")
|
||||
args = parser.parse_args()
|
||||
|
||||
payload = run_streaming_benchmark(n_bars=args.bars, seed=args.seed)
|
||||
|
||||
dataset = payload["metadata"]["dataset"]
|
||||
print(f"Streaming Benchmark: {dataset['n_bars']} bars")
|
||||
print("-" * 86)
|
||||
print(
|
||||
f"{'Indicator':<16} {'Stream (ms)':>12} {'Batch (ms)':>12} "
|
||||
f"{'ns/update':>12} {'upd/s':>12} {'ratio':>10}"
|
||||
)
|
||||
print("-" * 86)
|
||||
for row in payload["results"]:
|
||||
print(
|
||||
f"{row['indicator']:<16} {row['stream_total_ms']:12.2f} "
|
||||
f"{row['batch_total_ms']:12.2f} {row['stream_ns_per_update']:12.2f} "
|
||||
f"{row['updates_per_second']:12.1f} {row['stream_over_batch_ratio']:10.2f}"
|
||||
)
|
||||
|
||||
if args.json_path:
|
||||
path = Path(args.json_path)
|
||||
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote JSON results to {path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+210
-112
@@ -1,37 +1,34 @@
|
||||
"""
|
||||
ferro_ta vs TA-Lib speed comparison.
|
||||
|
||||
Measures throughput (M bars/s) for both libraries on the same data and parameters,
|
||||
and reports speedup (talib_time / ferro_ta_time; > 1 means ferro_ta is faster).
|
||||
Measures throughput (M bars/s) for both libraries on the same synthetic data
|
||||
and parameters. The output is intentionally evidence-heavy:
|
||||
|
||||
Requirements:
|
||||
pip install ta-lib # or conda install ta-lib
|
||||
- median timings
|
||||
- per-run timing samples
|
||||
- variability stats
|
||||
- Python-tracked peak allocation snapshots
|
||||
- machine, runtime, and build metadata
|
||||
|
||||
Run:
|
||||
python benchmarks/bench_vs_talib.py
|
||||
python benchmarks/bench_vs_talib.py --json results.json
|
||||
python benchmarks/bench_vs_talib.py --sizes 10000 100000 # default: 10k, 100k, 1M
|
||||
|
||||
If ta-lib is not installed, the script still runs and reports ferro_ta timings only (no speedup).
|
||||
Methodology: same synthetic data, same parameters, median of 7 runs after warmup.
|
||||
Environment: document Python version and OS when publishing results.
|
||||
This is meant to support a narrow claim: ferro-ta is often faster on selected
|
||||
indicators, not universally faster.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import platform
|
||||
import subprocess
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
import tracemalloc
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import talib # noqa: F401
|
||||
|
||||
TALIB_AVAILABLE = True
|
||||
except ImportError:
|
||||
TALIB_AVAILABLE = False
|
||||
@@ -39,6 +36,11 @@ except ImportError:
|
||||
|
||||
import ferro_ta
|
||||
|
||||
try:
|
||||
from benchmarks.metadata import benchmark_metadata, package_versions
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution fallback
|
||||
from metadata import benchmark_metadata, package_versions
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -46,100 +48,138 @@ import ferro_ta
|
||||
N_WARMUP = 1
|
||||
N_RUNS = 7
|
||||
DEFAULT_SIZES = [10_000, 100_000, 1_000_000]
|
||||
TIE_EPSILON = 0.05
|
||||
|
||||
_rng = np.random.default_rng(42)
|
||||
|
||||
|
||||
def _git_info() -> dict[str, Any]:
|
||||
"""Best-effort git metadata for benchmark reproducibility."""
|
||||
try:
|
||||
commit = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL
|
||||
).strip()
|
||||
except Exception:
|
||||
commit = None
|
||||
|
||||
try:
|
||||
dirty = bool(
|
||||
subprocess.check_output(
|
||||
["git", "status", "--porcelain"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
)
|
||||
except Exception:
|
||||
dirty = None
|
||||
|
||||
return {"commit": commit, "dirty": dirty}
|
||||
def _median(values: list[float]) -> float:
|
||||
ordered = sorted(values)
|
||||
mid = len(ordered) // 2
|
||||
if len(ordered) % 2:
|
||||
return ordered[mid]
|
||||
return (ordered[mid - 1] + ordered[mid]) / 2.0
|
||||
|
||||
|
||||
def _runtime_info() -> dict[str, Any]:
|
||||
def _summary_stats(samples_ms: list[float]) -> dict[str, float]:
|
||||
if not samples_ms:
|
||||
return {
|
||||
"median_ms": 0.0,
|
||||
"mean_ms": 0.0,
|
||||
"min_ms": 0.0,
|
||||
"max_ms": 0.0,
|
||||
"stddev_ms": 0.0,
|
||||
"cv_pct": 0.0,
|
||||
}
|
||||
|
||||
mean_ms = sum(samples_ms) / len(samples_ms)
|
||||
variance = (
|
||||
sum((sample - mean_ms) ** 2 for sample in samples_ms) / (len(samples_ms) - 1)
|
||||
if len(samples_ms) > 1
|
||||
else 0.0
|
||||
)
|
||||
stddev_ms = math.sqrt(variance)
|
||||
cv_pct = (stddev_ms / mean_ms * 100.0) if mean_ms else 0.0
|
||||
return {
|
||||
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"python_version": sys.version.split()[0],
|
||||
"platform": platform.platform(),
|
||||
"machine": platform.machine(),
|
||||
"median_ms": round(_median(samples_ms), 4),
|
||||
"mean_ms": round(mean_ms, 4),
|
||||
"min_ms": round(min(samples_ms), 4),
|
||||
"max_ms": round(max(samples_ms), 4),
|
||||
"stddev_ms": round(stddev_ms, 4),
|
||||
"cv_pct": round(cv_pct, 3),
|
||||
}
|
||||
|
||||
|
||||
def _outcome(speedup: float) -> str:
|
||||
if speedup > 1.0 + TIE_EPSILON:
|
||||
return "ferro_ta_win"
|
||||
if speedup < 1.0 - TIE_EPSILON:
|
||||
return "talib_win"
|
||||
return "tie"
|
||||
|
||||
|
||||
def _summary_for_size(results: list[dict[str, Any]], size: int) -> dict[str, Any]:
|
||||
rows = [r for r in results if r.get("size") == size and "speedup" in r]
|
||||
rows = [row for row in results if row.get("size") == size and "speedup" in row]
|
||||
if not rows:
|
||||
return {"size": size, "rows": 0}
|
||||
|
||||
speedups = [float(r["speedup"]) for r in rows]
|
||||
wins = sum(1 for s in speedups if s > 1.0)
|
||||
speedups_sorted = sorted(speedups)
|
||||
mid = len(speedups_sorted) // 2
|
||||
if len(speedups_sorted) % 2:
|
||||
median = speedups_sorted[mid]
|
||||
else:
|
||||
median = (speedups_sorted[mid - 1] + speedups_sorted[mid]) / 2.0
|
||||
|
||||
speedups = [float(row["speedup"]) for row in rows]
|
||||
wins = sum(1 for row in rows if row.get("outcome") == "ferro_ta_win")
|
||||
ties = sum(1 for row in rows if row.get("outcome") == "tie")
|
||||
losses = sum(1 for row in rows if row.get("outcome") == "talib_win")
|
||||
return {
|
||||
"size": size,
|
||||
"rows": len(rows),
|
||||
"wins": wins,
|
||||
"win_rate": wins / len(rows),
|
||||
"median_speedup": round(median, 4),
|
||||
"ties": ties,
|
||||
"losses": losses,
|
||||
"win_rate": round(wins / len(rows), 4),
|
||||
"non_loss_rate": round((wins + ties) / len(rows), 4),
|
||||
"median_speedup": round(_median(speedups), 4),
|
||||
"min_speedup": round(min(speedups), 4),
|
||||
"max_speedup": round(max(speedups), 4),
|
||||
"talib_wins_or_ties": [
|
||||
row["indicator"]
|
||||
for row in rows
|
||||
if row.get("outcome") in {"talib_win", "tie"}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _synthetic_ohlcv(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
# Generate OHLCV so that ta crate DataItem constraints hold: low >= 0, volume >= 0,
|
||||
# and low <= open, close <= high, high >= open (see ta DataItemBuilder::build).
|
||||
def _synthetic_ohlcv(
|
||||
n: int,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
# Generate OHLCV so that ta crate DataItem constraints hold: low >= 0,
|
||||
# volume >= 0, and low <= open, close <= high, high >= open.
|
||||
close = 100.0 + np.cumsum(_rng.standard_normal(n) * 0.5)
|
||||
open_ = close + _rng.standard_normal(n) * 0.2
|
||||
high = np.maximum(open_, close) + np.abs(_rng.standard_normal(n) * 0.3)
|
||||
low = np.minimum(open_, close) - np.abs(_rng.standard_normal(n) * 0.3)
|
||||
# Enforce high >= low and low >= 0 (ta requires non-negative prices)
|
||||
high = np.maximum(high, low)
|
||||
low = np.maximum(low, 0.0)
|
||||
high = np.maximum(high, low) # again after clamping low
|
||||
high = np.maximum(high, low)
|
||||
open_ = np.clip(open_, low, high)
|
||||
close = np.clip(close, low, high)
|
||||
volume = np.abs(_rng.standard_normal(n) * 1_000_000) + 500_000
|
||||
return open_, high, low, close, volume
|
||||
|
||||
|
||||
def _median_time_ms(fn, *args, **kwargs) -> float:
|
||||
def _timed_runs_ms(fn, *args, **kwargs) -> list[float]:
|
||||
for _ in range(N_WARMUP):
|
||||
fn(*args, **kwargs)
|
||||
times = []
|
||||
|
||||
samples_ms: list[float] = []
|
||||
for _ in range(N_RUNS):
|
||||
t0 = time.perf_counter()
|
||||
fn(*args, **kwargs)
|
||||
times.append((time.perf_counter() - t0) * 1000)
|
||||
times.sort()
|
||||
return times[len(times) // 2]
|
||||
samples_ms.append((time.perf_counter() - t0) * 1000.0)
|
||||
return samples_ms
|
||||
|
||||
|
||||
def _python_peak_bytes(fn, *args, **kwargs) -> int | None:
|
||||
try:
|
||||
tracemalloc.start()
|
||||
tracemalloc.reset_peak()
|
||||
fn(*args, **kwargs)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
return int(peak)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
|
||||
|
||||
def _throughput_m_bars_s(size: int, median_ms: float) -> float:
|
||||
if median_ms <= 0:
|
||||
return 0.0
|
||||
return (size / 1e6) / (median_ms / 1000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Benchmarked callables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Each entry: (label, ferro_ta_callable, talib_callable, needs_ohlcv)
|
||||
# ferro_ta_callable / talib_callable receive (open_, high, low, close, volume) and size;
|
||||
# they return (args, ft_kwargs, ta_kwargs) or we use a simpler convention:
|
||||
# we pass (o, h, l, c, v) and size; each runner knows how to slice and call.
|
||||
def _run_ft_sma(o, h, l, c, v, n):
|
||||
return ferro_ta.SMA(c[:n], timeperiod=14)
|
||||
|
||||
@@ -236,7 +276,6 @@ def _run_ta_wma(o, h, l, c, v, n):
|
||||
return talib.WMA(c[:n], timeperiod=14)
|
||||
|
||||
|
||||
# List of (indicator_name, ft_runner, ta_runner); skip 1M for very slow indicators if needed
|
||||
COMPARISON_CASES = [
|
||||
("SMA", _run_ft_sma, _run_ta_sma),
|
||||
("EMA", _run_ft_ema, _run_ta_ema),
|
||||
@@ -252,14 +291,14 @@ COMPARISON_CASES = [
|
||||
("WMA", _run_ft_wma, _run_ta_wma),
|
||||
]
|
||||
|
||||
# For STOCH/ADX and other heavier indicators, optionally skip 1M to keep runtime reasonable
|
||||
SKIP_1M_FOR = {"STOCH", "ADX"}
|
||||
|
||||
|
||||
def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, Any]]:
|
||||
max_size = max(sizes)
|
||||
open_, high, low, close, volume = _synthetic_ohlcv(max_size)
|
||||
results = []
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
col_label = 10
|
||||
col_size = 10
|
||||
col_ft_ms = 12
|
||||
@@ -269,12 +308,13 @@ def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, An
|
||||
col_ta_m = 12
|
||||
|
||||
if not TALIB_AVAILABLE:
|
||||
print("Note: ta-lib not installed — reporting ferro_ta timings only (no speedup).")
|
||||
print("Note: ta-lib not installed. Reporting ferro_ta timings only.")
|
||||
print("Install with: pip install ta-lib (or conda install ta-lib) for comparison.\n")
|
||||
|
||||
print(f"\nferro_ta vs TA-Lib — median of {N_RUNS} runs (after {N_WARMUP} warmup)")
|
||||
print(f"\nferro_ta vs TA-Lib — median of {N_RUNS} measured runs after {N_WARMUP} warmup")
|
||||
print(f"Sizes: {sizes}")
|
||||
print()
|
||||
|
||||
header = (
|
||||
f"{'Indicator':<{col_label}} {'Size':<{col_size}} "
|
||||
f"{'ferro_ta(ms)':<{col_ft_ms}} {'TA-Lib(ms)':<{col_ta_ms}} "
|
||||
@@ -287,82 +327,140 @@ def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, An
|
||||
for size in sizes:
|
||||
if size == 1_000_000 and name in SKIP_1M_FOR:
|
||||
continue
|
||||
ms_ft = _median_time_ms(ft_run, open_, high, low, close, volume, size)
|
||||
|
||||
ft_samples_ms = _timed_runs_ms(ft_run, open_, high, low, close, volume, size)
|
||||
ft_stats = _summary_stats(ft_samples_ms)
|
||||
ft_median_ms = float(ft_stats["median_ms"])
|
||||
ft_m_bars_s = _throughput_m_bars_s(size, ft_median_ms)
|
||||
ft_peak_bytes = _python_peak_bytes(ft_run, open_, high, low, close, volume, size)
|
||||
|
||||
row: dict[str, Any] = {
|
||||
"indicator": name,
|
||||
"size": size,
|
||||
"input_layout": {
|
||||
"dtype": "float64",
|
||||
"contiguous": True,
|
||||
},
|
||||
"ferro_ta_ms": round(ft_median_ms, 4),
|
||||
"ferro_ta_m_bars_s": round(ft_m_bars_s, 2),
|
||||
"ferro_ta_runs_ms": [round(sample, 4) for sample in ft_samples_ms],
|
||||
"ferro_ta_stats": ft_stats,
|
||||
"python_peak_allocation_bytes": {
|
||||
"ferro_ta": ft_peak_bytes,
|
||||
},
|
||||
}
|
||||
|
||||
if TALIB_AVAILABLE:
|
||||
ms_ta = _median_time_ms(ta_run, open_, high, low, close, volume, size)
|
||||
speedup = ms_ta / ms_ft if ms_ft > 0 else float("inf")
|
||||
m_bars_ft = (size / 1e6) / (ms_ft / 1000) if ms_ft > 0 else 0
|
||||
m_bars_ta = (size / 1e6) / (ms_ta / 1000) if ms_ta > 0 else 0
|
||||
ta_samples_ms = _timed_runs_ms(ta_run, open_, high, low, close, volume, size)
|
||||
ta_stats = _summary_stats(ta_samples_ms)
|
||||
ta_median_ms = float(ta_stats["median_ms"])
|
||||
ta_m_bars_s = _throughput_m_bars_s(size, ta_median_ms)
|
||||
speedup = ta_median_ms / ft_median_ms if ft_median_ms > 0 else float("inf")
|
||||
outcome = _outcome(speedup)
|
||||
ta_peak_bytes = _python_peak_bytes(
|
||||
ta_run, open_, high, low, close, volume, size
|
||||
)
|
||||
|
||||
print(
|
||||
f"{name:<{col_label}} {size:<{col_size}} "
|
||||
f"{ms_ft:<{col_ft_ms}.3f} {ms_ta:<{col_ta_ms}.3f} "
|
||||
f"{speedup:<{col_speedup}.2f}x {m_bars_ft:<{col_ft_m}.1f} {m_bars_ta:<{col_ta_m}.1f}"
|
||||
f"{ft_median_ms:<{col_ft_ms}.3f} {ta_median_ms:<{col_ta_ms}.3f} "
|
||||
f"{speedup:<{col_speedup}.2f}x {ft_m_bars_s:<{col_ft_m}.1f} {ta_m_bars_s:<{col_ta_m}.1f}"
|
||||
)
|
||||
row = {
|
||||
"indicator": name,
|
||||
"size": size,
|
||||
"ferro_ta_ms": round(ms_ft, 4),
|
||||
"talib_ms": round(ms_ta, 4),
|
||||
"speedup": round(speedup, 4),
|
||||
"ferro_ta_m_bars_s": round(m_bars_ft, 2),
|
||||
"talib_m_bars_s": round(m_bars_ta, 2),
|
||||
}
|
||||
|
||||
row.update(
|
||||
{
|
||||
"talib_ms": round(ta_median_ms, 4),
|
||||
"talib_m_bars_s": round(ta_m_bars_s, 2),
|
||||
"talib_runs_ms": [round(sample, 4) for sample in ta_samples_ms],
|
||||
"talib_stats": ta_stats,
|
||||
"speedup": round(speedup, 4),
|
||||
"outcome": outcome,
|
||||
}
|
||||
)
|
||||
row["python_peak_allocation_bytes"]["talib"] = ta_peak_bytes
|
||||
else:
|
||||
m_bars_ft = (size / 1e6) / (ms_ft / 1000) if ms_ft > 0 else 0
|
||||
print(
|
||||
f"{name:<{col_label}} {size:<{col_size}} "
|
||||
f"{ms_ft:<{col_ft_ms}.3f} {'N/A':<{col_ta_ms}} "
|
||||
f"{'N/A':<{col_speedup}} {m_bars_ft:<{col_ft_m}.1f} {'N/A':<{col_ta_m}}"
|
||||
f"{ft_median_ms:<{col_ft_ms}.3f} {'N/A':<{col_ta_ms}} "
|
||||
f"{'N/A':<{col_speedup}} {ft_m_bars_s:<{col_ft_m}.1f} {'N/A':<{col_ta_m}}"
|
||||
)
|
||||
row = {
|
||||
"indicator": name,
|
||||
"size": size,
|
||||
"ferro_ta_ms": round(ms_ft, 4),
|
||||
"ferro_ta_m_bars_s": round(m_bars_ft, 2),
|
||||
}
|
||||
|
||||
results.append(row)
|
||||
|
||||
print()
|
||||
if TALIB_AVAILABLE and results:
|
||||
wins = sum(1 for r in results if r.get("speedup", 0) > 1)
|
||||
total = len(results)
|
||||
print(f"Summary: ferro_ta faster on {wins}/{total} rows (speedup > 1).")
|
||||
wins = sum(1 for row in results if row.get("outcome") == "ferro_ta_win")
|
||||
total = len([row for row in results if "speedup" in row])
|
||||
print(f"Summary: ferro_ta ahead outside the tie band on {wins}/{total} rows.")
|
||||
print()
|
||||
|
||||
if json_path:
|
||||
metadata = benchmark_metadata(
|
||||
"benchmark_vs_talib",
|
||||
extra={
|
||||
"dataset": {
|
||||
"generator": "synthetic_ohlcv",
|
||||
"sizes": sizes,
|
||||
"dtype": "float64",
|
||||
"array_layout": "C-contiguous",
|
||||
"seed": 42,
|
||||
},
|
||||
"methodology": {
|
||||
"warmup_runs": N_WARMUP,
|
||||
"measured_runs": N_RUNS,
|
||||
"reported_metric": "median_ms",
|
||||
"speedup_definition": "talib_median_ms / ferro_ta_median_ms",
|
||||
"tie_band": f"{1.0 - TIE_EPSILON:.2f} to {1.0 + TIE_EPSILON:.2f}",
|
||||
"input_layout_notes": (
|
||||
"Benchmarks use contiguous float64 arrays. If your workload "
|
||||
"passes non-contiguous arrays or other dtypes, benchmark that "
|
||||
"separately because wrapper overhead can dominate."
|
||||
),
|
||||
"allocation_notes": (
|
||||
"python_peak_allocation_bytes is a tracemalloc snapshot of "
|
||||
"Python-tracked allocations only; it is not a full native RSS "
|
||||
"or allocator profile."
|
||||
),
|
||||
},
|
||||
"packages": package_versions("numpy", "ferro-ta", "TA-Lib"),
|
||||
},
|
||||
)
|
||||
out = {
|
||||
"schema_version": 1,
|
||||
"command": "python benchmarks/bench_vs_talib.py",
|
||||
"schema_version": 2,
|
||||
"command": " ".join(["python", *sys.argv]),
|
||||
"n_warmup": N_WARMUP,
|
||||
"n_runs": N_RUNS,
|
||||
"sizes": sizes,
|
||||
"talib_available": TALIB_AVAILABLE,
|
||||
"runtime": _runtime_info(),
|
||||
"git": _git_info(),
|
||||
"runtime": metadata["runtime"],
|
||||
"git": metadata["git"],
|
||||
"metadata": metadata,
|
||||
"summary": {
|
||||
"total_rows": len(results),
|
||||
"by_size": [_summary_for_size(results, s) for s in sizes],
|
||||
"by_size": [_summary_for_size(results, size) for size in sizes],
|
||||
},
|
||||
"results": results,
|
||||
}
|
||||
if not TALIB_AVAILABLE:
|
||||
out["note"] = "ferro_ta only — ta-lib not installed"
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
out["note"] = "ferro_ta only; ta-lib not installed"
|
||||
with open(json_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(out, handle, indent=2)
|
||||
print(f"Results written to {json_path}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="ferro_ta vs TA-Lib speed comparison")
|
||||
ap.add_argument("--json", default=None, help="Write results to JSON file")
|
||||
ap.add_argument(
|
||||
parser = argparse.ArgumentParser(description="ferro_ta vs TA-Lib speed comparison")
|
||||
parser.add_argument("--json", default=None, help="Write results to JSON file")
|
||||
parser.add_argument(
|
||||
"--sizes",
|
||||
type=int,
|
||||
nargs="+",
|
||||
default=DEFAULT_SIZES,
|
||||
help="Bar counts to benchmark (default: 10000 100000 1000000)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
args = parser.parse_args()
|
||||
run_comparison(args.sizes, args.json)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate hotspot benchmark JSON against conservative speedup floors.
|
||||
|
||||
This gate is intentionally lightweight: it checks that the optimized paths
|
||||
remain faster than their bundled reference implementations and that all
|
||||
expected cases were present in the report.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _parse_threshold_items(items: list[str]) -> dict[str, float]:
|
||||
thresholds: dict[str, float] = {}
|
||||
for item in items:
|
||||
if "=" not in item:
|
||||
raise ValueError(f"Invalid threshold '{item}', expected NAME=VALUE")
|
||||
name, value_s = item.split("=", 1)
|
||||
thresholds[name] = float(value_s)
|
||||
return thresholds
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check hotspot benchmark JSON against regression thresholds."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
default="runtime_hotspots.json",
|
||||
help="Path to JSON produced by benchmarks/profile_runtime_hotspots.py",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-speedup",
|
||||
action="append",
|
||||
default=[
|
||||
"CORREL=2.0",
|
||||
"BETA=2.0",
|
||||
"LINEARREG=2.0",
|
||||
"TSF=2.0",
|
||||
"iv_rank=1.1",
|
||||
"iv_percentile=1.1",
|
||||
"iv_zscore=1.05",
|
||||
"compute_many_close=0.85",
|
||||
"feature_matrix=0.40",
|
||||
],
|
||||
help="Required minimum speedup per named case, e.g. CORREL=5.0 (repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-cases",
|
||||
type=int,
|
||||
default=9,
|
||||
help="Minimum number of benchmark rows expected in the report",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
path = Path(args.input)
|
||||
if not path.exists():
|
||||
print(f"ERROR: hotspot benchmark file not found: {path}")
|
||||
return 1
|
||||
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
rows = payload.get("results", [])
|
||||
if len(rows) < args.min_cases:
|
||||
print(
|
||||
f"ERROR: hotspot report contains {len(rows)} rows, expected at least {args.min_cases}"
|
||||
)
|
||||
return 1
|
||||
|
||||
thresholds = _parse_threshold_items(args.min_speedup)
|
||||
rows_by_name = {str(row.get("name")): row for row in rows}
|
||||
failures: list[str] = []
|
||||
|
||||
for name, floor in thresholds.items():
|
||||
row = rows_by_name.get(name)
|
||||
if row is None:
|
||||
failures.append(f"missing row for {name}")
|
||||
continue
|
||||
|
||||
speedup = float(row.get("speedup_vs_reference", 0.0))
|
||||
fast_ms = float(row.get("fast_ms", 0.0))
|
||||
reference_ms = float(row.get("reference_ms", 0.0))
|
||||
print(
|
||||
f"{name}: fast_ms={fast_ms:.4f}, reference_ms={reference_ms:.4f}, "
|
||||
f"speedup={speedup:.4f}"
|
||||
)
|
||||
|
||||
if fast_ms <= 0.0 or reference_ms <= 0.0:
|
||||
failures.append(f"{name} has non-positive timing values")
|
||||
if speedup < floor:
|
||||
failures.append(f"{name} speedup {speedup:.4f} < floor {floor:.4f}")
|
||||
|
||||
if failures:
|
||||
print("FAILED hotspot regression policy:")
|
||||
for failure in failures:
|
||||
print(f" - {failure}")
|
||||
return 1
|
||||
|
||||
print("PASS hotspot regression policy.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from importlib import metadata as importlib_metadata
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError: # pragma: no cover
|
||||
try:
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
except ImportError: # pragma: no cover
|
||||
tomllib = None # type: ignore[assignment]
|
||||
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _run_cmd(command: list[str]) -> str | None:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
command,
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _read_toml(path: Path) -> dict[str, Any] | None:
|
||||
if tomllib is None or not path.exists():
|
||||
return None
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
return tomllib.load(handle)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _cpu_model() -> str | None:
|
||||
if sys.platform == "darwin":
|
||||
return (
|
||||
_run_cmd(["sysctl", "-n", "machdep.cpu.brand_string"])
|
||||
or _run_cmd(["sysctl", "-n", "hw.model"])
|
||||
or platform.processor()
|
||||
or None
|
||||
)
|
||||
if sys.platform.startswith("linux"):
|
||||
cpuinfo = Path("/proc/cpuinfo")
|
||||
if cpuinfo.exists():
|
||||
text = cpuinfo.read_text(encoding="utf-8", errors="ignore")
|
||||
for pattern in (r"model name\s+:\s+(.+)", r"Hardware\s+:\s+(.+)"):
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return platform.processor() or None
|
||||
if sys.platform.startswith("win"):
|
||||
return os.environ.get("PROCESSOR_IDENTIFIER") or platform.processor() or None
|
||||
return platform.processor() or None
|
||||
|
||||
|
||||
def _total_memory_bytes() -> int | None:
|
||||
if sys.platform == "darwin":
|
||||
raw = _run_cmd(["sysctl", "-n", "hw.memsize"])
|
||||
return int(raw) if raw and raw.isdigit() else None
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
meminfo = Path("/proc/meminfo")
|
||||
if meminfo.exists():
|
||||
text = meminfo.read_text(encoding="utf-8", errors="ignore")
|
||||
match = re.search(r"MemTotal:\s+(\d+)\s+kB", text)
|
||||
if match:
|
||||
return int(match.group(1)) * 1024
|
||||
return None
|
||||
|
||||
if sys.platform.startswith("win"): # pragma: no cover
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
class MEMORYSTATUSEX(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwLength", ctypes.c_ulong),
|
||||
("dwMemoryLoad", ctypes.c_ulong),
|
||||
("ullTotalPhys", ctypes.c_ulonglong),
|
||||
("ullAvailPhys", ctypes.c_ulonglong),
|
||||
("ullTotalPageFile", ctypes.c_ulonglong),
|
||||
("ullAvailPageFile", ctypes.c_ulonglong),
|
||||
("ullTotalVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
||||
]
|
||||
|
||||
status = MEMORYSTATUSEX()
|
||||
status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
|
||||
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status))
|
||||
return int(status.ullTotalPhys)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _cargo_release_profile() -> dict[str, Any] | None:
|
||||
cargo_toml = _read_toml(_ROOT / "Cargo.toml")
|
||||
if not cargo_toml:
|
||||
return None
|
||||
profile = cargo_toml.get("profile", {}).get("release")
|
||||
return profile if isinstance(profile, dict) else None
|
||||
|
||||
|
||||
def git_info() -> dict[str, Any]:
|
||||
"""Best-effort git metadata for reproducible benchmark artifacts."""
|
||||
return {
|
||||
"commit": _run_cmd(["git", "rev-parse", "HEAD"]),
|
||||
"dirty": bool(_run_cmd(["git", "status", "--porcelain"]) or ""),
|
||||
"branch": _run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]),
|
||||
}
|
||||
|
||||
|
||||
def runtime_info() -> dict[str, Any]:
|
||||
return {
|
||||
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"python_version": sys.version.split()[0],
|
||||
"python_implementation": platform.python_implementation(),
|
||||
"python_executable": sys.executable,
|
||||
"platform": platform.platform(),
|
||||
"system": platform.system(),
|
||||
"release": platform.release(),
|
||||
"machine": platform.machine(),
|
||||
"processor": platform.processor() or None,
|
||||
"cpu_model": _cpu_model(),
|
||||
"cpu_count_logical": os.cpu_count(),
|
||||
"total_memory_bytes": _total_memory_bytes(),
|
||||
}
|
||||
|
||||
|
||||
def build_info() -> dict[str, Any]:
|
||||
return {
|
||||
"rustc": _run_cmd(["rustc", "-Vv"]),
|
||||
"cargo": _run_cmd(["cargo", "-VV"]) or _run_cmd(["cargo", "-V"]),
|
||||
"cargo_release_profile": _cargo_release_profile(),
|
||||
"rustflags": os.environ.get("RUSTFLAGS"),
|
||||
"cargo_build_rustflags": os.environ.get("CARGO_BUILD_RUSTFLAGS"),
|
||||
"maturin_flags": os.environ.get("MATURIN_EXTRA_ARGS"),
|
||||
}
|
||||
|
||||
|
||||
def package_versions(*names: str) -> dict[str, str | None]:
|
||||
versions: dict[str, str | None] = {}
|
||||
for name in names:
|
||||
try:
|
||||
versions[name] = importlib_metadata.version(name)
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
versions[name] = None
|
||||
return versions
|
||||
|
||||
|
||||
def file_info(path: str | Path) -> dict[str, Any]:
|
||||
file_path = Path(path)
|
||||
data = file_path.read_bytes()
|
||||
return {
|
||||
"path": str(file_path),
|
||||
"size_bytes": file_path.stat().st_size,
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def benchmark_metadata(
|
||||
suite: str,
|
||||
*,
|
||||
fixtures: list[str | Path] | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {
|
||||
"suite": suite,
|
||||
"runtime": runtime_info(),
|
||||
"git": git_info(),
|
||||
"build": build_info(),
|
||||
"packages": package_versions("numpy", "ferro-ta"),
|
||||
}
|
||||
if fixtures:
|
||||
metadata["fixtures"] = [file_info(path) for path in fixtures]
|
||||
if extra:
|
||||
metadata.update(extra)
|
||||
return metadata
|
||||
@@ -0,0 +1,269 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ferro_ta as ft
|
||||
from ferro_ta.analysis.features import feature_matrix
|
||||
from ferro_ta.analysis.options import iv_percentile, iv_rank, iv_zscore
|
||||
from ferro_ta.data.batch import compute_many
|
||||
|
||||
try:
|
||||
from benchmarks.metadata import benchmark_metadata
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution fallback
|
||||
from metadata import benchmark_metadata
|
||||
|
||||
|
||||
def _time_min(fn: Callable[[], object], rounds: int = 5) -> float:
|
||||
fn()
|
||||
samples: list[float] = []
|
||||
for _ in range(rounds):
|
||||
t0 = time.perf_counter()
|
||||
fn()
|
||||
samples.append(time.perf_counter() - t0)
|
||||
return min(samples) * 1000.0
|
||||
|
||||
|
||||
def _naive_correl(x: np.ndarray, y: np.ndarray, window: int) -> np.ndarray:
|
||||
out = np.full(len(x), np.nan, dtype=np.float64)
|
||||
for end in range(window - 1, len(x)):
|
||||
x_window = x[end + 1 - window : end + 1]
|
||||
y_window = y[end + 1 - window : end + 1]
|
||||
mean_x = float(np.sum(x_window)) / window
|
||||
mean_y = float(np.sum(y_window)) / window
|
||||
cov = float(np.sum((x_window - mean_x) * (y_window - mean_y)))
|
||||
std_x = float(np.sqrt(np.sum((x_window - mean_x) ** 2)))
|
||||
std_y = float(np.sqrt(np.sum((y_window - mean_y) ** 2)))
|
||||
denom = std_x * std_y
|
||||
out[end] = cov / denom if denom != 0.0 else np.nan
|
||||
return out
|
||||
|
||||
|
||||
def _naive_beta(x: np.ndarray, y: np.ndarray, window: int) -> np.ndarray:
|
||||
out = np.full(len(x), np.nan, dtype=np.float64)
|
||||
for end in range(window, len(x)):
|
||||
start = end - window
|
||||
rx = np.array(
|
||||
[x[idx + 1] / x[idx] - 1.0 if x[idx] != 0.0 else np.nan for idx in range(start, end)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
ry = np.array(
|
||||
[y[idx + 1] / y[idx] - 1.0 if y[idx] != 0.0 else np.nan for idx in range(start, end)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
mean_x = float(np.sum(rx)) / window
|
||||
mean_y = float(np.sum(ry)) / window
|
||||
cov = float(np.sum((rx - mean_x) * (ry - mean_y))) / window
|
||||
var_x = float(np.sum((rx - mean_x) ** 2)) / window
|
||||
out[end] = cov / var_x if var_x != 0.0 else np.nan
|
||||
return out
|
||||
|
||||
|
||||
def _naive_linearreg(series: np.ndarray, timeperiod: int, x_value: float) -> np.ndarray:
|
||||
out = np.full(len(series), np.nan, dtype=np.float64)
|
||||
xs = np.arange(timeperiod, dtype=np.float64)
|
||||
sum_x = float(np.sum(xs))
|
||||
sum_x2 = float(np.sum(xs * xs))
|
||||
for end in range(timeperiod - 1, len(series)):
|
||||
window = series[end + 1 - timeperiod : end + 1]
|
||||
sum_y = float(np.sum(window))
|
||||
sum_xy = float(np.sum(xs * window))
|
||||
denom = timeperiod * sum_x2 - sum_x * sum_x
|
||||
slope = (timeperiod * sum_xy - sum_x * sum_y) / denom if denom != 0.0 else 0.0
|
||||
intercept = (sum_y - slope * sum_x) / timeperiod
|
||||
out[end] = intercept + slope * x_value
|
||||
return out
|
||||
|
||||
|
||||
def _old_iv_rank(iv: np.ndarray, window: int) -> np.ndarray:
|
||||
out = np.full(len(iv), np.nan, dtype=np.float64)
|
||||
for idx in range(window - 1, len(iv)):
|
||||
win = iv[idx - window + 1 : idx + 1]
|
||||
lower = float(np.nanmin(win))
|
||||
upper = float(np.nanmax(win))
|
||||
out[idx] = 0.0 if upper == lower else (iv[idx] - lower) / (upper - lower)
|
||||
return out
|
||||
|
||||
|
||||
def _old_iv_percentile(iv: np.ndarray, window: int) -> np.ndarray:
|
||||
out = np.full(len(iv), np.nan, dtype=np.float64)
|
||||
for idx in range(window - 1, len(iv)):
|
||||
win = iv[idx - window + 1 : idx + 1]
|
||||
out[idx] = float(np.sum(win <= iv[idx])) / window
|
||||
return out
|
||||
|
||||
|
||||
def _old_iv_zscore(iv: np.ndarray, window: int) -> np.ndarray:
|
||||
out = np.full(len(iv), np.nan, dtype=np.float64)
|
||||
for idx in range(window - 1, len(iv)):
|
||||
win = iv[idx - window + 1 : idx + 1]
|
||||
mean = float(np.nanmean(win))
|
||||
std = float(np.nanstd(win, ddof=0))
|
||||
out[idx] = np.nan if std == 0.0 else (iv[idx] - mean) / std
|
||||
return out
|
||||
|
||||
|
||||
def build_hotspot_report(
|
||||
*,
|
||||
price_bars: int = 20_000,
|
||||
iv_bars: int = 50_000,
|
||||
window: int = 252,
|
||||
) -> dict[str, Any]:
|
||||
rng = np.random.default_rng(2026)
|
||||
close = 100 + np.cumsum(rng.normal(0, 1, price_bars)).astype(np.float64)
|
||||
high = close + rng.uniform(0.1, 2.0, price_bars)
|
||||
low = close - rng.uniform(0.1, 2.0, price_bars)
|
||||
iv = rng.uniform(10.0, 40.0, iv_bars).astype(np.float64)
|
||||
ohlcv = {"close": close, "high": high, "low": low, "volume": np.full(price_bars, 1000.0)}
|
||||
|
||||
rows = [
|
||||
(
|
||||
"rust_kernel",
|
||||
"CORREL",
|
||||
lambda: ft.CORREL(high, low, timeperiod=30),
|
||||
lambda: _naive_correl(high, low, 30),
|
||||
),
|
||||
(
|
||||
"rust_kernel",
|
||||
"BETA",
|
||||
lambda: ft.BETA(high, low, timeperiod=5),
|
||||
lambda: _naive_beta(high, low, 5),
|
||||
),
|
||||
(
|
||||
"rust_kernel",
|
||||
"LINEARREG",
|
||||
lambda: ft.LINEARREG(close, timeperiod=14),
|
||||
lambda: _naive_linearreg(close, 14, 13.0),
|
||||
),
|
||||
(
|
||||
"rust_kernel",
|
||||
"TSF",
|
||||
lambda: ft.TSF(close, timeperiod=14),
|
||||
lambda: _naive_linearreg(close, 14, 14.0),
|
||||
),
|
||||
(
|
||||
"python_analysis",
|
||||
"iv_rank",
|
||||
lambda: iv_rank(iv, window),
|
||||
lambda: _old_iv_rank(iv, window),
|
||||
),
|
||||
(
|
||||
"python_analysis",
|
||||
"iv_percentile",
|
||||
lambda: iv_percentile(iv, window),
|
||||
lambda: _old_iv_percentile(iv, window),
|
||||
),
|
||||
(
|
||||
"python_analysis",
|
||||
"iv_zscore",
|
||||
lambda: iv_zscore(iv, window),
|
||||
lambda: _old_iv_zscore(iv, window),
|
||||
),
|
||||
(
|
||||
"ffi_grouping",
|
||||
"compute_many_close",
|
||||
lambda: compute_many(
|
||||
[
|
||||
("SMA", {"timeperiod": 10}),
|
||||
("EMA", {"timeperiod": 12}),
|
||||
("RSI", {"timeperiod": 14}),
|
||||
],
|
||||
close=close,
|
||||
),
|
||||
lambda: (
|
||||
ft.SMA(close, timeperiod=10),
|
||||
ft.EMA(close, timeperiod=12),
|
||||
ft.RSI(close, timeperiod=14),
|
||||
),
|
||||
),
|
||||
(
|
||||
"ffi_grouping",
|
||||
"feature_matrix",
|
||||
lambda: feature_matrix(
|
||||
ohlcv,
|
||||
[
|
||||
("SMA", {"timeperiod": 10}),
|
||||
("ATR", {"timeperiod": 14}),
|
||||
("ADX", {"timeperiod": 14}),
|
||||
],
|
||||
),
|
||||
lambda: {
|
||||
"SMA": ft.SMA(close, timeperiod=10),
|
||||
"ATR": ft.ATR(high, low, close, timeperiod=14),
|
||||
"ADX": ft.ADX(high, low, close, timeperiod=14),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for category, name, fast_fn, reference_fn in rows:
|
||||
fast_ms = _time_min(fast_fn)
|
||||
reference_ms = _time_min(reference_fn, rounds=1)
|
||||
results.append(
|
||||
{
|
||||
"category": category,
|
||||
"name": name,
|
||||
"fast_ms": round(fast_ms, 4),
|
||||
"reference_ms": round(reference_ms, 4),
|
||||
"speedup_vs_reference": round(reference_ms / fast_ms, 4),
|
||||
}
|
||||
)
|
||||
|
||||
results.sort(key=lambda row: row["fast_ms"], reverse=True)
|
||||
total_fast_ms = sum(float(row["fast_ms"]) for row in results) or 1.0
|
||||
for row in results:
|
||||
row["share_of_suite_pct"] = round(float(row["fast_ms"]) / total_fast_ms * 100.0, 2)
|
||||
|
||||
return {
|
||||
"metadata": benchmark_metadata(
|
||||
"runtime_hotspots",
|
||||
extra={
|
||||
"dataset": {
|
||||
"price_bars": price_bars,
|
||||
"iv_bars": iv_bars,
|
||||
"window": window,
|
||||
}
|
||||
},
|
||||
),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Profile ferro-ta runtime hotspots.")
|
||||
parser.add_argument("--price-bars", type=int, default=20_000)
|
||||
parser.add_argument("--iv-bars", type=int, default=50_000)
|
||||
parser.add_argument("--window", type=int, default=252)
|
||||
parser.add_argument("--json", dest="json_path")
|
||||
args = parser.parse_args()
|
||||
|
||||
payload = build_hotspot_report(
|
||||
price_bars=args.price_bars,
|
||||
iv_bars=args.iv_bars,
|
||||
window=args.window,
|
||||
)
|
||||
|
||||
print(f"{'Category':<16} {'Case':<18} {'Fast (ms)':>10} {'Ref (ms)':>10} {'Speedup':>10}")
|
||||
print("-" * 70)
|
||||
for row in payload["results"]:
|
||||
print(
|
||||
f"{row['category']:<16} {row['name']:<18} {row['fast_ms']:10.2f} "
|
||||
f"{row['reference_ms']:10.2f} {row['speedup_vs_reference']:10.2f}x"
|
||||
)
|
||||
|
||||
if args.json_path:
|
||||
path = Path(args.json_path)
|
||||
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
print(f"\nWrote JSON results to {path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from benchmarks.bench_batch import run_batch_benchmark
|
||||
from benchmarks.bench_simd import run_simd_benchmark
|
||||
from benchmarks.bench_streaming import run_streaming_benchmark
|
||||
from benchmarks.bench_vs_talib import run_comparison
|
||||
from benchmarks.metadata import benchmark_metadata, file_info
|
||||
from benchmarks.profile_runtime_hotspots import build_hotspot_report
|
||||
from benchmarks.test_benchmark_suite import (
|
||||
FIXTURE_PATH,
|
||||
INDICATOR_SUITE,
|
||||
_run_indicator,
|
||||
)
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution fallback
|
||||
from bench_batch import run_batch_benchmark
|
||||
from bench_simd import run_simd_benchmark
|
||||
from bench_streaming import run_streaming_benchmark
|
||||
from bench_vs_talib import run_comparison
|
||||
from metadata import benchmark_metadata, file_info
|
||||
from profile_runtime_hotspots import build_hotspot_report
|
||||
from test_benchmark_suite import FIXTURE_PATH, INDICATOR_SUITE, _run_indicator
|
||||
|
||||
|
||||
def _time_min(fn, rounds: int = 5) -> float:
|
||||
fn()
|
||||
samples: list[float] = []
|
||||
for _ in range(rounds):
|
||||
t0 = time.perf_counter()
|
||||
fn()
|
||||
samples.append(time.perf_counter() - t0)
|
||||
return min(samples) * 1000.0
|
||||
|
||||
|
||||
def build_indicator_latency_report(*, rounds: int = 5) -> dict[str, Any]:
|
||||
if not FIXTURE_PATH.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Canonical fixture not found: {FIXTURE_PATH}. "
|
||||
"Run benchmarks/fixtures/generate_canonical.py first."
|
||||
)
|
||||
|
||||
fixture = np.load(FIXTURE_PATH)
|
||||
ohlcv = {key: fixture[key] for key in fixture.files}
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for entry in INDICATOR_SUITE:
|
||||
elapsed_ms = _time_min(lambda entry=entry: _run_indicator(entry, ohlcv), rounds=rounds)
|
||||
rows.append(
|
||||
{
|
||||
"name": entry["name"],
|
||||
"inputs": entry["inputs"],
|
||||
"kwargs": entry["kwargs"],
|
||||
"elapsed_ms": round(elapsed_ms, 4),
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda row: float(row["elapsed_ms"]), reverse=True)
|
||||
return {
|
||||
"metadata": benchmark_metadata(
|
||||
"indicator_latency",
|
||||
fixtures=[FIXTURE_PATH],
|
||||
extra={
|
||||
"dataset": {
|
||||
"fixture": str(FIXTURE_PATH),
|
||||
"bars": len(ohlcv["close"]),
|
||||
"rounds": rounds,
|
||||
}
|
||||
},
|
||||
),
|
||||
"results": rows,
|
||||
}
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate reproducible performance baseline artifacts."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default="benchmarks/artifacts/latest",
|
||||
help="Directory where benchmark JSON artifacts are written",
|
||||
)
|
||||
parser.add_argument("--indicator-rounds", type=int, default=5)
|
||||
parser.add_argument("--batch-samples", type=int, default=100_000)
|
||||
parser.add_argument("--batch-series", type=int, default=100)
|
||||
parser.add_argument("--batch-seed", type=int, default=42)
|
||||
parser.add_argument("--streaming-bars", type=int, default=100_000)
|
||||
parser.add_argument("--streaming-seed", type=int, default=2026)
|
||||
parser.add_argument("--price-bars", type=int, default=20_000)
|
||||
parser.add_argument("--iv-bars", type=int, default=50_000)
|
||||
parser.add_argument("--window", type=int, default=252)
|
||||
parser.add_argument(
|
||||
"--skip-simd",
|
||||
action="store_true",
|
||||
help="Skip portable-vs-SIMD comparison",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--talib-sizes",
|
||||
type=int,
|
||||
nargs="+",
|
||||
default=[10_000, 100_000],
|
||||
help="Bar counts used for the TA-Lib comparison suite",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-talib",
|
||||
action="store_true",
|
||||
help="Skip the TA-Lib comparison artifact",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
artifacts: dict[str, str] = {}
|
||||
|
||||
indicator_path = output_dir / "indicator_latency.json"
|
||||
_write_json(
|
||||
indicator_path,
|
||||
build_indicator_latency_report(rounds=args.indicator_rounds),
|
||||
)
|
||||
artifacts["indicator_latency"] = str(indicator_path)
|
||||
|
||||
batch_path = output_dir / "batch.json"
|
||||
_write_json(
|
||||
batch_path,
|
||||
run_batch_benchmark(
|
||||
n_samples=args.batch_samples,
|
||||
n_series=args.batch_series,
|
||||
seed=args.batch_seed,
|
||||
),
|
||||
)
|
||||
artifacts["batch"] = str(batch_path)
|
||||
|
||||
streaming_path = output_dir / "streaming.json"
|
||||
_write_json(
|
||||
streaming_path,
|
||||
run_streaming_benchmark(
|
||||
n_bars=args.streaming_bars,
|
||||
seed=args.streaming_seed,
|
||||
),
|
||||
)
|
||||
artifacts["streaming"] = str(streaming_path)
|
||||
|
||||
hotspot_path = output_dir / "runtime_hotspots.json"
|
||||
_write_json(
|
||||
hotspot_path,
|
||||
build_hotspot_report(
|
||||
price_bars=args.price_bars,
|
||||
iv_bars=args.iv_bars,
|
||||
window=args.window,
|
||||
),
|
||||
)
|
||||
artifacts["runtime_hotspots"] = str(hotspot_path)
|
||||
|
||||
if not args.skip_simd:
|
||||
simd_path = output_dir / "simd.json"
|
||||
_write_json(
|
||||
simd_path,
|
||||
run_simd_benchmark(
|
||||
price_bars=args.price_bars,
|
||||
iv_bars=args.iv_bars,
|
||||
window=args.window,
|
||||
),
|
||||
)
|
||||
artifacts["simd"] = str(simd_path)
|
||||
|
||||
if not args.skip_talib:
|
||||
talib_path = output_dir / "benchmark_vs_talib.json"
|
||||
run_comparison(args.talib_sizes, str(talib_path))
|
||||
artifacts["benchmark_vs_talib"] = str(talib_path)
|
||||
|
||||
wasm_path = output_dir / "wasm.json"
|
||||
if wasm_path.exists():
|
||||
artifacts["wasm"] = str(wasm_path)
|
||||
|
||||
manifest = {
|
||||
"metadata": benchmark_metadata(
|
||||
"perf_contract",
|
||||
fixtures=[FIXTURE_PATH],
|
||||
extra={"output_dir": str(output_dir)},
|
||||
),
|
||||
"artifacts": {
|
||||
name: file_info(path)
|
||||
for name, path in artifacts.items()
|
||||
},
|
||||
}
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
_write_json(manifest_path, manifest)
|
||||
|
||||
print(f"Generated performance contract artifacts in {output_dir}")
|
||||
for name, path in artifacts.items():
|
||||
print(f" - {name}: {path}")
|
||||
print(f" - manifest: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -32,7 +32,8 @@ from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -46,7 +47,7 @@ BASELINE_PATH = pathlib.Path(__file__).parent / "baselines.npz"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ohlcv() -> Dict[str, np.ndarray]:
|
||||
def ohlcv() -> dict[str, np.ndarray]:
|
||||
"""Load canonical OHLCV fixture."""
|
||||
if not FIXTURE_PATH.exists():
|
||||
pytest.skip(f"Canonical fixture not found: {FIXTURE_PATH}")
|
||||
@@ -60,7 +61,7 @@ def ohlcv() -> Dict[str, np.ndarray]:
|
||||
|
||||
# Each entry: (name, callable, kwargs)
|
||||
# The callable receives (close,) or (high, low, close,) based on 'inputs' key.
|
||||
INDICATOR_SUITE: List[Dict[str, Any]] = [
|
||||
INDICATOR_SUITE: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "SMA_20",
|
||||
"inputs": "close",
|
||||
@@ -131,6 +132,20 @@ INDICATOR_SUITE: List[Dict[str, Any]] = [
|
||||
"fn_name": "LINEARREG",
|
||||
"kwargs": {"timeperiod": 14},
|
||||
},
|
||||
{
|
||||
"name": "LINEARREG_SLOPE_14",
|
||||
"inputs": "close",
|
||||
"fn": None,
|
||||
"fn_name": "LINEARREG_SLOPE",
|
||||
"kwargs": {"timeperiod": 14},
|
||||
},
|
||||
{
|
||||
"name": "TSF_14",
|
||||
"inputs": "close",
|
||||
"fn": None,
|
||||
"fn_name": "TSF",
|
||||
"kwargs": {"timeperiod": 14},
|
||||
},
|
||||
{
|
||||
"name": "VAR_20",
|
||||
"inputs": "close",
|
||||
@@ -138,6 +153,20 @@ INDICATOR_SUITE: List[Dict[str, Any]] = [
|
||||
"fn_name": "VAR",
|
||||
"kwargs": {"timeperiod": 20},
|
||||
},
|
||||
{
|
||||
"name": "CORREL_30",
|
||||
"inputs": "pair_hl",
|
||||
"fn": None,
|
||||
"fn_name": "CORREL",
|
||||
"kwargs": {"timeperiod": 30},
|
||||
},
|
||||
{
|
||||
"name": "BETA_5",
|
||||
"inputs": "pair_hl",
|
||||
"fn": None,
|
||||
"fn_name": "BETA",
|
||||
"kwargs": {"timeperiod": 5},
|
||||
},
|
||||
{
|
||||
"name": "CCI_14",
|
||||
"inputs": "hlc",
|
||||
@@ -161,12 +190,14 @@ def _load_fn(fn_name: str) -> Callable[..., Any]:
|
||||
return getattr(ft, fn_name)
|
||||
|
||||
|
||||
def _run_indicator(entry: Dict[str, Any], data: Dict[str, np.ndarray]) -> np.ndarray:
|
||||
def _run_indicator(entry: dict[str, Any], data: dict[str, np.ndarray]) -> np.ndarray:
|
||||
fn = _load_fn(entry["fn_name"])
|
||||
if entry["inputs"] == "close":
|
||||
result = fn(data["close"], **entry["kwargs"])
|
||||
else: # hlc
|
||||
elif entry["inputs"] == "hlc":
|
||||
result = fn(data["high"], data["low"], data["close"], **entry["kwargs"])
|
||||
else: # pair_hl
|
||||
result = fn(data["high"], data["low"], **entry["kwargs"])
|
||||
if isinstance(result, tuple):
|
||||
result = result[0]
|
||||
return np.asarray(result, dtype=np.float64)
|
||||
@@ -184,7 +215,7 @@ class TestNumericalRegression:
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_output_shape(
|
||||
self, entry: Dict[str, Any], ohlcv: Dict[str, np.ndarray]
|
||||
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
|
||||
) -> None:
|
||||
"""Indicator output length must equal input length."""
|
||||
out = _run_indicator(entry, ohlcv)
|
||||
@@ -196,7 +227,7 @@ class TestNumericalRegression:
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_warmup_is_nan(
|
||||
self, entry: Dict[str, Any], ohlcv: Dict[str, np.ndarray]
|
||||
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
|
||||
) -> None:
|
||||
"""First bar must be NaN (warm-up)."""
|
||||
out = _run_indicator(entry, ohlcv)
|
||||
@@ -205,7 +236,7 @@ class TestNumericalRegression:
|
||||
@pytest.mark.parametrize(
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_no_inf(self, entry: Dict[str, Any], ohlcv: Dict[str, np.ndarray]) -> None:
|
||||
def test_no_inf(self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]) -> None:
|
||||
"""Output must not contain infinities."""
|
||||
out = _run_indicator(entry, ohlcv)
|
||||
assert not np.any(np.isinf(out)), f"{entry['name']}: output contains Inf"
|
||||
@@ -214,7 +245,7 @@ class TestNumericalRegression:
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_last_values_stable(
|
||||
self, entry: Dict[str, Any], ohlcv: Dict[str, np.ndarray]
|
||||
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
|
||||
) -> None:
|
||||
"""Last 10 non-NaN values must be finite and stable (no sudden jumps)."""
|
||||
out = _run_indicator(entry, ohlcv)
|
||||
@@ -230,7 +261,7 @@ class TestNumericalRegression:
|
||||
"entry", INDICATOR_SUITE, ids=[e["name"] for e in INDICATOR_SUITE]
|
||||
)
|
||||
def test_regression_vs_baseline(
|
||||
self, entry: Dict[str, Any], ohlcv: Dict[str, np.ndarray]
|
||||
self, entry: dict[str, Any], ohlcv: dict[str, np.ndarray]
|
||||
) -> None:
|
||||
"""Compare last 10 values to stored baselines."""
|
||||
baselines = np.load(BASELINE_PATH)
|
||||
@@ -265,8 +296,8 @@ class TestPerformance:
|
||||
)
|
||||
def test_timing(
|
||||
self,
|
||||
entry: Dict[str, Any],
|
||||
ohlcv: Dict[str, np.ndarray],
|
||||
entry: dict[str, Any],
|
||||
ohlcv: dict[str, np.ndarray],
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
"""Time the indicator on the canonical dataset."""
|
||||
@@ -302,7 +333,7 @@ class TestPerformance:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def update_baselines(ohlcv_data: Dict[str, np.ndarray]) -> None:
|
||||
def update_baselines(ohlcv_data: dict[str, np.ndarray]) -> None:
|
||||
"""Write current indicator outputs and timings to baselines.npz.
|
||||
|
||||
Call this after intentional changes to update the stored baselines::
|
||||
@@ -314,7 +345,7 @@ def update_baselines(ohlcv_data: Dict[str, np.ndarray]) -> None:
|
||||
update_baselines(data)
|
||||
"
|
||||
"""
|
||||
store: Dict[str, np.ndarray] = {}
|
||||
store: dict[str, np.ndarray] = {}
|
||||
for entry in INDICATOR_SUITE:
|
||||
out = _run_indicator(entry, ohlcv_data)
|
||||
valid = out[~np.isnan(out)]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
Derivatives benchmark hooks.
|
||||
|
||||
These are intentionally optional and skip when `py_vollib` is unavailable.
|
||||
Run with:
|
||||
|
||||
uv run pytest benchmarks/test_derivatives_speed.py --benchmark-only -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ferro_ta.analysis.options import implied_volatility, option_price
|
||||
|
||||
|
||||
def _sample_chain(n: int = 1000) -> tuple[np.ndarray, ...]:
|
||||
spot = np.linspace(90.0, 110.0, n)
|
||||
strike = np.full(n, 100.0)
|
||||
rate = np.full(n, 0.02)
|
||||
time_to_expiry = np.full(n, 0.5)
|
||||
volatility = np.full(n, 0.2)
|
||||
return spot, strike, rate, time_to_expiry, volatility
|
||||
|
||||
|
||||
def test_ferro_ta_option_price_speed(benchmark):
|
||||
spot, strike, rate, time_to_expiry, volatility = _sample_chain()
|
||||
|
||||
benchmark.pedantic(
|
||||
lambda: option_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_type="call",
|
||||
model="bsm",
|
||||
),
|
||||
iterations=5,
|
||||
rounds=20,
|
||||
warmup_rounds=2,
|
||||
)
|
||||
|
||||
|
||||
def test_ferro_ta_implied_vol_speed(benchmark):
|
||||
spot, strike, rate, time_to_expiry, volatility = _sample_chain()
|
||||
prices = option_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_type="call",
|
||||
model="bsm",
|
||||
)
|
||||
|
||||
benchmark.pedantic(
|
||||
lambda: implied_volatility(
|
||||
prices,
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
time_to_expiry,
|
||||
option_type="call",
|
||||
model="bsm",
|
||||
),
|
||||
iterations=5,
|
||||
rounds=20,
|
||||
warmup_rounds=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
importlib.util.find_spec("py_vollib") is None,
|
||||
reason="py_vollib is optional",
|
||||
)
|
||||
def test_py_vollib_scalar_loop_baseline(benchmark):
|
||||
from py_vollib.black_scholes_merton import black_scholes_merton as py_vollib_bsm
|
||||
from py_vollib.black_scholes_merton.implied_volatility import (
|
||||
implied_volatility as py_vollib_iv,
|
||||
)
|
||||
|
||||
spot, strike, rate, time_to_expiry, volatility = _sample_chain(250)
|
||||
prices = [
|
||||
py_vollib_bsm("c", float(s), float(k), float(t), float(r), float(vol), 0.0)
|
||||
for s, k, r, t, vol in zip(spot, strike, rate, time_to_expiry, volatility)
|
||||
]
|
||||
|
||||
benchmark.pedantic(
|
||||
lambda: [
|
||||
py_vollib_iv(
|
||||
float(price),
|
||||
"c",
|
||||
float(s),
|
||||
float(k),
|
||||
float(t),
|
||||
float(r),
|
||||
0.0,
|
||||
)
|
||||
for price, s, k, r, t in zip(prices, spot, strike, rate, time_to_expiry)
|
||||
],
|
||||
iterations=3,
|
||||
rounds=10,
|
||||
warmup_rounds=1,
|
||||
)
|
||||
+6
-5
@@ -1,5 +1,5 @@
|
||||
{% set name = "ferro-ta" %}
|
||||
{% set version = "1.0.0" %}
|
||||
{% set version = "1.0.3" %}
|
||||
|
||||
package:
|
||||
name: {{ name|lower }}
|
||||
@@ -39,11 +39,12 @@ about:
|
||||
home: https://github.com/pratikbhadane24/ferro-ta
|
||||
license: MIT
|
||||
license_family: MIT
|
||||
summary: A fast Technical Analysis library — TA-Lib alternative powered by Rust and PyO3
|
||||
summary: Rust-powered Python technical analysis library with a TA-Lib-compatible API
|
||||
description: |
|
||||
ferro-ta is a drop-in TA-Lib alternative with pre-compiled wheels for all
|
||||
major platforms. It provides 155+ indicators via a Rust core and PyO3
|
||||
bindings, with optional pandas / streaming APIs.
|
||||
ferro-ta is a Rust-powered Python technical analysis library with a
|
||||
TA-Lib-compatible API and pre-compiled wheels for the supported platforms.
|
||||
It provides 155+ indicators via a Rust core and PyO3 bindings, with
|
||||
optional pandas and streaming APIs.
|
||||
doc_url: https://github.com/pratikbhadane24/ferro-ta
|
||||
dev_url: https://github.com/pratikbhadane24/ferro-ta
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
edition = "2021"
|
||||
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
|
||||
license = "MIT"
|
||||
|
||||
@@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ferro_ta_core = "1.0.1"
|
||||
ferro_ta_core = "1.0.3"
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! Or: cd crates/ferro_ta_core && cargo bench
|
||||
//!
|
||||
//! Input sizes: 1k, 10k, 100k, and 1M bars for key indicators.
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use ferro_ta_core::{momentum, overlap, volatility};
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use ferro_ta_core::{futures, momentum, options, overlap, volatility};
|
||||
use std::hint::black_box;
|
||||
|
||||
fn synthetic_close(n: usize) -> Vec<f64> {
|
||||
let mut v = Vec::with_capacity(n);
|
||||
@@ -83,12 +84,129 @@ fn bench_bbands(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_bsm_price(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("BSM_PRICE");
|
||||
for size in [1_000_usize, 10_000, 100_000] {
|
||||
let close = synthetic_close(size);
|
||||
let strikes: Vec<f64> = close.iter().map(|_| 100.0).collect();
|
||||
let vols: Vec<f64> = close.iter().map(|_| 0.2).collect();
|
||||
group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| {
|
||||
b.iter(|| {
|
||||
close
|
||||
.iter()
|
||||
.zip(strikes.iter())
|
||||
.zip(vols.iter())
|
||||
.map(|((&spot, &strike), &vol)| {
|
||||
options::pricing::black_scholes_price(
|
||||
black_box(spot),
|
||||
black_box(strike),
|
||||
black_box(0.02),
|
||||
black_box(0.0),
|
||||
black_box(0.5),
|
||||
black_box(vol),
|
||||
options::OptionKind::Call,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_implied_volatility(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("IMPLIED_VOL");
|
||||
for size in [1_000_usize, 10_000] {
|
||||
let prices: Vec<f64> = (0..size)
|
||||
.map(|i| {
|
||||
let spot = 90.0 + (i % 20) as f64;
|
||||
options::pricing::black_scholes_price(
|
||||
spot,
|
||||
100.0,
|
||||
0.02,
|
||||
0.0,
|
||||
0.5,
|
||||
0.2,
|
||||
options::OptionKind::Call,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
group.bench_with_input(BenchmarkId::from_parameter(size), &prices, |b, prices| {
|
||||
b.iter(|| {
|
||||
prices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &price)| {
|
||||
options::iv::implied_volatility(
|
||||
options::OptionContract {
|
||||
model: options::PricingModel::BlackScholes,
|
||||
underlying: black_box(90.0 + (i % 20) as f64),
|
||||
strike: black_box(100.0),
|
||||
rate: black_box(0.02),
|
||||
carry: black_box(0.0),
|
||||
time_to_expiry: black_box(0.5),
|
||||
kind: options::OptionKind::Call,
|
||||
},
|
||||
black_box(price),
|
||||
options::IvSolverConfig {
|
||||
initial_guess: black_box(0.25),
|
||||
tolerance: black_box(1e-8),
|
||||
max_iterations: black_box(100),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_smile_metrics(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("SMILE_METRICS");
|
||||
let strikes: Vec<f64> = (0..41).map(|i| 80.0 + i as f64).collect();
|
||||
let vols: Vec<f64> = strikes
|
||||
.iter()
|
||||
.map(|&k| 0.18 + ((k - 100.0).abs() / 100.0) * 0.15)
|
||||
.collect();
|
||||
group.bench_function("single_chain", |b| {
|
||||
b.iter(|| {
|
||||
options::surface::smile_metrics(
|
||||
black_box(&strikes),
|
||||
black_box(&vols),
|
||||
black_box(100.0),
|
||||
black_box(0.02),
|
||||
black_box(0.0),
|
||||
black_box(0.5),
|
||||
options::PricingModel::BlackScholes,
|
||||
)
|
||||
})
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_curve_summary(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("FUTURES_CURVE");
|
||||
let tenors = vec![0.1, 0.25, 0.5, 0.75, 1.0];
|
||||
let prices = vec![101.0, 101.8, 102.7, 103.4, 104.1];
|
||||
group.bench_function("curve_summary", |b| {
|
||||
b.iter(|| {
|
||||
futures::curve::curve_summary(black_box(100.0), black_box(&tenors), black_box(&prices))
|
||||
})
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_sma,
|
||||
bench_ema,
|
||||
bench_rsi,
|
||||
bench_atr,
|
||||
bench_bbands
|
||||
bench_bbands,
|
||||
bench_bsm_price,
|
||||
bench_implied_volatility,
|
||||
bench_smile_metrics,
|
||||
bench_curve_summary
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Basis and carry analytics.
|
||||
|
||||
/// Futures basis: futures - spot.
|
||||
pub fn basis(spot: f64, future: f64) -> f64 {
|
||||
if !spot.is_finite() || !future.is_finite() {
|
||||
f64::NAN
|
||||
} else {
|
||||
future - spot
|
||||
}
|
||||
}
|
||||
|
||||
/// Annualized simple basis return.
|
||||
pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> f64 {
|
||||
if !spot.is_finite()
|
||||
|| !future.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| time_to_expiry <= 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
(future / spot - 1.0) / time_to_expiry
|
||||
}
|
||||
|
||||
/// Implied continuously compounded carry rate.
|
||||
pub fn implied_carry_rate(spot: f64, future: f64, time_to_expiry: f64) -> f64 {
|
||||
if !spot.is_finite()
|
||||
|| !future.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| future <= 0.0
|
||||
|| time_to_expiry <= 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
(future / spot).ln() / time_to_expiry
|
||||
}
|
||||
|
||||
/// Carry spread relative to the risk-free rate.
|
||||
pub fn carry_spread(spot: f64, future: f64, rate: f64, time_to_expiry: f64) -> f64 {
|
||||
implied_carry_rate(spot, future, time_to_expiry) - rate
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{annualized_basis, basis, carry_spread, implied_carry_rate};
|
||||
|
||||
#[test]
|
||||
fn basis_helpers_work() {
|
||||
assert_eq!(basis(100.0, 103.0), 3.0);
|
||||
assert!(annualized_basis(100.0, 103.0, 0.25) > 0.0);
|
||||
assert!(implied_carry_rate(100.0, 103.0, 0.25) > 0.0);
|
||||
assert!(carry_spread(100.0, 103.0, 0.02, 0.25).is_finite());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Futures curve and term-structure analytics.
|
||||
|
||||
use super::basis;
|
||||
|
||||
/// Curve summary metrics.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CurveSummary {
|
||||
pub front_basis: f64,
|
||||
pub average_basis: f64,
|
||||
pub slope: f64,
|
||||
pub is_contango: bool,
|
||||
}
|
||||
|
||||
fn regression_slope(xs: &[f64], ys: &[f64]) -> f64 {
|
||||
if xs.len() != ys.len() || xs.len() < 2 {
|
||||
return f64::NAN;
|
||||
}
|
||||
let n = xs.len() as f64;
|
||||
let mean_x = xs.iter().sum::<f64>() / n;
|
||||
let mean_y = ys.iter().sum::<f64>() / n;
|
||||
let mut cov = 0.0;
|
||||
let mut var = 0.0;
|
||||
for (&x, &y) in xs.iter().zip(ys.iter()) {
|
||||
cov += (x - mean_x) * (y - mean_y);
|
||||
var += (x - mean_x) * (x - mean_x);
|
||||
}
|
||||
if var == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
cov / var
|
||||
}
|
||||
}
|
||||
|
||||
/// Calendar spreads between adjacent contracts.
|
||||
pub fn calendar_spreads(futures_prices: &[f64]) -> Vec<f64> {
|
||||
futures_prices.windows(2).map(|w| w[1] - w[0]).collect()
|
||||
}
|
||||
|
||||
/// Curve slope across tenor buckets.
|
||||
pub fn curve_slope(tenors: &[f64], futures_prices: &[f64]) -> f64 {
|
||||
regression_slope(tenors, futures_prices)
|
||||
}
|
||||
|
||||
/// Summary statistics for a forward curve.
|
||||
pub fn curve_summary(spot: f64, tenors: &[f64], futures_prices: &[f64]) -> CurveSummary {
|
||||
if futures_prices.is_empty() || tenors.len() != futures_prices.len() {
|
||||
return CurveSummary {
|
||||
front_basis: f64::NAN,
|
||||
average_basis: f64::NAN,
|
||||
slope: f64::NAN,
|
||||
is_contango: false,
|
||||
};
|
||||
}
|
||||
let bases: Vec<f64> = futures_prices
|
||||
.iter()
|
||||
.map(|&price| basis::basis(spot, price))
|
||||
.collect();
|
||||
let average_basis = bases.iter().sum::<f64>() / bases.len() as f64;
|
||||
let is_contango = futures_prices.windows(2).all(|w| w[1] >= w[0]);
|
||||
CurveSummary {
|
||||
front_basis: basis::basis(spot, futures_prices[0]),
|
||||
average_basis,
|
||||
slope: curve_slope(tenors, futures_prices),
|
||||
is_contango,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{calendar_spreads, curve_slope, curve_summary};
|
||||
|
||||
#[test]
|
||||
fn calendar_spreads_are_correct() {
|
||||
assert_eq!(calendar_spreads(&[100.0, 101.0, 103.0]), vec![1.0, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_summary_detects_contango() {
|
||||
let summary = curve_summary(100.0, &[0.1, 0.5, 1.0], &[101.0, 102.0, 104.0]);
|
||||
assert!(summary.is_contango);
|
||||
assert!(curve_slope(&[0.1, 0.5, 1.0], &[101.0, 102.0, 104.0]) > 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Futures analytics core.
|
||||
|
||||
pub mod basis;
|
||||
pub mod curve;
|
||||
pub mod roll;
|
||||
pub mod synthetic;
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Continuous futures roll helpers.
|
||||
|
||||
/// Weighted stitching using next-contract weights in [0, 1].
|
||||
pub fn weighted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec<f64> {
|
||||
if front.len() != next.len() || front.len() != next_weights.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
front
|
||||
.iter()
|
||||
.zip(next.iter())
|
||||
.zip(next_weights.iter())
|
||||
.map(|((&f, &n), &w)| f * (1.0 - w) + n * w)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn roll_index(weights: &[f64]) -> Option<usize> {
|
||||
if weights.is_empty() {
|
||||
return None;
|
||||
}
|
||||
weights
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, w)| **w >= 0.5)
|
||||
.map(|(idx, _)| idx)
|
||||
.or_else(|| weights.iter().position(|w| *w > 0.0))
|
||||
.or(Some(weights.len() - 1))
|
||||
}
|
||||
|
||||
/// Back-adjusted continuous series using the roll date implied by the weights.
|
||||
pub fn back_adjusted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec<f64> {
|
||||
if front.len() != next.len() || front.len() != next_weights.len() || front.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let idx = roll_index(next_weights).unwrap_or(front.len() - 1);
|
||||
let gap = next[idx] - front[idx];
|
||||
front
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &value)| if i < idx { value + gap } else { next[i] })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ratio-adjusted continuous series using the roll date implied by the weights.
|
||||
pub fn ratio_adjusted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec<f64> {
|
||||
if front.len() != next.len() || front.len() != next_weights.len() || front.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let idx = roll_index(next_weights).unwrap_or(front.len() - 1);
|
||||
let ratio = if front[idx] == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
next[idx] / front[idx]
|
||||
};
|
||||
front
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &value)| if i < idx { value * ratio } else { next[i] })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Annualized roll yield from front and next prices.
|
||||
pub fn roll_yield(front_price: f64, next_price: f64, time_to_expiry: f64) -> f64 {
|
||||
if !front_price.is_finite()
|
||||
|| !next_price.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| front_price <= 0.0
|
||||
|| time_to_expiry <= 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
(next_price / front_price - 1.0) / time_to_expiry
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
back_adjusted_continuous, ratio_adjusted_continuous, roll_yield, weighted_continuous,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn weighted_roll_blends_contracts() {
|
||||
let out = weighted_continuous(&[100.0, 101.0], &[102.0, 103.0], &[0.0, 1.0]);
|
||||
assert_eq!(out, vec![100.0, 103.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjusted_rolls_return_full_series() {
|
||||
let weights = [0.0, 0.25, 0.75, 1.0];
|
||||
assert_eq!(
|
||||
back_adjusted_continuous(
|
||||
&[100.0, 101.0, 102.0, 103.0],
|
||||
&[101.0, 102.0, 103.0, 104.0],
|
||||
&weights
|
||||
)
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
ratio_adjusted_continuous(
|
||||
&[100.0, 101.0, 102.0, 103.0],
|
||||
&[101.0, 102.0, 103.0, 104.0],
|
||||
&weights
|
||||
)
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
assert!(roll_yield(100.0, 102.0, 30.0 / 365.0).is_finite());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Synthetic futures helpers built from put-call parity.
|
||||
|
||||
/// Synthetic forward price from call/put parity.
|
||||
pub fn synthetic_forward(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
) -> f64 {
|
||||
if !call_price.is_finite()
|
||||
|| !put_price.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !rate.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
(call_price - put_price) * (rate * time_to_expiry).exp() + strike
|
||||
}
|
||||
|
||||
/// Synthetic spot price implied by call/put parity with continuous carry.
|
||||
pub fn synthetic_spot(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
) -> f64 {
|
||||
if !call_price.is_finite()
|
||||
|| !put_price.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !rate.is_finite()
|
||||
|| !carry.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
(call_price - put_price + strike * (-rate * time_to_expiry).exp())
|
||||
* (carry * time_to_expiry).exp()
|
||||
}
|
||||
|
||||
/// Put-call parity residual. Zero means the inputs are parity-consistent.
|
||||
pub fn parity_gap(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
) -> f64 {
|
||||
call_price
|
||||
- put_price
|
||||
- (spot * (-carry * time_to_expiry).exp() - strike * (-rate * time_to_expiry).exp())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parity_gap, synthetic_forward};
|
||||
|
||||
#[test]
|
||||
fn synthetic_forward_is_consistent() {
|
||||
let forward = synthetic_forward(8.0, 5.0, 100.0, 0.02, 0.5);
|
||||
assert!(forward > 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_gap_zero_when_consistent() {
|
||||
let gap = parity_gap(10.45, 5.57, 100.0, 100.0, 0.05, 0.0, 1.0);
|
||||
assert!(gap.abs() < 0.05);
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,10 @@ assert!((sma[2] - 2.0).abs() < 1e-10);
|
||||
```
|
||||
*/
|
||||
|
||||
pub mod futures;
|
||||
pub mod math;
|
||||
pub mod momentum;
|
||||
pub mod options;
|
||||
pub mod overlap;
|
||||
pub mod statistic;
|
||||
pub mod volatility;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Option chain analytics helpers.
|
||||
|
||||
use super::greeks::model_greeks;
|
||||
use super::{ChainGreeksContext, OptionContract, OptionEvaluation, OptionKind};
|
||||
|
||||
/// Return the index of the strike closest to the reference price.
|
||||
pub fn atm_index(strikes: &[f64], reference_price: f64) -> Option<usize> {
|
||||
if strikes.is_empty() || !reference_price.is_finite() {
|
||||
return None;
|
||||
}
|
||||
strikes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, strike)| strike.is_finite())
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
(*a - reference_price)
|
||||
.abs()
|
||||
.partial_cmp(&(*b - reference_price).abs())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(idx, _)| idx)
|
||||
}
|
||||
|
||||
/// Label strikes as ITM (1), ATM (0), or OTM (-1).
|
||||
pub fn label_moneyness(strikes: &[f64], reference_price: f64, kind: OptionKind) -> Vec<i8> {
|
||||
let mut labels = Vec::with_capacity(strikes.len());
|
||||
let atm_idx = atm_index(strikes, reference_price);
|
||||
for (idx, &strike) in strikes.iter().enumerate() {
|
||||
if Some(idx) == atm_idx {
|
||||
labels.push(0);
|
||||
continue;
|
||||
}
|
||||
let label = match kind {
|
||||
OptionKind::Call => {
|
||||
if strike < reference_price {
|
||||
1
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
OptionKind::Put => {
|
||||
if strike > reference_price {
|
||||
1
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
};
|
||||
labels.push(label);
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
/// Select a strike relative to the ATM strike by offset steps.
|
||||
pub fn select_strike_by_offset(
|
||||
strikes: &[f64],
|
||||
reference_price: f64,
|
||||
offset: isize,
|
||||
) -> Option<f64> {
|
||||
let idx = atm_index(strikes, reference_price)? as isize + offset;
|
||||
if idx < 0 || idx >= strikes.len() as isize {
|
||||
None
|
||||
} else {
|
||||
Some(strikes[idx as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the strike whose delta is closest to the requested target.
|
||||
pub fn select_strike_by_delta(
|
||||
strikes: &[f64],
|
||||
vols: &[f64],
|
||||
context: ChainGreeksContext,
|
||||
target_delta: f64,
|
||||
) -> Option<f64> {
|
||||
if strikes.len() != vols.len() || strikes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
strikes
|
||||
.iter()
|
||||
.zip(vols.iter())
|
||||
.filter(|(strike, vol)| strike.is_finite() && vol.is_finite())
|
||||
.min_by(|(strike_a, vol_a), (strike_b, vol_b)| {
|
||||
let delta_a = model_greeks(OptionEvaluation {
|
||||
contract: OptionContract {
|
||||
model: context.model,
|
||||
underlying: context.reference_price,
|
||||
strike: **strike_a,
|
||||
rate: context.rate,
|
||||
carry: context.carry,
|
||||
time_to_expiry: context.time_to_expiry,
|
||||
kind: context.kind,
|
||||
},
|
||||
volatility: **vol_a,
|
||||
})
|
||||
.delta;
|
||||
let delta_b = model_greeks(OptionEvaluation {
|
||||
contract: OptionContract {
|
||||
model: context.model,
|
||||
underlying: context.reference_price,
|
||||
strike: **strike_b,
|
||||
rate: context.rate,
|
||||
carry: context.carry,
|
||||
time_to_expiry: context.time_to_expiry,
|
||||
kind: context.kind,
|
||||
},
|
||||
volatility: **vol_b,
|
||||
})
|
||||
.delta;
|
||||
(delta_a - target_delta)
|
||||
.abs()
|
||||
.partial_cmp(&(delta_b - target_delta).abs())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(strike, _)| *strike)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{atm_index, label_moneyness, select_strike_by_delta, select_strike_by_offset};
|
||||
use crate::options::{ChainGreeksContext, OptionKind, PricingModel};
|
||||
|
||||
#[test]
|
||||
fn atm_index_finds_nearest() {
|
||||
let strikes = [90.0, 100.0, 110.0];
|
||||
assert_eq!(atm_index(&strikes, 103.0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moneyness_labels_calls() {
|
||||
let strikes = [90.0, 100.0, 110.0];
|
||||
assert_eq!(
|
||||
label_moneyness(&strikes, 100.0, OptionKind::Call),
|
||||
vec![1, 0, -1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_selects_expected_strike() {
|
||||
let strikes = [90.0, 100.0, 110.0];
|
||||
assert_eq!(select_strike_by_offset(&strikes, 101.0, 1), Some(110.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_selection_returns_a_strike() {
|
||||
let strikes = [80.0, 90.0, 100.0, 110.0, 120.0];
|
||||
let vols = [0.28, 0.24, 0.20, 0.22, 0.26];
|
||||
let strike = select_strike_by_delta(
|
||||
&strikes,
|
||||
&vols,
|
||||
ChainGreeksContext {
|
||||
model: PricingModel::BlackScholes,
|
||||
reference_price: 100.0,
|
||||
rate: 0.01,
|
||||
carry: 0.0,
|
||||
time_to_expiry: 0.5,
|
||||
kind: OptionKind::Call,
|
||||
},
|
||||
0.25,
|
||||
);
|
||||
assert!(strike.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Option Greeks.
|
||||
|
||||
use super::normal::{cdf, pdf};
|
||||
use super::pricing::{black_76_price, black_scholes_price};
|
||||
use super::{Greeks, OptionEvaluation, OptionKind, PricingModel};
|
||||
|
||||
fn bs_inputs_valid(
|
||||
underlying: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
) -> bool {
|
||||
underlying.is_finite()
|
||||
&& strike.is_finite()
|
||||
&& rate.is_finite()
|
||||
&& carry.is_finite()
|
||||
&& time_to_expiry.is_finite()
|
||||
&& volatility.is_finite()
|
||||
&& underlying > 0.0
|
||||
&& strike > 0.0
|
||||
&& time_to_expiry > 0.0
|
||||
&& volatility > 0.0
|
||||
}
|
||||
|
||||
fn numerical_theta<F>(time_to_expiry: f64, price_fn: F) -> f64
|
||||
where
|
||||
F: Fn(f64) -> f64,
|
||||
{
|
||||
if time_to_expiry <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let h = time_to_expiry.clamp(1e-6, 1.0 / 365.0);
|
||||
let t_minus = (time_to_expiry - h).max(1e-8);
|
||||
let t_plus = time_to_expiry + h;
|
||||
let price_minus = price_fn(t_minus);
|
||||
let price_plus = price_fn(t_plus);
|
||||
(price_minus - price_plus) / (t_plus - t_minus)
|
||||
}
|
||||
|
||||
/// Black-Scholes-Merton Greeks.
|
||||
pub fn black_scholes_greeks(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
dividend_yield: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> Greeks {
|
||||
if !bs_inputs_valid(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
dividend_yield,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
) {
|
||||
return Greeks {
|
||||
delta: f64::NAN,
|
||||
gamma: f64::NAN,
|
||||
vega: f64::NAN,
|
||||
theta: f64::NAN,
|
||||
rho: f64::NAN,
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let discount = (-rate * time_to_expiry).exp();
|
||||
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 delta = match kind {
|
||||
OptionKind::Call => carry_discount * cdf(d1),
|
||||
OptionKind::Put => carry_discount * (cdf(d1) - 1.0),
|
||||
};
|
||||
let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t);
|
||||
let vega = spot * carry_discount * pdf_d1 * sqrt_t;
|
||||
let theta = match kind {
|
||||
OptionKind::Call => {
|
||||
-(spot * carry_discount * pdf_d1 * volatility) / (2.0 * sqrt_t)
|
||||
- rate * strike * discount * cdf(d2)
|
||||
+ dividend_yield * spot * carry_discount * cdf(d1)
|
||||
}
|
||||
OptionKind::Put => {
|
||||
-(spot * carry_discount * pdf_d1 * volatility) / (2.0 * sqrt_t)
|
||||
+ rate * strike * discount * cdf(-d2)
|
||||
- dividend_yield * spot * carry_discount * cdf(-d1)
|
||||
}
|
||||
};
|
||||
let rho = match kind {
|
||||
OptionKind::Call => strike * time_to_expiry * discount * cdf(d2),
|
||||
OptionKind::Put => -strike * time_to_expiry * discount * cdf(-d2),
|
||||
};
|
||||
|
||||
Greeks {
|
||||
delta,
|
||||
gamma,
|
||||
vega,
|
||||
theta,
|
||||
rho,
|
||||
}
|
||||
}
|
||||
|
||||
/// Black-76 Greeks with respect to the forward.
|
||||
pub fn black_76_greeks(
|
||||
forward: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> Greeks {
|
||||
if !bs_inputs_valid(forward, strike, rate, 0.0, time_to_expiry, volatility) {
|
||||
return Greeks {
|
||||
delta: f64::NAN,
|
||||
gamma: f64::NAN,
|
||||
vega: f64::NAN,
|
||||
theta: f64::NAN,
|
||||
rho: f64::NAN,
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let discount = (-rate * time_to_expiry).exp();
|
||||
let d1 =
|
||||
((forward / strike).ln() + 0.5 * volatility * volatility * time_to_expiry) / sigma_sqrt_t;
|
||||
let pdf_d1 = pdf(d1);
|
||||
|
||||
let delta = match kind {
|
||||
OptionKind::Call => discount * cdf(d1),
|
||||
OptionKind::Put => -discount * cdf(-d1),
|
||||
};
|
||||
let gamma = discount * pdf_d1 / (forward * sigma_sqrt_t);
|
||||
let vega = discount * forward * pdf_d1 * sqrt_t;
|
||||
let theta = numerical_theta(time_to_expiry, |t| {
|
||||
black_76_price(forward, strike, rate, t, volatility, kind)
|
||||
});
|
||||
let rho =
|
||||
-time_to_expiry * black_76_price(forward, strike, rate, time_to_expiry, volatility, kind);
|
||||
|
||||
Greeks {
|
||||
delta,
|
||||
gamma,
|
||||
vega,
|
||||
theta,
|
||||
rho,
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-dispatched Greeks.
|
||||
pub fn model_greeks(input: OptionEvaluation) -> Greeks {
|
||||
let contract = input.contract;
|
||||
match contract.model {
|
||||
PricingModel::BlackScholes => black_scholes_greeks(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.carry,
|
||||
contract.time_to_expiry,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
PricingModel::Black76 => black_76_greeks(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.time_to_expiry,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Price derivative with respect to calendar time using the selected model.
|
||||
pub fn model_theta(input: OptionEvaluation) -> f64 {
|
||||
let contract = input.contract;
|
||||
numerical_theta(contract.time_to_expiry, |t| match contract.model {
|
||||
PricingModel::BlackScholes => black_scholes_price(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.carry,
|
||||
t,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
PricingModel::Black76 => black_76_price(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
t,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{black_76_greeks, black_scholes_greeks};
|
||||
use crate::options::OptionKind;
|
||||
|
||||
#[test]
|
||||
fn bsm_greeks_are_finite() {
|
||||
let g = black_scholes_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
assert!(g.delta.is_finite());
|
||||
assert!(g.gamma.is_finite());
|
||||
assert!(g.vega.is_finite());
|
||||
assert!(g.theta.is_finite());
|
||||
assert!(g.rho.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_76_greeks_are_finite() {
|
||||
let g = black_76_greeks(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Put);
|
||||
assert!(g.delta.is_finite());
|
||||
assert!(g.gamma.is_finite());
|
||||
assert!(g.vega.is_finite());
|
||||
assert!(g.theta.is_finite());
|
||||
assert!(g.rho.is_finite());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Implied volatility inversion and IV-series helpers.
|
||||
|
||||
use super::greeks::model_greeks;
|
||||
use super::pricing::{model_price, price_lower_bound, price_upper_bound};
|
||||
use super::{IvSolverConfig, OptionContract, OptionEvaluation};
|
||||
|
||||
/// Solve implied volatility with guarded Newton iterations and bisection fallback.
|
||||
pub fn implied_volatility(
|
||||
contract: OptionContract,
|
||||
target_price: f64,
|
||||
config: IvSolverConfig,
|
||||
) -> f64 {
|
||||
if !target_price.is_finite()
|
||||
|| !contract.underlying.is_finite()
|
||||
|| !contract.strike.is_finite()
|
||||
|| !contract.rate.is_finite()
|
||||
|| !contract.carry.is_finite()
|
||||
|| !contract.time_to_expiry.is_finite()
|
||||
|| target_price < 0.0
|
||||
|| contract.underlying <= 0.0
|
||||
|| contract.strike <= 0.0
|
||||
|| contract.time_to_expiry < 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
if contract.time_to_expiry == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let lower = price_lower_bound(contract);
|
||||
let upper = price_upper_bound(contract);
|
||||
if target_price < lower - config.tolerance || target_price > upper + config.tolerance {
|
||||
return f64::NAN;
|
||||
}
|
||||
if (target_price - lower).abs() <= config.tolerance {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut low_vol = 1e-9;
|
||||
let mut high_vol = config.initial_guess.max(0.25).max(low_vol * 10.0);
|
||||
let mut high_price = model_price(OptionEvaluation {
|
||||
contract,
|
||||
volatility: high_vol,
|
||||
});
|
||||
while high_price < target_price && high_vol < 10.0 {
|
||||
high_vol *= 2.0;
|
||||
high_price = model_price(OptionEvaluation {
|
||||
contract,
|
||||
volatility: high_vol,
|
||||
});
|
||||
}
|
||||
if high_price < target_price {
|
||||
return f64::NAN;
|
||||
}
|
||||
|
||||
let mut vol = config.initial_guess.clamp(low_vol, high_vol).max(1e-4);
|
||||
for _ in 0..config.max_iterations.max(1) {
|
||||
let price = model_price(OptionEvaluation {
|
||||
contract,
|
||||
volatility: vol,
|
||||
});
|
||||
let diff = price - target_price;
|
||||
if diff.abs() <= config.tolerance {
|
||||
return vol;
|
||||
}
|
||||
|
||||
if diff > 0.0 {
|
||||
high_vol = high_vol.min(vol);
|
||||
} else {
|
||||
low_vol = low_vol.max(vol);
|
||||
}
|
||||
|
||||
let vega = model_greeks(OptionEvaluation {
|
||||
contract,
|
||||
volatility: vol,
|
||||
})
|
||||
.vega;
|
||||
|
||||
let next = if vega.is_finite() && vega.abs() > 1e-10 {
|
||||
let candidate = vol - diff / vega;
|
||||
if candidate > low_vol && candidate < high_vol {
|
||||
candidate
|
||||
} else {
|
||||
0.5 * (low_vol + high_vol)
|
||||
}
|
||||
} else {
|
||||
0.5 * (low_vol + high_vol)
|
||||
};
|
||||
vol = next;
|
||||
}
|
||||
|
||||
let final_price = model_price(OptionEvaluation {
|
||||
contract,
|
||||
volatility: vol,
|
||||
});
|
||||
if (final_price - target_price).abs() <= config.tolerance * 10.0 {
|
||||
vol
|
||||
} else {
|
||||
f64::NAN
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_window(window: usize) -> bool {
|
||||
window >= 1
|
||||
}
|
||||
|
||||
/// Rolling IV rank.
|
||||
pub fn iv_rank(iv_series: &[f64], window: usize) -> Vec<f64> {
|
||||
let n = iv_series.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if !validate_window(window) || n < window {
|
||||
return out;
|
||||
}
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let mut min_v = f64::INFINITY;
|
||||
let mut max_v = f64::NEG_INFINITY;
|
||||
for &v in &iv_series[start..=end] {
|
||||
if v.is_finite() {
|
||||
min_v = min_v.min(v);
|
||||
max_v = max_v.max(v);
|
||||
}
|
||||
}
|
||||
let current = iv_series[end];
|
||||
if !current.is_finite() || !min_v.is_finite() || !max_v.is_finite() {
|
||||
out[end] = f64::NAN;
|
||||
continue;
|
||||
}
|
||||
let spread = max_v - min_v;
|
||||
out[end] = if spread == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(current - min_v) / spread
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling IV percentile.
|
||||
pub fn iv_percentile(iv_series: &[f64], window: usize) -> Vec<f64> {
|
||||
let n = iv_series.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if !validate_window(window) || n < window {
|
||||
return out;
|
||||
}
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let current = iv_series[end];
|
||||
let count = iv_series[start..=end]
|
||||
.iter()
|
||||
.filter(|&&v| v <= current)
|
||||
.count();
|
||||
out[end] = count as f64 / window as f64;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling IV z-score.
|
||||
pub fn iv_zscore(iv_series: &[f64], window: usize) -> Vec<f64> {
|
||||
let n = iv_series.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if !validate_window(window) || n < window {
|
||||
return out;
|
||||
}
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let mut count = 0usize;
|
||||
let mut sum = 0.0;
|
||||
for &v in &iv_series[start..=end] {
|
||||
if v.is_finite() {
|
||||
count += 1;
|
||||
sum += v;
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
out[end] = f64::NAN;
|
||||
continue;
|
||||
}
|
||||
let mean = sum / count as f64;
|
||||
let mut var = 0.0;
|
||||
for &v in &iv_series[start..=end] {
|
||||
if v.is_finite() {
|
||||
let d = v - mean;
|
||||
var += d * d;
|
||||
}
|
||||
}
|
||||
let std = (var / count as f64).sqrt();
|
||||
let current = iv_series[end];
|
||||
out[end] = if !current.is_finite() || std == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
(current - mean) / std
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{implied_volatility, iv_percentile, iv_rank, iv_zscore};
|
||||
use crate::options::pricing::black_scholes_price;
|
||||
use crate::options::{IvSolverConfig, OptionContract, OptionKind, PricingModel};
|
||||
|
||||
#[test]
|
||||
fn solver_recovers_input_vol() {
|
||||
let price = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
let iv = implied_volatility(
|
||||
OptionContract {
|
||||
model: PricingModel::BlackScholes,
|
||||
underlying: 100.0,
|
||||
strike: 100.0,
|
||||
rate: 0.05,
|
||||
carry: 0.0,
|
||||
time_to_expiry: 1.0,
|
||||
kind: OptionKind::Call,
|
||||
},
|
||||
price,
|
||||
IvSolverConfig {
|
||||
initial_guess: 0.3,
|
||||
tolerance: 1e-8,
|
||||
max_iterations: 100,
|
||||
},
|
||||
);
|
||||
assert!((iv - 0.2).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iv_helpers_match_expected_values() {
|
||||
let iv = [10.0, 20.0, 30.0, 15.0, 22.0];
|
||||
let rank = iv_rank(&iv, 3);
|
||||
let pct = iv_percentile(&iv, 3);
|
||||
let z = iv_zscore(&iv, 3);
|
||||
assert!(rank[0].is_nan() && rank[1].is_nan());
|
||||
assert!((rank[2] - 1.0).abs() < 1e-12);
|
||||
assert!((pct[3] - (1.0 / 3.0)).abs() < 1e-12);
|
||||
assert!((z[2] - 1.224_744_871).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Options analytics core.
|
||||
//!
|
||||
//! This module contains pricing, Greeks, implied volatility inversion,
|
||||
//! IV-series helpers, and smile/chain utilities. The public API is scalar-first
|
||||
//! and is used by the PyO3 bridge to build vectorized batch functions.
|
||||
|
||||
pub mod chain;
|
||||
pub mod greeks;
|
||||
pub mod iv;
|
||||
pub mod normal;
|
||||
pub mod pricing;
|
||||
pub mod surface;
|
||||
|
||||
/// Option side.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OptionKind {
|
||||
/// Call option.
|
||||
Call,
|
||||
/// Put option.
|
||||
Put,
|
||||
}
|
||||
|
||||
impl OptionKind {
|
||||
/// Returns +1 for calls and -1 for puts.
|
||||
pub fn sign(self) -> f64 {
|
||||
match self {
|
||||
Self::Call => 1.0,
|
||||
Self::Put => -1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supported pricing models.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PricingModel {
|
||||
/// Black-Scholes-Merton with continuous carry/dividend yield.
|
||||
BlackScholes,
|
||||
/// Black-76 using the forward price as the underlying input.
|
||||
Black76,
|
||||
}
|
||||
|
||||
/// Primary first-order Greeks returned by the pricing engine.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Greeks {
|
||||
pub delta: f64,
|
||||
pub gamma: f64,
|
||||
pub vega: f64,
|
||||
pub theta: f64,
|
||||
pub rho: f64,
|
||||
}
|
||||
|
||||
/// Shared contract fields for model-based option analytics.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OptionContract {
|
||||
pub model: PricingModel,
|
||||
pub underlying: f64,
|
||||
pub strike: f64,
|
||||
pub rate: f64,
|
||||
pub carry: f64,
|
||||
pub time_to_expiry: f64,
|
||||
pub kind: OptionKind,
|
||||
}
|
||||
|
||||
/// Contract plus volatility for pricing and Greeks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OptionEvaluation {
|
||||
pub contract: OptionContract,
|
||||
pub volatility: f64,
|
||||
}
|
||||
|
||||
/// Solver configuration for implied volatility inversion.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct IvSolverConfig {
|
||||
pub initial_guess: f64,
|
||||
pub tolerance: f64,
|
||||
pub max_iterations: usize,
|
||||
}
|
||||
|
||||
/// Shared context for strike selection and smile analytics.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ChainGreeksContext {
|
||||
pub model: PricingModel,
|
||||
pub reference_price: f64,
|
||||
pub rate: f64,
|
||||
pub carry: f64,
|
||||
pub time_to_expiry: f64,
|
||||
pub kind: OptionKind,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Normal distribution helpers.
|
||||
|
||||
const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
|
||||
|
||||
/// Standard normal probability density function.
|
||||
pub fn pdf(x: f64) -> f64 {
|
||||
INV_SQRT_2PI * (-0.5 * x * x).exp()
|
||||
}
|
||||
|
||||
/// Standard normal cumulative distribution function.
|
||||
///
|
||||
/// Uses a common Abramowitz-Stegun style approximation that is fast and
|
||||
/// sufficiently accurate for option pricing work.
|
||||
pub fn cdf(x: f64) -> f64 {
|
||||
let ax = x.abs();
|
||||
let t = 1.0 / (1.0 + 0.231_641_9 * ax);
|
||||
let poly = (((((1.330_274_429 * t - 1.821_255_978) * t) + 1.781_477_937) * t - 0.356_563_782)
|
||||
* t
|
||||
+ 0.319_381_530)
|
||||
* t;
|
||||
let approx = 1.0 - pdf(ax) * poly;
|
||||
if x >= 0.0 {
|
||||
approx
|
||||
} else {
|
||||
1.0 - approx
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cdf, pdf};
|
||||
|
||||
#[test]
|
||||
fn cdf_is_reasonable() {
|
||||
assert!((cdf(0.0) - 0.5).abs() < 1e-7);
|
||||
assert!((cdf(1.0) - 0.841_344_746).abs() < 5e-5);
|
||||
assert!((cdf(-1.0) - 0.158_655_254).abs() < 5e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_is_reasonable() {
|
||||
assert!((pdf(0.0) - 0.398_942_280_4).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Option pricing models.
|
||||
|
||||
use super::normal::cdf;
|
||||
use super::{OptionContract, OptionEvaluation, OptionKind, PricingModel};
|
||||
|
||||
fn invalid_inputs(underlying: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
|
||||
!underlying.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| !volatility.is_finite()
|
||||
|| underlying <= 0.0
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
|| volatility < 0.0
|
||||
}
|
||||
|
||||
/// Black-Scholes-Merton price with continuous carry/dividend yield.
|
||||
pub fn black_scholes_price(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
dividend_yield: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> f64 {
|
||||
if invalid_inputs(spot, strike, time_to_expiry, volatility) || !rate.is_finite() {
|
||||
return f64::NAN;
|
||||
}
|
||||
if time_to_expiry == 0.0 {
|
||||
return match kind {
|
||||
OptionKind::Call => (spot - strike).max(0.0),
|
||||
OptionKind::Put => (strike - spot).max(0.0),
|
||||
};
|
||||
}
|
||||
|
||||
let discount = (-rate * time_to_expiry).exp();
|
||||
let carry_discount = (-dividend_yield * time_to_expiry).exp();
|
||||
if volatility == 0.0 {
|
||||
return match kind {
|
||||
OptionKind::Call => (spot * carry_discount - strike * discount).max(0.0),
|
||||
OptionKind::Put => (strike * discount - spot * carry_discount).max(0.0),
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let d1 = ((spot / strike).ln()
|
||||
+ (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry)
|
||||
/ sigma_sqrt_t;
|
||||
let d2 = d1 - sigma_sqrt_t;
|
||||
|
||||
match kind {
|
||||
OptionKind::Call => spot * carry_discount * cdf(d1) - strike * discount * cdf(d2),
|
||||
OptionKind::Put => strike * discount * cdf(-d2) - spot * carry_discount * cdf(-d1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Black-76 price using the forward price as the underlying input.
|
||||
pub fn black_76_price(
|
||||
forward: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> f64 {
|
||||
if invalid_inputs(forward, strike, time_to_expiry, volatility) || !rate.is_finite() {
|
||||
return f64::NAN;
|
||||
}
|
||||
let discount = (-rate * time_to_expiry).exp();
|
||||
if time_to_expiry == 0.0 {
|
||||
return discount
|
||||
* match kind {
|
||||
OptionKind::Call => (forward - strike).max(0.0),
|
||||
OptionKind::Put => (strike - forward).max(0.0),
|
||||
};
|
||||
}
|
||||
if volatility == 0.0 {
|
||||
return discount
|
||||
* match kind {
|
||||
OptionKind::Call => (forward - strike).max(0.0),
|
||||
OptionKind::Put => (strike - forward).max(0.0),
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let d1 =
|
||||
((forward / strike).ln() + 0.5 * volatility * volatility * time_to_expiry) / sigma_sqrt_t;
|
||||
let d2 = d1 - sigma_sqrt_t;
|
||||
|
||||
let signed = kind.sign();
|
||||
discount * signed * (forward * cdf(signed * d1) - strike * cdf(signed * d2))
|
||||
}
|
||||
|
||||
/// Model-dispatched option price.
|
||||
pub fn model_price(input: OptionEvaluation) -> f64 {
|
||||
let contract = input.contract;
|
||||
match contract.model {
|
||||
PricingModel::BlackScholes => black_scholes_price(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.carry,
|
||||
contract.time_to_expiry,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
PricingModel::Black76 => black_76_price(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.time_to_expiry,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower no-arbitrage bound for the option price.
|
||||
pub fn price_lower_bound(contract: OptionContract) -> f64 {
|
||||
match contract.model {
|
||||
PricingModel::BlackScholes => {
|
||||
let discount = (-contract.rate * contract.time_to_expiry).exp();
|
||||
let carry_discount = (-contract.carry * contract.time_to_expiry).exp();
|
||||
match contract.kind {
|
||||
OptionKind::Call => {
|
||||
(contract.underlying * carry_discount - contract.strike * discount).max(0.0)
|
||||
}
|
||||
OptionKind::Put => {
|
||||
(contract.strike * discount - contract.underlying * carry_discount).max(0.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
PricingModel::Black76 => {
|
||||
let discount = (-contract.rate * contract.time_to_expiry).exp();
|
||||
discount
|
||||
* match contract.kind {
|
||||
OptionKind::Call => (contract.underlying - contract.strike).max(0.0),
|
||||
OptionKind::Put => (contract.strike - contract.underlying).max(0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper no-arbitrage bound for the option price.
|
||||
pub fn price_upper_bound(contract: OptionContract) -> f64 {
|
||||
match contract.model {
|
||||
PricingModel::BlackScholes => match contract.kind {
|
||||
OptionKind::Call => {
|
||||
contract.underlying * (-contract.carry * contract.time_to_expiry).exp()
|
||||
}
|
||||
OptionKind::Put => contract.strike * (-contract.rate * contract.time_to_expiry).exp(),
|
||||
},
|
||||
PricingModel::Black76 => {
|
||||
let discount = (-contract.rate * contract.time_to_expiry).exp();
|
||||
discount
|
||||
* match contract.kind {
|
||||
OptionKind::Call => contract.underlying,
|
||||
OptionKind::Put => contract.strike,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{black_76_price, black_scholes_price};
|
||||
use crate::options::OptionKind;
|
||||
|
||||
#[test]
|
||||
fn black_scholes_prices_are_reasonable() {
|
||||
let call = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
let put = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Put);
|
||||
assert!((call - 10.4506).abs() < 1e-3);
|
||||
assert!((put - 5.5735).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_76_prices_are_reasonable() {
|
||||
let call = black_76_price(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Call);
|
||||
let put = black_76_price(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Put);
|
||||
assert!((call - 7.730_148).abs() < 1e-3);
|
||||
assert!((put - 7.730_148).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Smile and surface analytics helpers.
|
||||
|
||||
use super::chain::atm_index;
|
||||
use super::greeks::model_greeks;
|
||||
use super::{ChainGreeksContext, OptionContract, OptionEvaluation, OptionKind, PricingModel};
|
||||
|
||||
/// Smile summary metrics.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SmileMetrics {
|
||||
pub atm_iv: f64,
|
||||
pub risk_reversal_25d: f64,
|
||||
pub butterfly_25d: f64,
|
||||
pub skew_slope: f64,
|
||||
pub convexity: f64,
|
||||
}
|
||||
|
||||
/// Linear interpolation helper.
|
||||
pub fn linear_interpolate(xs: &[f64], ys: &[f64], target: f64) -> f64 {
|
||||
if xs.len() != ys.len() || xs.is_empty() {
|
||||
return f64::NAN;
|
||||
}
|
||||
if target <= xs[0] {
|
||||
return ys[0];
|
||||
}
|
||||
for i in 1..xs.len() {
|
||||
if target <= xs[i] {
|
||||
let x0 = xs[i - 1];
|
||||
let x1 = xs[i];
|
||||
let y0 = ys[i - 1];
|
||||
let y1 = ys[i];
|
||||
let w = if x1 == x0 {
|
||||
0.0
|
||||
} else {
|
||||
(target - x0) / (x1 - x0)
|
||||
};
|
||||
return y0 + w * (y1 - y0);
|
||||
}
|
||||
}
|
||||
ys[ys.len() - 1]
|
||||
}
|
||||
|
||||
/// ATM implied volatility by nearest strike.
|
||||
pub fn atm_iv(strikes: &[f64], vols: &[f64], reference_price: f64) -> f64 {
|
||||
if strikes.len() != vols.len() || strikes.is_empty() || !reference_price.is_finite() {
|
||||
return f64::NAN;
|
||||
}
|
||||
atm_index(strikes, reference_price)
|
||||
.and_then(|idx| vols.get(idx).copied())
|
||||
.unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn regression_slope(xs: &[f64], ys: &[f64]) -> f64 {
|
||||
if xs.len() != ys.len() || xs.len() < 2 {
|
||||
return f64::NAN;
|
||||
}
|
||||
let n = xs.len() as f64;
|
||||
let mean_x = xs.iter().sum::<f64>() / n;
|
||||
let mean_y = ys.iter().sum::<f64>() / n;
|
||||
let mut cov = 0.0;
|
||||
let mut var = 0.0;
|
||||
for (&x, &y) in xs.iter().zip(ys.iter()) {
|
||||
cov += (x - mean_x) * (y - mean_y);
|
||||
var += (x - mean_x) * (x - mean_x);
|
||||
}
|
||||
if var == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
cov / var
|
||||
}
|
||||
}
|
||||
|
||||
fn closest_delta_iv(
|
||||
strikes: &[f64],
|
||||
vols: &[f64],
|
||||
context: ChainGreeksContext,
|
||||
target_delta: f64,
|
||||
) -> f64 {
|
||||
let mut best_iv = f64::NAN;
|
||||
let mut best_distance = f64::INFINITY;
|
||||
for (&strike, &vol) in strikes.iter().zip(vols.iter()) {
|
||||
if !strike.is_finite() || !vol.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let delta = model_greeks(OptionEvaluation {
|
||||
contract: OptionContract {
|
||||
model: context.model,
|
||||
underlying: context.reference_price,
|
||||
strike,
|
||||
rate: context.rate,
|
||||
carry: context.carry,
|
||||
time_to_expiry: context.time_to_expiry,
|
||||
kind: context.kind,
|
||||
},
|
||||
volatility: vol,
|
||||
})
|
||||
.delta;
|
||||
if !delta.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let distance = (delta - target_delta).abs();
|
||||
if distance < best_distance {
|
||||
best_distance = distance;
|
||||
best_iv = vol;
|
||||
}
|
||||
}
|
||||
best_iv
|
||||
}
|
||||
|
||||
/// Smile metrics from a single expiry slice.
|
||||
pub fn smile_metrics(
|
||||
strikes: &[f64],
|
||||
vols: &[f64],
|
||||
reference_price: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
model: PricingModel,
|
||||
) -> SmileMetrics {
|
||||
if strikes.len() != vols.len() || strikes.len() < 3 || reference_price <= 0.0 {
|
||||
return SmileMetrics {
|
||||
atm_iv: f64::NAN,
|
||||
risk_reversal_25d: f64::NAN,
|
||||
butterfly_25d: f64::NAN,
|
||||
skew_slope: f64::NAN,
|
||||
convexity: f64::NAN,
|
||||
};
|
||||
}
|
||||
|
||||
let atm_idx = match atm_index(strikes, reference_price) {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
return SmileMetrics {
|
||||
atm_iv: f64::NAN,
|
||||
risk_reversal_25d: f64::NAN,
|
||||
butterfly_25d: f64::NAN,
|
||||
skew_slope: f64::NAN,
|
||||
convexity: f64::NAN,
|
||||
}
|
||||
}
|
||||
};
|
||||
let atm_iv = vols[atm_idx];
|
||||
|
||||
let call_25 = closest_delta_iv(
|
||||
strikes,
|
||||
vols,
|
||||
ChainGreeksContext {
|
||||
model,
|
||||
reference_price,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind: OptionKind::Call,
|
||||
},
|
||||
0.25,
|
||||
);
|
||||
let put_25 = closest_delta_iv(
|
||||
strikes,
|
||||
vols,
|
||||
ChainGreeksContext {
|
||||
model,
|
||||
reference_price,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind: OptionKind::Put,
|
||||
},
|
||||
-0.25,
|
||||
);
|
||||
let risk_reversal_25d = call_25 - put_25;
|
||||
let butterfly_25d = 0.5 * (call_25 + put_25) - atm_iv;
|
||||
|
||||
let log_moneyness: Vec<f64> = strikes
|
||||
.iter()
|
||||
.map(|&k| (k / reference_price).ln())
|
||||
.collect();
|
||||
let skew_slope = regression_slope(&log_moneyness, vols);
|
||||
let convexity = if atm_idx > 0 && atm_idx + 1 < strikes.len() {
|
||||
let x0 = log_moneyness[atm_idx - 1];
|
||||
let x1 = log_moneyness[atm_idx];
|
||||
let x2 = log_moneyness[atm_idx + 1];
|
||||
let y0 = vols[atm_idx - 1];
|
||||
let y1 = vols[atm_idx];
|
||||
let y2 = vols[atm_idx + 1];
|
||||
let left = if x1 == x0 { 0.0 } else { (y1 - y0) / (x1 - x0) };
|
||||
let right = if x2 == x1 { 0.0 } else { (y2 - y1) / (x2 - x1) };
|
||||
right - left
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
|
||||
SmileMetrics {
|
||||
atm_iv,
|
||||
risk_reversal_25d,
|
||||
butterfly_25d,
|
||||
skew_slope,
|
||||
convexity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Term-structure slope from (tenor, atm_iv) points.
|
||||
pub fn term_structure_slope(tenors: &[f64], atm_ivs: &[f64]) -> f64 {
|
||||
regression_slope(tenors, atm_ivs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{atm_iv, smile_metrics, term_structure_slope};
|
||||
use crate::options::PricingModel;
|
||||
|
||||
#[test]
|
||||
fn atm_selection_works() {
|
||||
let strikes = [90.0, 100.0, 110.0];
|
||||
let vols = [0.24, 0.20, 0.22];
|
||||
assert!((atm_iv(&strikes, &vols, 102.0) - 0.20).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smile_metrics_are_finite() {
|
||||
let strikes = [80.0, 90.0, 100.0, 110.0, 120.0];
|
||||
let vols = [0.30, 0.25, 0.20, 0.22, 0.27];
|
||||
let metrics = smile_metrics(
|
||||
&strikes,
|
||||
&vols,
|
||||
100.0,
|
||||
0.02,
|
||||
0.0,
|
||||
0.5,
|
||||
PricingModel::BlackScholes,
|
||||
);
|
||||
assert!(metrics.atm_iv.is_finite());
|
||||
assert!(metrics.skew_slope.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn term_slope_is_reasonable() {
|
||||
let tenors = [0.1, 0.5, 1.0];
|
||||
let vols = [0.18, 0.20, 0.22];
|
||||
assert!(term_structure_slope(&tenors, &vols) > 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
Adjacent Tooling
|
||||
================
|
||||
|
||||
These modules are useful, but they are secondary to ferro-ta's core identity as
|
||||
a Python technical analysis library.
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Area
|
||||
- Status
|
||||
- What it is
|
||||
* - Derivatives analytics
|
||||
- Adjacent
|
||||
- Options pricing, Greeks, implied volatility helpers, futures basis,
|
||||
curve, and roll utilities. See :doc:`derivatives`.
|
||||
* - Agent workflow wrappers
|
||||
- Adjacent
|
||||
- Tool and workflow helpers for agent-style integrations. See
|
||||
`docs/agentic.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_.
|
||||
* - MCP server
|
||||
- Experimental or adjacent
|
||||
- Exposes selected ferro-ta capabilities to MCP-compatible clients. See
|
||||
`docs/mcp.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_.
|
||||
* - WASM package
|
||||
- Experimental
|
||||
- Browser and Node.js package with a smaller indicator subset. See
|
||||
`wasm/README.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/wasm/README.md>`_.
|
||||
* - GPU backend
|
||||
- Experimental
|
||||
- Optional PyTorch-backed acceleration for a limited subset of indicators.
|
||||
See `docs/gpu-backend.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/gpu-backend.md>`_.
|
||||
* - Plugin system
|
||||
- Experimental
|
||||
- Registry and plugin packaging model for custom indicators. See
|
||||
:doc:`plugins`.
|
||||
|
||||
How to read the project
|
||||
-----------------------
|
||||
|
||||
When evaluating ferro-ta:
|
||||
|
||||
- Start with the core library docs, migration guide, support matrix, and benchmarks.
|
||||
- Treat adjacent tooling as opt-in layers, not as proof that the core indicator
|
||||
library is broader or more stable than it is.
|
||||
- Check the release notes and stability policy before depending on experimental
|
||||
surfaces in production.
|
||||
@@ -0,0 +1,22 @@
|
||||
Analysis Modules
|
||||
================
|
||||
|
||||
.. automodule:: ferro_ta.analysis.options
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
.. automodule:: ferro_ta.analysis.futures
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
.. automodule:: ferro_ta.analysis.options_strategy
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
.. automodule:: ferro_ta.analysis.derivatives_payoff
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -17,3 +17,4 @@ API Reference
|
||||
extended
|
||||
streaming
|
||||
batch
|
||||
analysis
|
||||
|
||||
+122
-21
@@ -1,20 +1,130 @@
|
||||
Benchmarks
|
||||
==========
|
||||
|
||||
The authoritative benchmark workflow is in ``benchmarks/``:
|
||||
The benchmark suite is meant to support a narrow claim: ferro-ta is often
|
||||
faster on selected indicators, and the evidence is published in a reproducible
|
||||
form.
|
||||
|
||||
What is published
|
||||
-----------------
|
||||
|
||||
The authoritative benchmark workflow lives in ``benchmarks/``:
|
||||
|
||||
- Cross-library speed suite: ``benchmarks/test_speed.py``
|
||||
- Cross-library accuracy suite: ``benchmarks/test_accuracy.py``
|
||||
- TA-Lib head-to-head speed script: ``benchmarks/bench_vs_talib.py``
|
||||
- TA-Lib head-to-head script: ``benchmarks/bench_vs_talib.py``
|
||||
- Table generation from benchmark JSON: ``benchmarks/benchmark_table.py``
|
||||
- Perf-contract artifact bundle: ``benchmarks/run_perf_contract.py``
|
||||
|
||||
Run the cross-library speed suite on 100,000 bars:
|
||||
Latest checked-in TA-Lib artifact
|
||||
---------------------------------
|
||||
|
||||
The current checked-in TA-Lib comparison artifact benchmarks contiguous
|
||||
``float64`` arrays at 10k and 100k bars on an ``Apple M3 Max`` with 14 logical
|
||||
cores, about 38.7 GB RAM, ``CPython 3.13.5``, and ``Rust 1.91.1`` using the
|
||||
default release profile (``lto = true``, ``codegen-units = 1``).
|
||||
|
||||
Summary from ``benchmarks/artifacts/latest/benchmark_vs_talib.json``:
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Size
|
||||
- Rows
|
||||
- ferro-ta wins
|
||||
- Median speedup
|
||||
- TA-Lib wins or ties
|
||||
* - ``10,000``
|
||||
- 12
|
||||
- 6
|
||||
- ``1.0850x``
|
||||
- ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV``
|
||||
* - ``100,000``
|
||||
- 12
|
||||
- 6
|
||||
- ``1.0784x``
|
||||
- ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV``
|
||||
|
||||
Examples from the 100k-bar run:
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Indicator
|
||||
- ferro-ta
|
||||
- TA-Lib
|
||||
- Speedup
|
||||
- Read
|
||||
* - ``SMA``
|
||||
- ``0.0985 ms``
|
||||
- ``0.2241 ms``
|
||||
- ``2.2751x``
|
||||
- clear ferro-ta win
|
||||
* - ``BBANDS``
|
||||
- ``0.2122 ms``
|
||||
- ``0.4966 ms``
|
||||
- ``2.3402x``
|
||||
- clear ferro-ta win
|
||||
* - ``MACD``
|
||||
- ``0.5152 ms``
|
||||
- ``0.7111 ms``
|
||||
- ``1.3801x``
|
||||
- ferro-ta win
|
||||
* - ``STOCH``
|
||||
- ``1.7064 ms``
|
||||
- ``0.7603 ms``
|
||||
- ``0.4455x``
|
||||
- TA-Lib win
|
||||
* - ``ADX``
|
||||
- ``0.7910 ms``
|
||||
- ``0.5769 ms``
|
||||
- ``0.7294x``
|
||||
- TA-Lib win
|
||||
* - ``ATR``
|
||||
- ``0.5087 ms``
|
||||
- ``0.5147 ms``
|
||||
- ``1.0118x``
|
||||
- tie on this machine
|
||||
|
||||
Methodology notes
|
||||
-----------------
|
||||
|
||||
- The head-to-head script uses the same synthetic OHLCV generator, the same
|
||||
parameters, and the same contiguous ``float64`` array layout for both
|
||||
libraries.
|
||||
- Reported speedup is ``TA-Lib median time / ferro-ta median time``.
|
||||
- The script uses 1 warmup run and 7 measured runs per case, and now records
|
||||
the full per-run timing samples, not just one selected number.
|
||||
- Published JSON artifacts include machine/runtime metadata, git metadata, Rust
|
||||
toolchain and build-profile metadata, per-run variance statistics, and
|
||||
Python-tracked peak allocation snapshots.
|
||||
- Allocation snapshots are based on ``tracemalloc`` and capture Python-tracked
|
||||
allocations only; they are not full native RSS profiles.
|
||||
- If your workload uses non-contiguous arrays, different dtypes, or different
|
||||
batch sizes, benchmark that exact workload. Those factors can materially
|
||||
change the result.
|
||||
|
||||
Reproduce the TA-Lib comparison
|
||||
-------------------------------
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install ta-lib
|
||||
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
|
||||
The JSON output is the main artifact to review when publishing performance
|
||||
claims.
|
||||
|
||||
Cross-library suite
|
||||
-------------------
|
||||
|
||||
Run the broader speed suite on 100,000 bars:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v
|
||||
|
||||
Selected results on a modern CPU (100,000 bars):
|
||||
Selected throughput examples from the checked-in table:
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
@@ -38,25 +148,16 @@ Selected results on a modern CPU (100,000 bars):
|
||||
* - ``STOCH``
|
||||
- 33 M bars/s
|
||||
|
||||
Multi-size and JSON output
|
||||
--------------------------
|
||||
Perf-contract artifacts
|
||||
-----------------------
|
||||
|
||||
To build the markdown comparison table from the JSON output:
|
||||
Use the perf-contract runner when you want a compact, machine-readable artifact
|
||||
bundle for single-series latency, batch throughput, streaming throughput, and
|
||||
hotspot attribution:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
uv run python benchmarks/benchmark_table.py
|
||||
uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest
|
||||
|
||||
Comparison with TA-Lib
|
||||
----------------------
|
||||
|
||||
To measure speedup vs TA-Lib on the same data and parameters, run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install ta-lib
|
||||
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
|
||||
See the README “Performance vs TA-Lib” section for methodology and a
|
||||
representative comparison table. The script prints a table of median times and
|
||||
speedup (TA-Lib time / ferro_ta time); use ``--json out.json`` to save results.
|
||||
See ``benchmarks/README.md`` for the detailed benchmark playbook and the
|
||||
checked-in comparison tables.
|
||||
|
||||
+32
-45
@@ -1,55 +1,42 @@
|
||||
Changelog
|
||||
=========
|
||||
Release Notes
|
||||
=============
|
||||
|
||||
1.0.0 (2026)
|
||||
------------
|
||||
These docs track package version ``1.0.3``.
|
||||
|
||||
**Candlestick Pattern Parity (61/61)**
|
||||
1.0.3 (2026-03-24)
|
||||
------------------
|
||||
|
||||
- All 61 TA-Lib candlestick patterns implemented in Rust
|
||||
- ``{-100, 0, 100}`` convention, consistent with TA-Lib
|
||||
- Added top-level package metadata helpers such as ``ferro_ta.__version__``,
|
||||
``ferro_ta.about()``, and ``ferro_ta.methods()``.
|
||||
- Added a standalone derivatives benchmark artifact for selected options
|
||||
pricing, IV, Greeks, and Black-76 comparisons.
|
||||
- Simplified release version bumps with a single script and updated release
|
||||
guidance.
|
||||
|
||||
**Numerical Parity**
|
||||
1.0.2 (2026-03-24)
|
||||
------------------
|
||||
|
||||
- RSI, ATR/NATR, CCI, BETA, STOCH, STOCHRSI, ADX/DX/DI/DM all rewritten to match TA-Lib seeding
|
||||
- Removed dependency on ``ta`` crate for these indicators
|
||||
- Improved rolling statistical kernels and several Python analysis hotspots.
|
||||
- Added reproducible perf-contract artifacts, TA-Lib regression guards, and
|
||||
updated benchmark tooling.
|
||||
- Tightened the public benchmark documentation so claims, caveats, and evidence
|
||||
live closer together.
|
||||
|
||||
**Streaming / Incremental API**
|
||||
1.0.1 (2026-03-24)
|
||||
------------------
|
||||
|
||||
- New :mod:`ferro_ta.streaming` module with bar-by-bar stateful classes
|
||||
- ``StreamingSMA``, ``StreamingEMA``, ``StreamingRSI``, ``StreamingATR``, ``StreamingBBands``, ``StreamingMACD``, ``StreamingStoch``, ``StreamingVWAP``, ``StreamingSupertrend``
|
||||
- Improved release automation for PyPI, crates.io, and npm.
|
||||
- Fixed CI workflow issues that caused otherwise healthy release jobs to fail.
|
||||
- Ensured the published WASM package includes its built ``pkg/`` artifacts.
|
||||
|
||||
**Pandas Integration**
|
||||
1.0.0 (2026-03-23)
|
||||
------------------
|
||||
|
||||
- All indicators transparently accept ``pandas.Series`` and return ``Series`` with original index preserved
|
||||
- Multi-output functions return tuples of ``Series``
|
||||
- First stable release of the Rust-backed Python technical analysis library.
|
||||
- Shipped broad TA-Lib coverage, streaming APIs, extended indicators, and the
|
||||
initial Sphinx documentation set.
|
||||
- Added the benchmark suite, release playbook, and compatibility/testing
|
||||
scaffolding for stable releases.
|
||||
|
||||
**Math Operators / Transforms**
|
||||
|
||||
- 24 functions: arithmetic (ADD/SUB/MULT/DIV), rolling (SUM/MAX/MIN/MAXINDEX/MININDEX), element-wise math transforms
|
||||
- SUM uses vectorized cumsum (220× faster than a naive loop)
|
||||
|
||||
**Documentation**
|
||||
|
||||
- Sphinx documentation setup with API reference, quickstart guide, and benchmarks page
|
||||
|
||||
**Benchmarking Suite**
|
||||
|
||||
- ``benchmarks/test_speed.py`` for authoritative ``pytest-benchmark`` speed runs
|
||||
- ``benchmarks/bench_vs_talib.py`` for TA-Lib head-to-head comparisons
|
||||
|
||||
**Extended Indicators**
|
||||
|
||||
- ``VWAP`` — cumulative or rolling window
|
||||
- ``SUPERTREND`` — ATR-based trend signal
|
||||
|
||||
**Additional Extended Indicators**
|
||||
|
||||
- ``ICHIMOKU`` — Ichimoku Cloud (Tenkan, Kijun, Senkou A/B, Chikou)
|
||||
- ``DONCHIAN`` — Donchian Channels (upper, middle, lower)
|
||||
- ``PIVOT_POINTS`` — Classic, Fibonacci, and Camarilla pivot points
|
||||
|
||||
**Type Stubs & Packaging**
|
||||
|
||||
- ``python/ferro_ta/__init__.pyi`` type stub for IDE auto-completion
|
||||
- ``pyproject.toml``: added optional extras (benchmark, pandas, docs, all), project URLs, Python 3.10–3.13 classifiers
|
||||
For the canonical project changelog, including the full per-version details,
|
||||
see `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_.
|
||||
|
||||
+32
-2
@@ -4,7 +4,17 @@
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError: # pragma: no cover
|
||||
try:
|
||||
import tomli as tomllib # type: ignore[no-redef]
|
||||
except ImportError: # pragma: no cover
|
||||
tomllib = None # type: ignore[assignment]
|
||||
|
||||
# Add the python source directory so autodoc can import ferro_ta
|
||||
# Only add if ferro_ta is not already installed (e.g. from a wheel in CI)
|
||||
@@ -17,8 +27,28 @@ except ImportError:
|
||||
project = "ferro-ta"
|
||||
copyright = "2024, pratikbhadane24"
|
||||
author = "pratikbhadane24"
|
||||
# Version from env (e.g. set in CI from git tag) or default
|
||||
release = os.environ.get("FERRO_TA_VERSION", "1.0.0")
|
||||
|
||||
|
||||
def _default_release() -> str:
|
||||
if tomllib is None:
|
||||
return "0+unknown"
|
||||
pyproject_toml = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
try:
|
||||
if tomllib is not None:
|
||||
with pyproject_toml.open("rb") as handle:
|
||||
data = tomllib.load(handle)
|
||||
return data.get("project", {}).get("version", "0+unknown")
|
||||
text = pyproject_toml.read_text(encoding="utf-8")
|
||||
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "0+unknown"
|
||||
except Exception:
|
||||
return "0+unknown"
|
||||
|
||||
|
||||
# Version from env (e.g. set in CI from git tag) or default to pyproject.toml
|
||||
release = os.environ.get("FERRO_TA_VERSION", _default_release())
|
||||
version = release
|
||||
|
||||
# -- General configuration ----------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Derivatives Analytics
|
||||
|
||||
`ferro-ta` now includes a Rust-backed derivatives analytics layer focused on
|
||||
research, simulation, and risk analysis.
|
||||
|
||||
## Modules
|
||||
|
||||
- `ferro_ta.analysis.options`
|
||||
- Black-Scholes-Merton and Black-76 pricing
|
||||
- Delta, gamma, vega, theta, rho
|
||||
- Implied volatility inversion with guarded Newton + bisection fallback
|
||||
- IV rank / percentile / z-score
|
||||
- Smile metrics: ATM IV, 25-delta risk reversal, butterfly, skew slope, convexity
|
||||
- Chain helpers: moneyness labels and strike selection by offset or delta
|
||||
- `ferro_ta.analysis.futures`
|
||||
- Synthetic forwards and parity diagnostics
|
||||
- Basis, annualized basis, implied carry, carry spread
|
||||
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted
|
||||
- Curve analytics: calendar spreads, slope, contango summary
|
||||
- `ferro_ta.analysis.options_strategy`
|
||||
- Typed strategy schemas for expiry selectors, strike selectors, multi-leg presets,
|
||||
risk controls, cost assumptions, and simulation limits
|
||||
- `ferro_ta.analysis.derivatives_payoff`
|
||||
- Multi-leg payoff aggregation
|
||||
- Portfolio-level Greeks aggregation across option and futures legs
|
||||
|
||||
## Model conventions
|
||||
|
||||
- `model="bsm"` expects the underlying input to be spot and `carry` to represent
|
||||
a continuous dividend yield or generic carry term.
|
||||
- `model="black76"` expects the underlying input to be the forward price.
|
||||
- Volatility and rates use decimal units:
|
||||
- `0.20` means 20% annualized volatility
|
||||
- `0.05` means 5% annualized rate
|
||||
- `time_to_expiry` is expressed in years.
|
||||
|
||||
## Quick examples
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.options import greeks, implied_volatility, option_price
|
||||
|
||||
price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call")
|
||||
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
print(price, iv, g.delta)
|
||||
```
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.futures import basis, curve_summary
|
||||
|
||||
print(basis(100.0, 103.0))
|
||||
print(curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0]))
|
||||
```
|
||||
|
||||
```python
|
||||
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff
|
||||
|
||||
legs = [
|
||||
PayoffLeg("option", "long", option_type="call", strike=100.0, premium=5.0),
|
||||
PayoffLeg("future", "long", entry_price=100.0),
|
||||
]
|
||||
grid = [90.0, 100.0, 110.0]
|
||||
print(strategy_payoff(grid, legs=legs))
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Existing `iv_rank`, `iv_percentile`, and `iv_zscore` names are preserved.
|
||||
- The derivatives layer is analytics-only: there is no broker connectivity,
|
||||
order routing, or execution workflow in this API.
|
||||
@@ -0,0 +1,126 @@
|
||||
Derivatives Analytics
|
||||
=====================
|
||||
|
||||
``ferro-ta`` includes a Rust-backed derivatives layer for analytics, research,
|
||||
and simulation workflows. The implementation is analytics-only: there is no
|
||||
broker connectivity, order routing, or execution engine in this package.
|
||||
|
||||
What Is Included
|
||||
----------------
|
||||
|
||||
Options analytics
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
- Rolling IV helpers: ``iv_rank``, ``iv_percentile``, ``iv_zscore``
|
||||
- Black-Scholes-Merton pricing
|
||||
- Black-76 pricing
|
||||
- Greeks: delta, gamma, vega, theta, rho
|
||||
- Implied volatility inversion
|
||||
- Smile metrics: ATM IV, 25-delta risk reversal, butterfly, skew slope, convexity
|
||||
- Chain helpers: moneyness labels and strike selection by offset or delta
|
||||
|
||||
Futures analytics
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
- 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/backwardation summary
|
||||
|
||||
Strategy and payoff helpers
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
- Typed strategy schemas for expiry selectors, strike selectors, leg presets,
|
||||
risk controls, and simulation limits
|
||||
- Multi-leg payoff aggregation
|
||||
- Greeks aggregation across option and futures legs
|
||||
|
||||
Conventions
|
||||
-----------
|
||||
|
||||
- ``model="bsm"`` expects spot as the underlying input.
|
||||
- ``model="black76"`` expects forward as the underlying input.
|
||||
- Volatility uses decimal annualized units: ``0.20`` means 20%.
|
||||
- Rates and carry use decimal annualized units: ``0.05`` means 5%.
|
||||
- ``time_to_expiry`` is expressed in years.
|
||||
|
||||
Options Example
|
||||
---------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ferro_ta.analysis.options import greeks, implied_volatility, option_price
|
||||
|
||||
price = option_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
1.0,
|
||||
0.20,
|
||||
option_type="call",
|
||||
model="bsm",
|
||||
)
|
||||
iv = implied_volatility(
|
||||
price,
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
1.0,
|
||||
option_type="call",
|
||||
model="bsm",
|
||||
)
|
||||
g = greeks(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
1.0,
|
||||
0.20,
|
||||
option_type="call",
|
||||
model="bsm",
|
||||
)
|
||||
|
||||
Futures Example
|
||||
---------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ferro_ta.analysis.futures import basis, curve_summary, synthetic_forward
|
||||
|
||||
front_basis = basis(100.0, 103.0)
|
||||
synthetic = synthetic_forward(8.0, 5.0, 100.0, 0.02, 0.5)
|
||||
curve = curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0])
|
||||
|
||||
Strategy and Payoff Example
|
||||
---------------------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, aggregate_greeks, strategy_payoff
|
||||
|
||||
legs = [
|
||||
PayoffLeg(
|
||||
instrument="option",
|
||||
side="long",
|
||||
option_type="call",
|
||||
strike=100.0,
|
||||
premium=5.0,
|
||||
volatility=0.20,
|
||||
time_to_expiry=0.5,
|
||||
),
|
||||
PayoffLeg(
|
||||
instrument="future",
|
||||
side="long",
|
||||
entry_price=100.0,
|
||||
),
|
||||
]
|
||||
|
||||
payoff = strategy_payoff([90.0, 100.0, 110.0], legs=legs)
|
||||
portfolio_greeks = aggregate_greeks(100.0, legs=legs)
|
||||
|
||||
Related Modules
|
||||
---------------
|
||||
|
||||
- :mod:`ferro_ta.analysis.options`
|
||||
- :mod:`ferro_ta.analysis.futures`
|
||||
- :mod:`ferro_ta.analysis.options_strategy`
|
||||
- :mod:`ferro_ta.analysis.derivatives_payoff`
|
||||
+39
-15
@@ -3,42 +3,66 @@ ferro-ta Documentation
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents
|
||||
:caption: Core Library
|
||||
|
||||
quickstart
|
||||
migration_talib
|
||||
support_matrix
|
||||
pandas_api
|
||||
error_handling
|
||||
api/index
|
||||
streaming
|
||||
extended
|
||||
batch
|
||||
extended
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Evidence and Releases
|
||||
|
||||
benchmarks
|
||||
plugins
|
||||
changelog
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Adjacent and Experimental
|
||||
|
||||
derivatives
|
||||
adjacent_tooling
|
||||
plugins
|
||||
contributing
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
**ferro-ta** is a fast Technical Analysis library — a drop-in alternative to TA-Lib
|
||||
powered by Rust and PyO3.
|
||||
**ferro-ta** is a Rust-powered Python technical analysis library focused on a
|
||||
TA-Lib-compatible API for NumPy-centered workloads.
|
||||
|
||||
Features:
|
||||
.. important::
|
||||
|
||||
Performance varies by indicator, array layout, warmup, build flags, and
|
||||
machine. ferro-ta is often faster on selected indicators, not universally
|
||||
faster. See :doc:`benchmarks` for the reproducible workflow, methodology
|
||||
notes, and the indicators where TA-Lib still wins or ties in the current
|
||||
checked-in artifact.
|
||||
|
||||
Core library:
|
||||
|
||||
- 160+ indicators covering all TA-Lib categories
|
||||
- 10 extended indicators not in TA-Lib (VWAP, Supertrend, Ichimoku Cloud, …)
|
||||
- Batch execution API — run indicators on 2-D arrays of multiple series
|
||||
- TA-Lib-style imports such as ``ferro_ta.SMA(close, timeperiod=20)``
|
||||
- Pre-built wheels for the supported Python/OS matrix
|
||||
- Pure Rust core library (``crates/ferro_ta_core``) — no PyO3 / numpy dependency
|
||||
- Batch execution API — run indicators on 2-D arrays of multiple series
|
||||
- Streaming / bar-by-bar API for live trading
|
||||
- Transparent pandas.Series support
|
||||
- Math operators and transforms
|
||||
- Type stubs (.pyi) for IDE auto-completion
|
||||
- WASM binding for browser/Node.js use
|
||||
- Options/IV helpers (IV rank, IV percentile, IV z-score) — see `Options/IV Helpers <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/options-volatility.md>`_
|
||||
- 10 extended indicators not in TA-Lib (VWAP, Supertrend, Ichimoku Cloud, ...)
|
||||
|
||||
Adjacent and experimental tooling:
|
||||
|
||||
- Derivatives analytics — see :doc:`derivatives`
|
||||
- Agentic workflow and LangChain tool wrappers — see `Agentic guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_
|
||||
- MCP server for Cursor/Claude integration — see `MCP guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_
|
||||
- Sphinx documentation
|
||||
- WASM, plugins, and other optional surfaces — see :doc:`adjacent_tooling`
|
||||
|
||||
Installation
|
||||
~~~~~~~~~~~~
|
||||
@@ -69,11 +93,11 @@ Further Reading
|
||||
- `Architecture <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/architecture.md>`_ — Rust/Python layout, two-crate design, binding flow.
|
||||
- `Performance Guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/performance.md>`_ — when to use raw numpy vs pandas/polars, batch notes, tips.
|
||||
- `API Stability <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/stability.md>`_ — stability tiers, versioning, and deprecation policy.
|
||||
- :doc:`support_matrix` — parity status, tested wheel targets, supported Python versions, and experimental modules.
|
||||
- `Rust-First Policy <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/rust_first.md>`_ — all compute logic belongs in Rust; how to add new indicators.
|
||||
- `Out-of-Core Execution <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/out-of-core.md>`_ — chunked processing and Dask integration.
|
||||
- `Options/IV Helpers <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/options-volatility.md>`_ — IV rank, IV percentile, IV z-score.
|
||||
- `Agentic Workflow <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_ — tools.py, workflow.py, LangChain integration.
|
||||
- `MCP Server <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_ — run ferro-ta as an MCP server in Cursor/Claude.
|
||||
- :doc:`derivatives` — IV helpers, options pricing/Greeks/IV, futures analytics, strategy schemas, and payoff helpers.
|
||||
- :doc:`adjacent_tooling` — optional surfaces such as derivatives, MCP, WASM, GPU, plugins, and agent-oriented integrations.
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
+50
-72
@@ -1,101 +1,79 @@
|
||||
# Options and Implied Volatility
|
||||
|
||||
ferro-ta provides optional helpers for implied volatility (IV) analysis
|
||||
via the `ferro_ta.options` module. This document describes the scope,
|
||||
data format, dependency strategy, and limitations.
|
||||
|
||||
---
|
||||
`ferro-ta` exposes options analytics from `ferro_ta.analysis.options`.
|
||||
|
||||
## Scope
|
||||
|
||||
The `ferro_ta.options` module focuses on **IV series analysis**:
|
||||
The module now covers both classic IV-series helpers and model-based option
|
||||
analytics:
|
||||
|
||||
- **IV rank** — where today's IV sits relative to the min/max over a look-back window.
|
||||
- **IV percentile** — fraction of observations over a look-back window at or below today's IV.
|
||||
- **IV z-score** — how many standard deviations today's IV is above the rolling mean.
|
||||
- `iv_rank`, `iv_percentile`, `iv_zscore`
|
||||
- Black-Scholes-Merton pricing
|
||||
- Black-76 pricing
|
||||
- Delta, gamma, vega, theta, rho
|
||||
- Implied volatility inversion
|
||||
- Smile metrics and chain helpers
|
||||
|
||||
These functions accept any 1-D IV series (e.g. VIX daily closes, single-name
|
||||
30-day IV, etc.) and return rolling statistics.
|
||||
Heavy computation runs in Rust through the `_ferro_ta` extension.
|
||||
|
||||
**Out of scope (for now):** Black-Scholes pricing, Greeks, option chain
|
||||
parsing, synthetic forward construction, dividend adjustment. For full
|
||||
option-pricing functionality consider `py_vollib`, `mibian`, or similar.
|
||||
## IV-series helpers
|
||||
|
||||
---
|
||||
|
||||
## Data format
|
||||
|
||||
All functions accept a 1-D NumPy array (or any array-like) of IV values.
|
||||
IV values are typically in **percentage points** (e.g. VIX = 20 means 20%
|
||||
annualised volatility), but the helpers are unit-agnostic — they only
|
||||
compare values within the rolling window.
|
||||
The original rolling helpers remain available and keep their public names:
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from ferro_ta.options import iv_rank, iv_percentile, iv_zscore
|
||||
from ferro_ta.analysis.options import iv_rank, iv_percentile, iv_zscore
|
||||
|
||||
# VIX-like daily close series
|
||||
iv = np.array([18.5, 22.3, 19.1, 25.0, 30.2, 27.8, 21.4, 19.0])
|
||||
|
||||
rank = iv_rank(iv, window=5) # rolling IV rank in [0, 1]
|
||||
pct = iv_percentile(iv, window=5) # rolling IV percentile in [0, 1]
|
||||
z = iv_zscore(iv, window=5) # rolling z-score
|
||||
rank = iv_rank(iv, window=5)
|
||||
pct = iv_percentile(iv, window=5)
|
||||
z = iv_zscore(iv, window=5)
|
||||
```
|
||||
|
||||
---
|
||||
These helpers accept a 1-D IV series and return rolling statistics with
|
||||
`NaN` during the warmup period.
|
||||
|
||||
## Dependency strategy
|
||||
## Pricing and Greeks
|
||||
|
||||
The `ferro_ta.options` module uses **only NumPy** (already a core dependency).
|
||||
No additional packages are required for the helpers described here.
|
||||
```python
|
||||
from ferro_ta.analysis.options import greeks, implied_volatility, option_price
|
||||
|
||||
For advanced option analytics (Black-Scholes, volatility surface
|
||||
interpolation), install the optional extra:
|
||||
|
||||
```bash
|
||||
pip install "ferro-ta[options]"
|
||||
price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call")
|
||||
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
```
|
||||
|
||||
This may install additional packages in the future (e.g. `py_vollib`).
|
||||
Conventions:
|
||||
|
||||
---
|
||||
- Volatility is decimal annualized volatility: `0.20` means 20%.
|
||||
- Rates are decimal annualized rates: `0.05` means 5%.
|
||||
- `time_to_expiry` is measured in years.
|
||||
- `model="bsm"` uses spot as the underlying input.
|
||||
- `model="black76"` uses forward as the underlying input.
|
||||
|
||||
## API reference
|
||||
## Smile and chain helpers
|
||||
|
||||
### `iv_rank(iv_series, window=252)`
|
||||
```python
|
||||
from ferro_ta.analysis.options import label_moneyness, select_strike, smile_metrics
|
||||
|
||||
Rolling IV rank.
|
||||
strikes = [80, 90, 100, 110, 120]
|
||||
vols = [0.30, 0.25, 0.20, 0.22, 0.27]
|
||||
|
||||
```
|
||||
rank_t = (IV_t - min(IV[t-window+1:t+1])) / (max(IV[t-window+1:t+1]) - min(IV[t-window+1:t+1]))
|
||||
metrics = smile_metrics(strikes, vols, 100.0, 0.5)
|
||||
labels = label_moneyness(strikes, 100.0, option_type="call")
|
||||
atm = select_strike(strikes, 100.0, selector="ATM")
|
||||
delta_strike = select_strike(
|
||||
strikes,
|
||||
100.0,
|
||||
selector="DELTA0.25",
|
||||
option_type="call",
|
||||
volatilities=vols,
|
||||
time_to_expiry=0.5,
|
||||
)
|
||||
```
|
||||
|
||||
Returns values in [0, 1]. NaN for the first `window - 1` bars.
|
||||
## Related futures analytics
|
||||
|
||||
### `iv_percentile(iv_series, window=252)`
|
||||
|
||||
Rolling IV percentile: fraction of the *window* bars whose IV was at or
|
||||
below the current value.
|
||||
|
||||
### `iv_zscore(iv_series, window=252)`
|
||||
|
||||
Rolling z-score: `(IV_t - rolling_mean) / rolling_std`.
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
- All functions use **O(n × window)** time complexity (pure Python loops).
|
||||
For large windows or series consider vectorised alternatives.
|
||||
- No option chain support; the module assumes IV series as input.
|
||||
- Streaming (bar-by-bar) versions of these functions are not yet
|
||||
implemented. For live use, maintain a rolling buffer and call the
|
||||
functions on the buffer at each bar.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `ferro_ta.options` — module source.
|
||||
- `ferro_ta.statistic` — general statistical functions (STDDEV, VAR, CORREL, etc.).
|
||||
- `ferro_ta.volatility` — price-based volatility indicators (ATR, NATR).
|
||||
See `ferro_ta.analysis.futures` and
|
||||
[`docs/derivatives-analytics.md`](./derivatives-analytics.md) for synthetic
|
||||
forwards, basis, carry, curve, and roll analytics.
|
||||
|
||||
+85
-12
@@ -14,6 +14,7 @@ practical advice on how to get the best speed from the library.
|
||||
| polars users | Pass `pl.Series`; result is `pl.Series`| Small overhead for type conversion |
|
||||
| Raw Rust access (expert) | `from ferro_ta._ferro_ta import sma` | Bypasses all Python wrappers |
|
||||
| Multiple series at once | `batch_sma`, `batch_ema`, `batch_rsi` | One Python call for all columns |
|
||||
| Many indicators on same arrays | `compute_many` | Amortizes Python→Rust overhead |
|
||||
|
||||
**Recorded baseline and roadmap:** Performance roadmap and trade-offs are tracked
|
||||
in [PERFORMANCE_ROADMAP.md](../PERFORMANCE_ROADMAP.md). For reproducible benchmark
|
||||
@@ -41,10 +42,12 @@ fast. The bottlenecks for most users are in the Python wrapping layer:
|
||||
np.asarray(result))`), which avoids the O(n) `.tolist()` conversion of
|
||||
earlier versions.
|
||||
|
||||
4. **Batch** — `batch_sma`/`batch_ema`/`batch_rsi` use Rust-side batch functions
|
||||
for 2-D input (single GIL release for all columns). The generic
|
||||
`batch_apply` runs any indicator in a Python loop over columns; use the
|
||||
dedicated batch functions when available.
|
||||
4. **Batch / grouped execution** — `batch_sma`/`batch_ema`/`batch_rsi` use
|
||||
Rust-side batch functions for 2-D input (single GIL release for all
|
||||
columns). `compute_many(...)` groups supported 1-D indicator bundles into
|
||||
one Rust call, which helps most on medium-to-large workloads. The generic
|
||||
`batch_apply` still runs a Python loop over columns; use it only when there
|
||||
is no dedicated fast path.
|
||||
|
||||
---
|
||||
|
||||
@@ -156,6 +159,25 @@ For 2-D input, `batch_sma`/`batch_ema`/`batch_rsi` use Rust-side batch
|
||||
functions (single GIL release for all columns). Use `batch_apply` for other
|
||||
indicators that do not have a dedicated Rust batch implementation.
|
||||
|
||||
When you have several indicators over the same 1-D arrays, use `compute_many`:
|
||||
|
||||
```python
|
||||
from ferro_ta.batch import compute_many
|
||||
|
||||
results = compute_many(
|
||||
[
|
||||
("SMA", {"timeperiod": 10}),
|
||||
("EMA", {"timeperiod": 12}),
|
||||
("RSI", {"timeperiod": 14}),
|
||||
],
|
||||
close=close,
|
||||
)
|
||||
```
|
||||
|
||||
Supported grouped paths currently cover common close-only indicators plus a
|
||||
small HLC bundle (`ATR`, `NATR`, `ADX`, `ADXR`, `CCI`, `WILLR`). Unsupported
|
||||
parameter shapes fall back to the normal registry path automatically.
|
||||
|
||||
---
|
||||
|
||||
## Streaming (Bar-by-Bar)
|
||||
@@ -208,6 +230,53 @@ wrapper with validation and `_to_f64`; all computation runs in the extension.
|
||||
5. **Profile before optimising.** Use `cProfile` or `py-spy` to find the
|
||||
actual bottleneck before assuming a particular layer is slow.
|
||||
|
||||
6. **Use the perf-contract scripts for evidence.** `benchmarks/run_perf_contract.py`
|
||||
and `benchmarks/profile_runtime_hotspots.py` record timings with git/runtime
|
||||
metadata so you can compare apples to apples across machines and commits.
|
||||
|
||||
## Benchmark Tooling
|
||||
|
||||
The benchmark suite now includes a small set of machine-readable scripts for
|
||||
performance work beyond the full pytest benchmark table:
|
||||
|
||||
- `python benchmarks/bench_batch.py --json batch_benchmark.json`
|
||||
- `python benchmarks/bench_streaming.py --json streaming_benchmark.json`
|
||||
- `python benchmarks/profile_runtime_hotspots.py --json runtime_hotspots.json`
|
||||
- `python benchmarks/bench_simd.py --json simd_benchmark.json`
|
||||
- `python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest`
|
||||
- `python benchmarks/check_hotspot_regression.py --input runtime_hotspots.json`
|
||||
|
||||
The WASM bindings also ship with a Node benchmark:
|
||||
|
||||
- `cd wasm && wasm-pack build --target nodejs --out-dir pkg`
|
||||
- `node bench.js --json ../wasm_benchmark.json`
|
||||
|
||||
## SIMD And Build Flags
|
||||
|
||||
Distributable wheels should stay on the portable release profile:
|
||||
|
||||
- `cargo`/`maturin` release build
|
||||
- `lto = true`
|
||||
- `codegen-units = 1`
|
||||
- no architecture-specific `target-cpu=native` in shipped artifacts
|
||||
|
||||
For local source builds, there are two opt-in tuning levers:
|
||||
|
||||
```bash
|
||||
# Portable SIMD-enabled local build
|
||||
uv run maturin develop --release --features simd
|
||||
|
||||
# Maximum local tuning for your current machine only
|
||||
RUSTFLAGS="-C target-cpu=native" uv run maturin develop --release --features simd
|
||||
```
|
||||
|
||||
Policy:
|
||||
|
||||
- Ship portable wheels with the default release settings.
|
||||
- Use `--features simd` for measured local/source wins.
|
||||
- Reserve `target-cpu=native` for developer workstations or private deploys,
|
||||
because those binaries are not portable across CPU families.
|
||||
|
||||
---
|
||||
|
||||
## Performance Improvements (implemented)
|
||||
@@ -246,18 +315,22 @@ bottlenecks are fixed or deferred.
|
||||
- No fast path for already 2-D C-contiguous float64 in batch_sma/ema/rsi
|
||||
(unlike `_to_f64` for 1-D); could avoid a potential copy.
|
||||
|
||||
**Options** (`python/ferro_ta/options.py`):
|
||||
- `iv_rank`, `iv_percentile`, `iv_zscore` use Python loops over windows
|
||||
(O(n) iterations with per-window NumPy). Could move to Rust or vectorize.
|
||||
See also `docs/options-volatility.md`.
|
||||
**Derivatives analytics** (`python/ferro_ta/analysis/options.py`):
|
||||
- `iv_rank`, `iv_percentile`, and `iv_zscore` now delegate to Rust.
|
||||
- The Python layer mostly performs broadcasting and result shaping; the hot
|
||||
path is in Rust.
|
||||
- Model-based implied-volatility inversion is much faster now, but still more
|
||||
expensive than direct pricing or Greeks due to root-finding.
|
||||
|
||||
**Features** (`python/ferro_ta/features.py`):
|
||||
- With `nan_policy="fill"` and no pandas, a Python loop fills NaN per column.
|
||||
- Indicators are run in a Python loop (one call per indicator); no bulk API.
|
||||
- `nan_policy="fill"` is vectorized now.
|
||||
- `feature_matrix(...)` uses `compute_many(...)`, but grouped HLC bundles are
|
||||
still only near parity on medium workloads and are best on larger arrays.
|
||||
|
||||
**Signals** (`python/ferro_ta/signals.py`):
|
||||
- `compose(..., method="rank")` uses a list comprehension over columns (one
|
||||
Python round-trip per column). Could add a Rust batch rank for 2-D input.
|
||||
- `compose(..., method="rank")` now uses a one-call Rust rank-composition
|
||||
path, but its gains are moderate rather than dramatic. Keep measuring before
|
||||
treating it as a major optimization lever.
|
||||
|
||||
**Other**:
|
||||
- **dsl.py**: Some code paths use Python loops over bars.
|
||||
|
||||
@@ -101,3 +101,19 @@ Extended Indicators
|
||||
|
||||
# Pivot Points
|
||||
pivot, r1, s1, r2, s2 = PIVOT_POINTS(high, low, close, method="classic")
|
||||
|
||||
Derivatives Analytics
|
||||
---------------------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ferro_ta.analysis.options import greeks, option_price
|
||||
from ferro_ta.analysis.futures import basis
|
||||
|
||||
call_price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
call_greeks = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
|
||||
front_basis = basis(100.0, 103.0)
|
||||
|
||||
See :doc:`derivatives` for the full analytics surface, including implied
|
||||
volatility inversion, smile metrics, strike selection, futures curve tools,
|
||||
strategy schemas, and multi-leg payoff helpers.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
Support Matrix
|
||||
==============
|
||||
|
||||
The primary product is the Python technical analysis library: TA-Lib-style
|
||||
indicator calls backed by a Rust implementation.
|
||||
|
||||
Indicator compatibility
|
||||
-----------------------
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Status
|
||||
- Scope
|
||||
- Notes
|
||||
* - Exact parity
|
||||
- Common TA-Lib-compatible indicators such as ``SMA``, ``WMA``,
|
||||
``BBANDS``, ``RSI``, ``ATR``, ``NATR``, ``CCI``, ``STOCH``,
|
||||
``STOCHRSI``, and most candlestick patterns
|
||||
- Matches TA-Lib numerically within floating-point tolerance in the
|
||||
current comparison suite.
|
||||
* - Approximate parity
|
||||
- EMA-family indicators (``EMA``, ``DEMA``, ``TEMA``, ``T3``, ``MACD``),
|
||||
``MAMA`` / ``FAMA``, ``SAR`` / ``SAREXT``, and ``HT_*`` cycle
|
||||
indicators
|
||||
- Same API and intended use, with convergence-window or floating-point
|
||||
differences documented in the migration guide.
|
||||
* - Intentionally different
|
||||
- ferro-ta-only indicators such as ``VWAP``, ``SUPERTREND``,
|
||||
``ICHIMOKU``, ``DONCHIAN``, ``KELTNER_CHANNELS``, ``HULL_MA``,
|
||||
``CHANDELIER_EXIT``, ``VWMA``, and ``CHOPPINESS_INDEX``
|
||||
- These extend the library beyond TA-Lib and are not parity claims.
|
||||
|
||||
For migration details and known indicator-specific differences, see
|
||||
:doc:`migration_talib`.
|
||||
|
||||
Module status
|
||||
-------------
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Surface
|
||||
- Status
|
||||
- Notes
|
||||
* - Top-level indicators and category submodules
|
||||
- Stable core
|
||||
- This is the main supported surface of the project.
|
||||
* - ``ferro_ta.batch``
|
||||
- Supported
|
||||
- Public API is supported; internal dispatch may evolve.
|
||||
* - ``ferro_ta.streaming``
|
||||
- Supported, still evolving
|
||||
- Suitable for live workflows; some API details are still marked
|
||||
experimental in the stability policy.
|
||||
* - ``ferro_ta.extended``
|
||||
- Supported extension
|
||||
- Useful indicators beyond TA-Lib, but not part of drop-in parity claims.
|
||||
* - ``ferro_ta.analysis.*``
|
||||
- Adjacent tooling
|
||||
- Useful analytics helpers, but not the primary product story.
|
||||
* - MCP, WASM, GPU, plugin, and agent-oriented tooling
|
||||
- Experimental or adjacent
|
||||
- Evaluate these independently from the core indicator library.
|
||||
|
||||
Supported Python versions
|
||||
-------------------------
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Python
|
||||
- Status
|
||||
* - 3.13
|
||||
- Supported and tested in CI
|
||||
* - 3.12
|
||||
- Supported and tested in CI
|
||||
* - 3.11
|
||||
- Supported and tested in CI
|
||||
* - 3.10
|
||||
- Supported and tested in CI
|
||||
* - < 3.10
|
||||
- Not supported
|
||||
|
||||
Tested wheel targets
|
||||
--------------------
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - OS
|
||||
- Architecture
|
||||
- Wheel status
|
||||
* - Linux
|
||||
- ``x86_64`` (manylinux2014 / ``manylinux_2_17``)
|
||||
- Tested wheel target
|
||||
* - macOS
|
||||
- ``universal2``
|
||||
- Tested wheel target for Intel and Apple Silicon
|
||||
* - Windows
|
||||
- ``x86_64``
|
||||
- Tested wheel target
|
||||
|
||||
For source builds, packaging details, and platform notes, see
|
||||
`PLATFORMS.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/PLATFORMS.md>`_.
|
||||
|
||||
Release status
|
||||
--------------
|
||||
|
||||
These docs track package version ``1.0.3``.
|
||||
|
||||
- Release notes by version: :doc:`changelog`
|
||||
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
|
||||
- Stability policy: `docs/stability.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/stability.md>`_
|
||||
|
||||
If the package version, docs version, or support matrix disagree, treat that as
|
||||
a documentation bug.
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "batch",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:08:30.877702+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"n_samples": 20000,
|
||||
"n_series": 32,
|
||||
"total_bars": 640000,
|
||||
"seed": 42
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"indicator": "SMA",
|
||||
"parallel_ms": 1.9615,
|
||||
"sequential_ms": 2.0423,
|
||||
"loop_ms": 0.8865,
|
||||
"parallel_speedup_vs_loop": 0.452,
|
||||
"sequential_speedup_vs_loop": 0.4341
|
||||
},
|
||||
{
|
||||
"indicator": "RSI",
|
||||
"parallel_ms": 2.1731,
|
||||
"sequential_ms": 4.3225,
|
||||
"loop_ms": 3.2043,
|
||||
"parallel_speedup_vs_loop": 1.4745,
|
||||
"sequential_speedup_vs_loop": 0.7413
|
||||
},
|
||||
{
|
||||
"indicator": "ATR",
|
||||
"parallel_ms": 4.2249,
|
||||
"sequential_ms": 6.6175,
|
||||
"loop_ms": 4.0382,
|
||||
"parallel_speedup_vs_loop": 0.9558,
|
||||
"sequential_speedup_vs_loop": 0.6102
|
||||
},
|
||||
{
|
||||
"indicator": "ADX",
|
||||
"parallel_ms": 4.8674,
|
||||
"sequential_ms": 7.6402,
|
||||
"loop_ms": 5.1861,
|
||||
"parallel_speedup_vs_loop": 1.0655,
|
||||
"sequential_speedup_vs_loop": 0.6788
|
||||
}
|
||||
],
|
||||
"grouped_results": [
|
||||
{
|
||||
"case": "close_bundle_3",
|
||||
"grouped_ms": 0.1878,
|
||||
"separate_ms": 0.1719,
|
||||
"speedup_vs_separate": 0.9155
|
||||
},
|
||||
{
|
||||
"case": "hlc_bundle_3",
|
||||
"grouped_ms": 0.2958,
|
||||
"separate_ms": 0.4633,
|
||||
"speedup_vs_separate": 1.5666
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "indicator_latency",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:08:30.515962+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"fixtures": [
|
||||
{
|
||||
"path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz",
|
||||
"size_bytes": 75586,
|
||||
"sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c"
|
||||
}
|
||||
],
|
||||
"dataset": {
|
||||
"fixture": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz",
|
||||
"bars": 2000,
|
||||
"rounds": 5
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"name": "VAR_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0202
|
||||
},
|
||||
{
|
||||
"name": "WILLR_14",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0183
|
||||
},
|
||||
{
|
||||
"name": "STOCH",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {},
|
||||
"elapsed_ms": 0.0181
|
||||
},
|
||||
{
|
||||
"name": "ADX_14",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0147
|
||||
},
|
||||
{
|
||||
"name": "CCI_14",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0144
|
||||
},
|
||||
{
|
||||
"name": "MACD",
|
||||
"inputs": "close",
|
||||
"kwargs": {},
|
||||
"elapsed_ms": 0.0132
|
||||
},
|
||||
{
|
||||
"name": "ATR_14",
|
||||
"inputs": "hlc",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0108
|
||||
},
|
||||
{
|
||||
"name": "RSI_14",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0102
|
||||
},
|
||||
{
|
||||
"name": "STDDEV_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0086
|
||||
},
|
||||
{
|
||||
"name": "BETA_5",
|
||||
"inputs": "pair_hl",
|
||||
"kwargs": {
|
||||
"timeperiod": 5
|
||||
},
|
||||
"elapsed_ms": 0.008
|
||||
},
|
||||
{
|
||||
"name": "CORREL_30",
|
||||
"inputs": "pair_hl",
|
||||
"kwargs": {
|
||||
"timeperiod": 30
|
||||
},
|
||||
"elapsed_ms": 0.0068
|
||||
},
|
||||
{
|
||||
"name": "EMA_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0052
|
||||
},
|
||||
{
|
||||
"name": "BBANDS_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.005
|
||||
},
|
||||
{
|
||||
"name": "TSF_14",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0048
|
||||
},
|
||||
{
|
||||
"name": "LINEARREG_14",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0046
|
||||
},
|
||||
{
|
||||
"name": "LINEARREG_SLOPE_14",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 14
|
||||
},
|
||||
"elapsed_ms": 0.0045
|
||||
},
|
||||
{
|
||||
"name": "SMA_20",
|
||||
"inputs": "close",
|
||||
"kwargs": {
|
||||
"timeperiod": 20
|
||||
},
|
||||
"elapsed_ms": 0.0027
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "perf_contract",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:09:13.077731+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"fixtures": [
|
||||
{
|
||||
"path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz",
|
||||
"size_bytes": 75586,
|
||||
"sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c"
|
||||
}
|
||||
],
|
||||
"output_dir": "perf-contract"
|
||||
},
|
||||
"artifacts": {
|
||||
"indicator_latency": {
|
||||
"path": "perf-contract/indicator_latency.json",
|
||||
"size_bytes": 3212,
|
||||
"sha256": "1e7f844fe6eb467f07bbb1e4eb918c56861e8cf819ab4802b96e033c6d2570c4"
|
||||
},
|
||||
"batch": {
|
||||
"path": "perf-contract/batch.json",
|
||||
"size_bytes": 1679,
|
||||
"sha256": "202d67b24a88184473f93cec9f866ca34ff60721068e15e7e5d23962f006d169"
|
||||
},
|
||||
"streaming": {
|
||||
"path": "perf-contract/streaming.json",
|
||||
"size_bytes": 1939,
|
||||
"sha256": "04476c3d95a9e7d87e40331b28c5ccad9a7aa5abcfc47fb6ac7e929365f68554"
|
||||
},
|
||||
"runtime_hotspots": {
|
||||
"path": "perf-contract/runtime_hotspots.json",
|
||||
"size_bytes": 2365,
|
||||
"sha256": "d37a58bc88def9f1525b9b0f64f0bd7b9c0df9ed4cae260c833697f6a1689ca8"
|
||||
},
|
||||
"simd": {
|
||||
"path": "perf-contract/simd.json",
|
||||
"size_bytes": 7670,
|
||||
"sha256": "3f9b341a8206698ed172650752d808621c9b5218f42a2373f0f4485496197b4c"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "runtime_hotspots",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:08:34.436165+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"price_bars": 20000,
|
||||
"iv_bars": 50000,
|
||||
"window": 252
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_zscore",
|
||||
"fast_ms": 28.857,
|
||||
"reference_ms": 897.0178,
|
||||
"speedup_vs_reference": 31.0849,
|
||||
"share_of_suite_pct": 70.0
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_rank",
|
||||
"fast_ms": 10.8496,
|
||||
"reference_ms": 199.4376,
|
||||
"speedup_vs_reference": 18.3821,
|
||||
"share_of_suite_pct": 26.32
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_percentile",
|
||||
"fast_ms": 0.9124,
|
||||
"reference_ms": 79.6557,
|
||||
"speedup_vs_reference": 87.3019,
|
||||
"share_of_suite_pct": 2.21
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "feature_matrix",
|
||||
"fast_ms": 0.2411,
|
||||
"reference_ms": 0.2215,
|
||||
"speedup_vs_reference": 0.9186,
|
||||
"share_of_suite_pct": 0.58
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "compute_many_close",
|
||||
"fast_ms": 0.1563,
|
||||
"reference_ms": 0.1498,
|
||||
"speedup_vs_reference": 0.9587,
|
||||
"share_of_suite_pct": 0.38
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "BETA",
|
||||
"fast_ms": 0.0694,
|
||||
"reference_ms": 159.2715,
|
||||
"speedup_vs_reference": 2294.4163,
|
||||
"share_of_suite_pct": 0.17
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "CORREL",
|
||||
"fast_ms": 0.0555,
|
||||
"reference_ms": 158.2775,
|
||||
"speedup_vs_reference": 2851.8468,
|
||||
"share_of_suite_pct": 0.13
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "LINEARREG",
|
||||
"fast_ms": 0.0414,
|
||||
"reference_ms": 44.8111,
|
||||
"speedup_vs_reference": 1081.9491,
|
||||
"share_of_suite_pct": 0.1
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "TSF",
|
||||
"fast_ms": 0.0413,
|
||||
"reference_ms": 46.3799,
|
||||
"speedup_vs_reference": 1122.1039,
|
||||
"share_of_suite_pct": 0.1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "simd",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:09:13.028473+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"price_bars": 20000,
|
||||
"iv_bars": 50000,
|
||||
"window": 252
|
||||
},
|
||||
"variants": [
|
||||
"portable_release",
|
||||
"simd_release"
|
||||
]
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"name": "compute_many_close",
|
||||
"category": "ffi_grouping",
|
||||
"portable_ms": 0.1711,
|
||||
"simd_ms": 0.1618,
|
||||
"speedup_simd_vs_portable": 1.0575
|
||||
},
|
||||
{
|
||||
"name": "iv_percentile",
|
||||
"category": "python_analysis",
|
||||
"portable_ms": 0.9126,
|
||||
"simd_ms": 0.9096,
|
||||
"speedup_simd_vs_portable": 1.0033
|
||||
},
|
||||
{
|
||||
"name": "iv_zscore",
|
||||
"category": "python_analysis",
|
||||
"portable_ms": 29.0039,
|
||||
"simd_ms": 28.9123,
|
||||
"speedup_simd_vs_portable": 1.0032
|
||||
},
|
||||
{
|
||||
"name": "iv_rank",
|
||||
"category": "python_analysis",
|
||||
"portable_ms": 10.8629,
|
||||
"simd_ms": 10.862,
|
||||
"speedup_simd_vs_portable": 1.0001
|
||||
},
|
||||
{
|
||||
"name": "BETA",
|
||||
"category": "rust_kernel",
|
||||
"portable_ms": 0.0696,
|
||||
"simd_ms": 0.0696,
|
||||
"speedup_simd_vs_portable": 1.0
|
||||
},
|
||||
{
|
||||
"name": "CORREL",
|
||||
"category": "rust_kernel",
|
||||
"portable_ms": 0.0555,
|
||||
"simd_ms": 0.0556,
|
||||
"speedup_simd_vs_portable": 0.9982
|
||||
},
|
||||
{
|
||||
"name": "TSF",
|
||||
"category": "rust_kernel",
|
||||
"portable_ms": 0.0414,
|
||||
"simd_ms": 0.0415,
|
||||
"speedup_simd_vs_portable": 0.9976
|
||||
},
|
||||
{
|
||||
"name": "LINEARREG",
|
||||
"category": "rust_kernel",
|
||||
"portable_ms": 0.0413,
|
||||
"simd_ms": 0.0416,
|
||||
"speedup_simd_vs_portable": 0.9928
|
||||
},
|
||||
{
|
||||
"name": "feature_matrix",
|
||||
"category": "ffi_grouping",
|
||||
"portable_ms": 0.2543,
|
||||
"simd_ms": 0.2634,
|
||||
"speedup_simd_vs_portable": 0.9655
|
||||
}
|
||||
],
|
||||
"reports": {
|
||||
"portable_release": {
|
||||
"metadata": {
|
||||
"suite": "runtime_hotspots",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:08:38.698373+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"price_bars": 20000,
|
||||
"iv_bars": 50000,
|
||||
"window": 252
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_zscore",
|
||||
"fast_ms": 29.0039,
|
||||
"reference_ms": 903.5384,
|
||||
"speedup_vs_reference": 31.1523,
|
||||
"share_of_suite_pct": 70.04
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_rank",
|
||||
"fast_ms": 10.8629,
|
||||
"reference_ms": 201.621,
|
||||
"speedup_vs_reference": 18.5606,
|
||||
"share_of_suite_pct": 26.23
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_percentile",
|
||||
"fast_ms": 0.9126,
|
||||
"reference_ms": 80.0849,
|
||||
"speedup_vs_reference": 87.7523,
|
||||
"share_of_suite_pct": 2.2
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "feature_matrix",
|
||||
"fast_ms": 0.2543,
|
||||
"reference_ms": 0.2222,
|
||||
"speedup_vs_reference": 0.874,
|
||||
"share_of_suite_pct": 0.61
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "compute_many_close",
|
||||
"fast_ms": 0.1711,
|
||||
"reference_ms": 0.1413,
|
||||
"speedup_vs_reference": 0.8257,
|
||||
"share_of_suite_pct": 0.41
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "BETA",
|
||||
"fast_ms": 0.0696,
|
||||
"reference_ms": 162.9168,
|
||||
"speedup_vs_reference": 2341.2971,
|
||||
"share_of_suite_pct": 0.17
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "CORREL",
|
||||
"fast_ms": 0.0555,
|
||||
"reference_ms": 163.8589,
|
||||
"speedup_vs_reference": 2950.1793,
|
||||
"share_of_suite_pct": 0.13
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "TSF",
|
||||
"fast_ms": 0.0414,
|
||||
"reference_ms": 49.2846,
|
||||
"speedup_vs_reference": 1189.9901,
|
||||
"share_of_suite_pct": 0.1
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "LINEARREG",
|
||||
"fast_ms": 0.0413,
|
||||
"reference_ms": 47.5241,
|
||||
"speedup_vs_reference": 1149.7853,
|
||||
"share_of_suite_pct": 0.1
|
||||
}
|
||||
]
|
||||
},
|
||||
"simd_release": {
|
||||
"metadata": {
|
||||
"suite": "runtime_hotspots",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:08:57.755423+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"price_bars": 20000,
|
||||
"iv_bars": 50000,
|
||||
"window": 252
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_zscore",
|
||||
"fast_ms": 28.9123,
|
||||
"reference_ms": 909.2586,
|
||||
"speedup_vs_reference": 31.4489,
|
||||
"share_of_suite_pct": 69.98
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_rank",
|
||||
"fast_ms": 10.862,
|
||||
"reference_ms": 198.2076,
|
||||
"speedup_vs_reference": 18.2478,
|
||||
"share_of_suite_pct": 26.29
|
||||
},
|
||||
{
|
||||
"category": "python_analysis",
|
||||
"name": "iv_percentile",
|
||||
"fast_ms": 0.9096,
|
||||
"reference_ms": 78.1232,
|
||||
"speedup_vs_reference": 85.889,
|
||||
"share_of_suite_pct": 2.2
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "feature_matrix",
|
||||
"fast_ms": 0.2634,
|
||||
"reference_ms": 0.2219,
|
||||
"speedup_vs_reference": 0.8425,
|
||||
"share_of_suite_pct": 0.64
|
||||
},
|
||||
{
|
||||
"category": "ffi_grouping",
|
||||
"name": "compute_many_close",
|
||||
"fast_ms": 0.1618,
|
||||
"reference_ms": 0.155,
|
||||
"speedup_vs_reference": 0.9583,
|
||||
"share_of_suite_pct": 0.39
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "BETA",
|
||||
"fast_ms": 0.0696,
|
||||
"reference_ms": 161.4576,
|
||||
"speedup_vs_reference": 2320.3601,
|
||||
"share_of_suite_pct": 0.17
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "CORREL",
|
||||
"fast_ms": 0.0556,
|
||||
"reference_ms": 162.6189,
|
||||
"speedup_vs_reference": 2923.4861,
|
||||
"share_of_suite_pct": 0.13
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "LINEARREG",
|
||||
"fast_ms": 0.0416,
|
||||
"reference_ms": 48.029,
|
||||
"speedup_vs_reference": 1155.0143,
|
||||
"share_of_suite_pct": 0.1
|
||||
},
|
||||
{
|
||||
"category": "rust_kernel",
|
||||
"name": "TSF",
|
||||
"fast_ms": 0.0415,
|
||||
"reference_ms": 47.55,
|
||||
"speedup_vs_reference": 1145.783,
|
||||
"share_of_suite_pct": 0.1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "streaming",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-23T21:08:30.968886+00:00",
|
||||
"python_version": "3.12.11",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit",
|
||||
"machine": "arm64",
|
||||
"processor": "arm"
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
|
||||
"dirty": true,
|
||||
"branch": "feat/performace-1.0.2"
|
||||
},
|
||||
"dataset": {
|
||||
"n_bars": 20000,
|
||||
"seed": 2026
|
||||
}
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"indicator": "StreamingSMA",
|
||||
"inputs": "close",
|
||||
"stream_total_ms": 0.8808,
|
||||
"batch_total_ms": 0.0153,
|
||||
"stream_ns_per_update": 44.04,
|
||||
"batch_ns_per_bar": 0.77,
|
||||
"updates_per_second": 22705779.59,
|
||||
"stream_over_batch_ratio": 57.4469
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingEMA",
|
||||
"inputs": "close",
|
||||
"stream_total_ms": 0.8611,
|
||||
"batch_total_ms": 0.0408,
|
||||
"stream_ns_per_update": 43.06,
|
||||
"batch_ns_per_bar": 2.04,
|
||||
"updates_per_second": 23225431.81,
|
||||
"stream_over_batch_ratio": 21.0884
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingRSI",
|
||||
"inputs": "close",
|
||||
"stream_total_ms": 0.9235,
|
||||
"batch_total_ms": 0.0932,
|
||||
"stream_ns_per_update": 46.17,
|
||||
"batch_ns_per_bar": 4.66,
|
||||
"updates_per_second": 21656740.75,
|
||||
"stream_over_batch_ratio": 9.9035
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingATR",
|
||||
"inputs": "hlc",
|
||||
"stream_total_ms": 2.0074,
|
||||
"batch_total_ms": 0.0935,
|
||||
"stream_ns_per_update": 100.37,
|
||||
"batch_ns_per_bar": 4.68,
|
||||
"updates_per_second": 9963056.99,
|
||||
"stream_over_batch_ratio": 21.4603
|
||||
},
|
||||
{
|
||||
"indicator": "StreamingVWAP",
|
||||
"inputs": "hlcv",
|
||||
"stream_total_ms": 2.6068,
|
||||
"batch_total_ms": 0.0223,
|
||||
"stream_ns_per_update": 130.34,
|
||||
"batch_ns_per_bar": 1.11,
|
||||
"updates_per_second": 7672265.38,
|
||||
"stream_over_batch_ratio": 116.9385
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -4,8 +4,8 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "ferro-ta"
|
||||
version = "1.0.1"
|
||||
description = "A fast Technical Analysis library — TA-Lib alternative powered by Rust and PyO3"
|
||||
version = "1.0.3"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -11,7 +11,7 @@ Sub-packages
|
||||
* :mod:`ferro_ta.indicators` — All indicator functions (overlap, momentum, volume, volatility, statistic, cycle, pattern, price_transform, math_ops, extended)
|
||||
* :mod:`ferro_ta.core` — Core utilities (exceptions, config, logging, registry, raw)
|
||||
* :mod:`ferro_ta.data` — Data utilities (streaming, batch, chunked, resampling, aggregation, adapters)
|
||||
* :mod:`ferro_ta.analysis` — Analysis tools (portfolio, backtest, regime, cross_asset, attribution, signals, features, crypto, options)
|
||||
* :mod:`ferro_ta.analysis` — Analysis tools (portfolio, backtest, regime, cross_asset, attribution, signals, features, crypto, options, futures, derivatives payoff)
|
||||
* :mod:`ferro_ta.tools` — Developer tools (tools, viz, dashboard, alerts, dsl, pipeline, workflow, api_info, gpu)
|
||||
|
||||
Sub-modules (also accessible via sub-packages above)
|
||||
@@ -35,6 +35,8 @@ Sub-modules (also accessible via sub-packages above)
|
||||
* :mod:`ferro_ta.analysis.portfolio` — Portfolio and multi-asset analytics
|
||||
* :mod:`ferro_ta.analysis.cross_asset` — Cross-asset and relative strength
|
||||
* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness
|
||||
* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, smile, and chain analytics
|
||||
* :mod:`ferro_ta.analysis.futures` — Futures basis, carry, roll, and curve analytics
|
||||
* :mod:`ferro_ta.tools.viz` — Charting and visualisation API
|
||||
* :mod:`ferro_ta.data.adapters` — Market data adapters
|
||||
|
||||
@@ -57,8 +59,52 @@ array([ nan, nan, 11. , 12. , 13. , 13.5, 13.33...])
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
||||
from importlib.metadata import version as _dist_version
|
||||
from pathlib import Path as _Path
|
||||
import re as _re
|
||||
import sys as _sys
|
||||
|
||||
try:
|
||||
import tomllib as _tomllib
|
||||
except ImportError: # pragma: no cover
|
||||
try:
|
||||
import tomli as _tomllib # type: ignore[no-redef]
|
||||
except ImportError: # pragma: no cover
|
||||
_tomllib = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def _detect_version() -> str:
|
||||
try:
|
||||
return _dist_version("ferro-ta")
|
||||
except _PackageNotFoundError:
|
||||
pass
|
||||
|
||||
if _tomllib is not None:
|
||||
pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
if pyproject_toml.is_file():
|
||||
try:
|
||||
with pyproject_toml.open("rb") as handle:
|
||||
data = _tomllib.load(handle)
|
||||
return data.get("project", {}).get("version", "0+unknown")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
if pyproject_toml.is_file():
|
||||
try:
|
||||
text = pyproject_toml.read_text(encoding="utf-8")
|
||||
match = _re.search(r'^version\s*=\s*"([^"]+)"', text, _re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "0+unknown"
|
||||
|
||||
|
||||
__version__ = _detect_version()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exceptions — exported at the top level for convenient catching
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -279,6 +325,7 @@ from ferro_ta.indicators.volume import ( # noqa: F401
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
# Overlap Studies
|
||||
"SMA",
|
||||
"EMA",
|
||||
@@ -457,7 +504,9 @@ __all__ = [
|
||||
"VWMA",
|
||||
"CHOPPINESS_INDEX",
|
||||
# API discovery
|
||||
"about",
|
||||
"indicators",
|
||||
"methods",
|
||||
"info",
|
||||
# Logging utilities
|
||||
"enable_debug",
|
||||
@@ -525,6 +574,7 @@ from ferro_ta.data.batch import ( # noqa: F401, E402
|
||||
batch_ema,
|
||||
batch_rsi,
|
||||
batch_sma,
|
||||
compute_many,
|
||||
)
|
||||
from ferro_ta.data.chunked import ( # noqa: F401, E402
|
||||
chunk_apply,
|
||||
@@ -582,9 +632,10 @@ from ferro_ta.tools.alerts import ( # noqa: F401, E402
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API discovery helpers — ferro_ta.indicators() and ferro_ta.info()
|
||||
# API discovery helpers — ferro_ta.about(), ferro_ta.methods(),
|
||||
# ferro_ta.indicators(), and ferro_ta.info()
|
||||
# ---------------------------------------------------------------------------
|
||||
from ferro_ta.tools.api_info import indicators, info # noqa: F401, E402
|
||||
from ferro_ta.tools.api_info import about, indicators, info, methods # noqa: F401, E402
|
||||
|
||||
_ALIASED_SUBMODULES = {
|
||||
"batch": batch,
|
||||
|
||||
@@ -710,6 +710,7 @@ from ferro_ta.batch import batch_apply as batch_apply
|
||||
from ferro_ta.batch import batch_ema as batch_ema
|
||||
from ferro_ta.batch import batch_rsi as batch_rsi
|
||||
from ferro_ta.batch import batch_sma as batch_sma
|
||||
from ferro_ta.batch import compute_many as compute_many
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception hierarchy (re-exported from ferro_ta.exceptions)
|
||||
|
||||
@@ -20,6 +20,26 @@ DEFAULT_OHLCV_COLUMNS = {
|
||||
}
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _optional_pandas_module():
|
||||
"""Import pandas lazily once and cache absence for low-overhead hot paths."""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
return None
|
||||
return pd
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _optional_polars_module():
|
||||
"""Import polars lazily once and cache absence for low-overhead hot paths."""
|
||||
try:
|
||||
import polars as pl
|
||||
except ImportError:
|
||||
return None
|
||||
return pl
|
||||
|
||||
|
||||
def _to_f64(data: ArrayLike) -> np.ndarray:
|
||||
"""Convert any array-like to a contiguous 1-D float64 NumPy array.
|
||||
|
||||
@@ -159,9 +179,8 @@ def pandas_wrap(func):
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
import pandas as pd # local import — pandas is optional
|
||||
except ImportError:
|
||||
pd = _optional_pandas_module()
|
||||
if pd is None:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
pd_index = None
|
||||
@@ -232,9 +251,8 @@ def polars_wrap(func):
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
import polars as pl # local import — polars is optional
|
||||
except ImportError:
|
||||
pl = _optional_polars_module()
|
||||
if pl is None:
|
||||
return func(*args, **kwargs)
|
||||
|
||||
pl_name: Optional[str] = None
|
||||
|
||||
@@ -11,7 +11,10 @@ Sub-modules
|
||||
* :mod:`ferro_ta.analysis.signals` — Signal composition and screening
|
||||
* :mod:`ferro_ta.analysis.features` — Feature matrix and ML readiness helpers
|
||||
* :mod:`ferro_ta.analysis.crypto` — Crypto-specific indicators and helpers
|
||||
* :mod:`ferro_ta.analysis.options` — Options pricing and Greeks
|
||||
* :mod:`ferro_ta.analysis.options` — Options pricing, Greeks, IV, and smile analytics
|
||||
* :mod:`ferro_ta.analysis.futures` — Futures basis, curve, roll, and synthetic analytics
|
||||
* :mod:`ferro_ta.analysis.options_strategy` — Typed derivatives strategy schemas
|
||||
* :mod:`ferro_ta.analysis.derivatives_payoff` — Multi-leg payoff and Greeks aggregation
|
||||
|
||||
Example usage::
|
||||
|
||||
|
||||
@@ -360,10 +360,10 @@ def backtest(
|
||||
bar_returns[1:] = np.diff(c) / c[:-1]
|
||||
|
||||
strategy_returns = positions * bar_returns
|
||||
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
|
||||
|
||||
# Slippage: on each position change, reduce return by slippage_bps/10000 (one-way)
|
||||
if slippage_bps > 0:
|
||||
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
|
||||
strategy_returns = strategy_returns.copy()
|
||||
strategy_returns[position_changed] -= slippage_bps / 10_000.0
|
||||
|
||||
@@ -371,13 +371,18 @@ def backtest(
|
||||
if commission_per_trade <= 0:
|
||||
equity = np.cumprod(1.0 + strategy_returns)
|
||||
else:
|
||||
equity = np.empty(len(c), dtype=np.float64)
|
||||
equity[0] = 1.0
|
||||
position_changed = np.concatenate([[False], positions[1:] != positions[:-1]])
|
||||
for i in range(1, len(c)):
|
||||
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i])
|
||||
if position_changed[i]:
|
||||
equity[i] -= commission_per_trade
|
||||
gross_equity = np.cumprod(1.0 + strategy_returns)
|
||||
if np.any(gross_equity == 0.0):
|
||||
equity = np.empty(len(c), dtype=np.float64)
|
||||
equity[0] = 1.0
|
||||
for i in range(1, len(c)):
|
||||
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i])
|
||||
if position_changed[i]:
|
||||
equity[i] -= commission_per_trade
|
||||
else:
|
||||
commissions = position_changed.astype(np.float64) * commission_per_trade
|
||||
discounted_commissions = np.cumsum(commissions / gross_equity)
|
||||
equity = gross_equity * (1.0 - discounted_commissions)
|
||||
|
||||
return BacktestResult(
|
||||
signals=signals,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
ferro_ta.analysis.derivatives_payoff — Multi-leg payoff and Greeks aggregation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta.analysis.options import OptionGreeks
|
||||
from ferro_ta.analysis.options import greeks as option_greeks
|
||||
from ferro_ta.analysis.options_strategy import DerivativesStrategy, StrategyLeg
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
|
||||
__all__ = [
|
||||
"PayoffLeg",
|
||||
"option_leg_payoff",
|
||||
"futures_leg_payoff",
|
||||
"strategy_payoff",
|
||||
"aggregate_greeks",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PayoffLeg:
|
||||
instrument: str
|
||||
side: str
|
||||
quantity: float = 1.0
|
||||
option_type: str | None = None
|
||||
strike: float | None = None
|
||||
premium: float = 0.0
|
||||
entry_price: float | None = None
|
||||
volatility: float | None = None
|
||||
time_to_expiry: float | None = None
|
||||
rate: float = 0.0
|
||||
carry: float = 0.0
|
||||
multiplier: float = 1.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.instrument not in {"option", "future"}:
|
||||
raise FerroTAValueError("instrument must be 'option' or 'future'.")
|
||||
if self.side not in {"long", "short"}:
|
||||
raise FerroTAValueError("side must be 'long' or 'short'.")
|
||||
if self.instrument == "option":
|
||||
if self.option_type not in {"call", "put"}:
|
||||
raise FerroTAValueError(
|
||||
"option legs require option_type='call' or 'put'."
|
||||
)
|
||||
if self.strike is None:
|
||||
raise FerroTAValueError("option legs require strike.")
|
||||
if self.instrument == "future" and self.entry_price is None:
|
||||
raise FerroTAValueError("future legs require entry_price.")
|
||||
|
||||
|
||||
def _side_sign(side: str) -> float:
|
||||
return 1.0 if side == "long" else -1.0
|
||||
|
||||
|
||||
def _coerce_spot_grid(spot_grid: ArrayLike) -> NDArray[np.float64]:
|
||||
grid = np.asarray(spot_grid, dtype=np.float64)
|
||||
if grid.ndim != 1:
|
||||
raise FerroTAInputError("spot_grid must be a 1-D array.")
|
||||
return np.ascontiguousarray(grid)
|
||||
|
||||
|
||||
def option_leg_payoff(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
strike: float,
|
||||
premium: float = 0.0,
|
||||
option_type: str = "call",
|
||||
side: str = "long",
|
||||
quantity: float = 1.0,
|
||||
multiplier: float = 1.0,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Expiry payoff for a single option leg."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
sign = _side_sign(side) * float(quantity) * float(multiplier)
|
||||
if option_type == "call":
|
||||
intrinsic = np.maximum(grid - float(strike), 0.0)
|
||||
elif option_type == "put":
|
||||
intrinsic = np.maximum(float(strike) - grid, 0.0)
|
||||
else:
|
||||
raise FerroTAValueError("option_type must be 'call' or 'put'.")
|
||||
return sign * (intrinsic - float(premium))
|
||||
|
||||
|
||||
def futures_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 futures leg."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
sign = _side_sign(side) * float(quantity) * float(multiplier)
|
||||
return sign * (grid - float(entry_price))
|
||||
|
||||
|
||||
def _mapping_to_leg(mapping: Mapping[str, Any]) -> PayoffLeg:
|
||||
return PayoffLeg(**mapping)
|
||||
|
||||
|
||||
def _strategy_leg_to_payoff_leg(leg: StrategyLeg) -> PayoffLeg:
|
||||
return PayoffLeg(
|
||||
instrument=leg.instrument,
|
||||
side=leg.side,
|
||||
quantity=float(leg.quantity),
|
||||
option_type=leg.option_type,
|
||||
strike=leg.strike_selector.explicit_strike,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_legs(
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
|
||||
*,
|
||||
strategy: DerivativesStrategy | None = None,
|
||||
) -> tuple[PayoffLeg, ...]:
|
||||
if strategy is not None:
|
||||
return tuple(_strategy_leg_to_payoff_leg(leg) for leg in strategy.legs)
|
||||
if legs is None:
|
||||
raise FerroTAInputError("Provide either legs or strategy.")
|
||||
normalized: list[PayoffLeg] = []
|
||||
for leg in legs:
|
||||
normalized.append(leg if isinstance(leg, PayoffLeg) else _mapping_to_leg(leg))
|
||||
return tuple(normalized)
|
||||
|
||||
|
||||
def strategy_payoff(
|
||||
spot_grid: ArrayLike,
|
||||
*,
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
|
||||
strategy: DerivativesStrategy | None = None,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Aggregate expiry payoff across option and futures legs."""
|
||||
grid = _coerce_spot_grid(spot_grid)
|
||||
normalized = _normalize_legs(legs, strategy=strategy)
|
||||
total = np.zeros_like(grid)
|
||||
for leg in normalized:
|
||||
if leg.instrument == "option":
|
||||
if leg.strike is None:
|
||||
raise FerroTAValueError("Option payoff legs require strike.")
|
||||
total += option_leg_payoff(
|
||||
grid,
|
||||
strike=float(leg.strike),
|
||||
premium=float(leg.premium),
|
||||
option_type=str(leg.option_type),
|
||||
side=str(leg.side),
|
||||
quantity=float(leg.quantity),
|
||||
multiplier=float(leg.multiplier),
|
||||
)
|
||||
else:
|
||||
if leg.entry_price is None:
|
||||
raise FerroTAValueError("Futures payoff legs require entry_price.")
|
||||
total += futures_leg_payoff(
|
||||
grid,
|
||||
entry_price=float(leg.entry_price),
|
||||
side=str(leg.side),
|
||||
quantity=float(leg.quantity),
|
||||
multiplier=float(leg.multiplier),
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
def aggregate_greeks(
|
||||
spot: float,
|
||||
*,
|
||||
legs: Sequence[PayoffLeg | Mapping[str, Any]] | None = None,
|
||||
strategy: DerivativesStrategy | None = None,
|
||||
) -> OptionGreeks:
|
||||
"""Aggregate Greeks across option and futures legs."""
|
||||
normalized = _normalize_legs(legs, strategy=strategy)
|
||||
totals = {
|
||||
"delta": 0.0,
|
||||
"gamma": 0.0,
|
||||
"vega": 0.0,
|
||||
"theta": 0.0,
|
||||
"rho": 0.0,
|
||||
}
|
||||
for leg in normalized:
|
||||
leg_sign = _side_sign(leg.side) * float(leg.quantity) * float(leg.multiplier)
|
||||
if leg.instrument == "future":
|
||||
totals["delta"] += leg_sign
|
||||
continue
|
||||
if leg.strike is None or leg.volatility is None or leg.time_to_expiry is None:
|
||||
raise FerroTAValueError(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation."
|
||||
)
|
||||
leg_greeks = option_greeks(
|
||||
float(spot),
|
||||
float(leg.strike),
|
||||
float(leg.rate),
|
||||
float(leg.time_to_expiry),
|
||||
float(leg.volatility),
|
||||
option_type=str(leg.option_type),
|
||||
model="bsm",
|
||||
carry=float(leg.carry),
|
||||
)
|
||||
totals["delta"] += leg_sign * float(leg_greeks.delta)
|
||||
totals["gamma"] += leg_sign * float(leg_greeks.gamma)
|
||||
totals["vega"] += leg_sign * float(leg_greeks.vega)
|
||||
totals["theta"] += leg_sign * float(leg_greeks.theta)
|
||||
totals["rho"] += leg_sign * float(leg_greeks.rho)
|
||||
|
||||
return OptionGreeks(
|
||||
totals["delta"],
|
||||
totals["gamma"],
|
||||
totals["vega"],
|
||||
totals["theta"],
|
||||
totals["rho"],
|
||||
)
|
||||
@@ -24,12 +24,23 @@ import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.registry import run as _registry_run
|
||||
from ferro_ta.data.batch import compute_many
|
||||
|
||||
__all__ = [
|
||||
"feature_matrix",
|
||||
]
|
||||
|
||||
|
||||
def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
mask = np.isnan(arr)
|
||||
if not mask.any():
|
||||
return arr
|
||||
|
||||
last_valid = np.where(~mask, np.arange(len(arr)), 0)
|
||||
np.maximum.accumulate(last_valid, out=last_valid)
|
||||
return arr[last_valid]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# feature_matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -117,67 +128,23 @@ def feature_matrix(
|
||||
n = len(close)
|
||||
columns: dict[str, NDArray[np.float64]] = {}
|
||||
|
||||
# --- Indicators needing HLCV ---
|
||||
_multi_input = {
|
||||
"ATR",
|
||||
"NATR",
|
||||
"TRANGE",
|
||||
"ADX",
|
||||
"ADXR",
|
||||
"PLUS_DI",
|
||||
"MINUS_DI",
|
||||
"PLUS_DM",
|
||||
"MINUS_DM",
|
||||
"DX",
|
||||
"AROON",
|
||||
"AROONOSC",
|
||||
"CCI",
|
||||
"MFI",
|
||||
"STOCH",
|
||||
"STOCHF",
|
||||
"STOCHRSI",
|
||||
"WILLR",
|
||||
"AD",
|
||||
"ADOSC",
|
||||
"OBV",
|
||||
"VWAP",
|
||||
"DONCHIAN",
|
||||
"ICHIMOKU",
|
||||
}
|
||||
results = compute_many(
|
||||
indicators,
|
||||
close=close,
|
||||
high=high if high is not None else None,
|
||||
low=low if low is not None else None,
|
||||
volume=volume if volume is not None else None,
|
||||
)
|
||||
|
||||
def _call_indicator(name: str, kwargs: dict[str, Any]) -> Any:
|
||||
# Try with close only first; if that fails try with hlcv
|
||||
try:
|
||||
return _registry_run(name, close, **kwargs)
|
||||
except (TypeError, Exception):
|
||||
pass
|
||||
# Build appropriate positional args from available arrays
|
||||
if name in _multi_input and high is not None and low is not None:
|
||||
try:
|
||||
return _registry_run(name, high, low, close, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
if volume is not None:
|
||||
try:
|
||||
return _registry_run(name, high, low, close, volume, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
raise ValueError(
|
||||
f"Cannot call indicator '{name}': insufficient data columns or incompatible parameters."
|
||||
)
|
||||
|
||||
for spec in indicators:
|
||||
for spec, result in zip(indicators, results):
|
||||
if isinstance(spec, str):
|
||||
name = spec
|
||||
kwargs: dict[str, Any] = {}
|
||||
out_key: Optional[Any] = None
|
||||
elif len(spec) == 2:
|
||||
name, kwargs = spec # type: ignore[misc]
|
||||
name, _ = spec # type: ignore[misc]
|
||||
out_key = None
|
||||
else:
|
||||
name, kwargs, out_key = spec # type: ignore[misc]
|
||||
|
||||
result = _call_indicator(name, kwargs)
|
||||
name, _, out_key = spec # type: ignore[misc]
|
||||
|
||||
if isinstance(result, tuple):
|
||||
if out_key is not None:
|
||||
@@ -215,8 +182,6 @@ def feature_matrix(
|
||||
mask &= ~np.isnan(arr)
|
||||
return {k: v[mask] for k, v in columns.items()}
|
||||
elif nan_policy == "fill":
|
||||
for k, arr in columns.items():
|
||||
for i in range(1, len(arr)):
|
||||
if np.isnan(arr[i]):
|
||||
arr[i] = arr[i - 1]
|
||||
for key, arr in columns.items():
|
||||
columns[key] = _forward_fill_nan(arr)
|
||||
return columns
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
ferro_ta.analysis.futures — Futures and forward-curve analytics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import annualized_basis as _rust_annualized_basis
|
||||
from ferro_ta._ferro_ta import (
|
||||
back_adjusted_continuous_contract as _rust_back_adjusted,
|
||||
)
|
||||
from ferro_ta._ferro_ta import calendar_spreads as _rust_calendar_spreads
|
||||
from ferro_ta._ferro_ta import carry_spread as _rust_carry_spread
|
||||
from ferro_ta._ferro_ta import curve_slope as _rust_curve_slope
|
||||
from ferro_ta._ferro_ta import curve_summary as _rust_curve_summary
|
||||
from ferro_ta._ferro_ta import futures_basis as _rust_basis
|
||||
from ferro_ta._ferro_ta import implied_carry_rate as _rust_implied_carry_rate
|
||||
from ferro_ta._ferro_ta import parity_gap as _rust_parity_gap
|
||||
from ferro_ta._ferro_ta import (
|
||||
ratio_adjusted_continuous_contract as _rust_ratio_adjusted,
|
||||
)
|
||||
from ferro_ta._ferro_ta import roll_yield as _rust_roll_yield
|
||||
from ferro_ta._ferro_ta import synthetic_forward as _rust_synthetic_forward
|
||||
from ferro_ta._ferro_ta import synthetic_spot as _rust_synthetic_spot
|
||||
from ferro_ta._ferro_ta import weighted_continuous_contract as _rust_weighted
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import _normalize_rust_error
|
||||
|
||||
__all__ = [
|
||||
"CurveSummary",
|
||||
"synthetic_forward",
|
||||
"synthetic_spot",
|
||||
"parity_gap",
|
||||
"basis",
|
||||
"annualized_basis",
|
||||
"implied_carry_rate",
|
||||
"carry_spread",
|
||||
"weighted_continuous_contract",
|
||||
"back_adjusted_continuous_contract",
|
||||
"ratio_adjusted_continuous_contract",
|
||||
"roll_yield",
|
||||
"calendar_spreads",
|
||||
"curve_slope",
|
||||
"curve_summary",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CurveSummary:
|
||||
front_basis: float
|
||||
average_basis: float
|
||||
slope: float
|
||||
is_contango: bool
|
||||
|
||||
def to_dict(self) -> dict[str, float | bool]:
|
||||
return {
|
||||
"front_basis": self.front_basis,
|
||||
"average_basis": self.average_basis,
|
||||
"slope": self.slope,
|
||||
"is_contango": self.is_contango,
|
||||
}
|
||||
|
||||
|
||||
def synthetic_forward(
|
||||
call_price: float,
|
||||
put_price: float,
|
||||
strike: float,
|
||||
rate: float,
|
||||
time_to_expiry: float,
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_synthetic_forward(
|
||||
float(call_price),
|
||||
float(put_price),
|
||||
float(strike),
|
||||
float(rate),
|
||||
float(time_to_expiry),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def synthetic_spot(
|
||||
call_price: float,
|
||||
put_price: float,
|
||||
strike: float,
|
||||
rate: float,
|
||||
time_to_expiry: float,
|
||||
*,
|
||||
carry: float = 0.0,
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_synthetic_spot(
|
||||
float(call_price),
|
||||
float(put_price),
|
||||
float(strike),
|
||||
float(rate),
|
||||
float(time_to_expiry),
|
||||
float(carry),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def parity_gap(
|
||||
call_price: float,
|
||||
put_price: float,
|
||||
spot: float,
|
||||
strike: float,
|
||||
rate: float,
|
||||
time_to_expiry: float,
|
||||
*,
|
||||
carry: float = 0.0,
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_parity_gap(
|
||||
float(call_price),
|
||||
float(put_price),
|
||||
float(spot),
|
||||
float(strike),
|
||||
float(rate),
|
||||
float(time_to_expiry),
|
||||
float(carry),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def basis(spot: float, future: float) -> float:
|
||||
return float(_rust_basis(float(spot), float(future)))
|
||||
|
||||
|
||||
def annualized_basis(spot: float, future: float, time_to_expiry: float) -> float:
|
||||
return float(
|
||||
_rust_annualized_basis(float(spot), float(future), float(time_to_expiry))
|
||||
)
|
||||
|
||||
|
||||
def implied_carry_rate(spot: float, future: float, time_to_expiry: float) -> float:
|
||||
return float(
|
||||
_rust_implied_carry_rate(float(spot), float(future), float(time_to_expiry))
|
||||
)
|
||||
|
||||
|
||||
def carry_spread(
|
||||
spot: float, future: float, rate: float, time_to_expiry: float
|
||||
) -> float:
|
||||
return float(
|
||||
_rust_carry_spread(
|
||||
float(spot), float(future), float(rate), float(time_to_expiry)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def weighted_continuous_contract(
|
||||
front: ArrayLike,
|
||||
next_contract: ArrayLike,
|
||||
next_weights: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_weighted(
|
||||
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def back_adjusted_continuous_contract(
|
||||
front: ArrayLike,
|
||||
next_contract: ArrayLike,
|
||||
next_weights: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_back_adjusted(
|
||||
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def ratio_adjusted_continuous_contract(
|
||||
front: ArrayLike,
|
||||
next_contract: ArrayLike,
|
||||
next_weights: ArrayLike,
|
||||
) -> NDArray[np.float64]:
|
||||
try:
|
||||
return np.asarray(
|
||||
_rust_ratio_adjusted(
|
||||
_to_f64(front), _to_f64(next_contract), _to_f64(next_weights)
|
||||
),
|
||||
dtype=np.float64,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def roll_yield(front_price: float, next_price: float, time_to_expiry: float) -> float:
|
||||
return float(
|
||||
_rust_roll_yield(float(front_price), float(next_price), float(time_to_expiry))
|
||||
)
|
||||
|
||||
|
||||
def calendar_spreads(futures_prices: ArrayLike) -> NDArray[np.float64]:
|
||||
return np.asarray(_rust_calendar_spreads(_to_f64(futures_prices)), dtype=np.float64)
|
||||
|
||||
|
||||
def curve_slope(tenors: ArrayLike, futures_prices: ArrayLike) -> float:
|
||||
try:
|
||||
return float(_rust_curve_slope(_to_f64(tenors), _to_f64(futures_prices)))
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def curve_summary(
|
||||
spot: float, tenors: ArrayLike, futures_prices: ArrayLike
|
||||
) -> CurveSummary:
|
||||
try:
|
||||
front_basis, average_basis, slope, is_contango = _rust_curve_summary(
|
||||
float(spot), _to_f64(tenors), _to_f64(futures_prices)
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
return CurveSummary(front_basis, average_basis, slope, is_contango)
|
||||
+599
-172
@@ -1,205 +1,632 @@
|
||||
"""
|
||||
ferro_ta.options — Options and Implied Volatility Helpers
|
||||
=========================================================
|
||||
ferro_ta.analysis.options — Rust-backed derivatives analytics for options.
|
||||
|
||||
Optional module that provides helpers for options/IV analysis when supplied
|
||||
with an implied-volatility series (IV series as input). All heavy compute
|
||||
delegates to Rust via ``ferro_ta`` core; this module is a thin orchestration
|
||||
layer.
|
||||
|
||||
.. note::
|
||||
Options support is **optional** and does not require any additional
|
||||
third-party libraries beyond ``numpy``. For advanced option-pricing
|
||||
functionality (e.g. Black-Scholes, Greeks) install the optional
|
||||
``ferro_ta[options]`` extra which may pull in additional dependencies.
|
||||
|
||||
See ``docs/options-volatility.md`` for the full design doc.
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.options import iv_rank, iv_percentile
|
||||
>>>
|
||||
>>> # Synthetic IV series (e.g. VIX or single-name IV)
|
||||
>>> rng = np.random.default_rng(42)
|
||||
>>> iv = rng.uniform(10, 40, 252)
|
||||
>>>
|
||||
>>> rank = iv_rank(iv, window=252)
|
||||
>>> pct = iv_percentile(iv, window=252)
|
||||
|
||||
API
|
||||
---
|
||||
iv_rank(iv_series, window)
|
||||
Rolling IV rank: where is today's IV relative to min/max over *window* bars?
|
||||
Returns values in [0, 1] (NaN during warm-up).
|
||||
|
||||
iv_percentile(iv_series, window)
|
||||
Rolling IV percentile: fraction of observations over *window* bars that are
|
||||
≤ today's IV. Returns values in [0, 1] (NaN during warm-up).
|
||||
|
||||
iv_zscore(iv_series, window)
|
||||
Rolling IV z-score: (IV - rolling_mean) / rolling_std over *window* bars.
|
||||
Returns z-score values (NaN during warm-up).
|
||||
This module preserves the legacy IV-series helpers and expands them with
|
||||
pricing, Greeks, implied-volatility inversion, smile analytics, and strike
|
||||
selection helpers suitable for research and simulation workflows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
from ferro_ta._ferro_ta import (
|
||||
black76_price as _rust_black76_price,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
black76_price_batch as _rust_black76_price_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
bsm_price as _rust_bsm_price,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
bsm_price_batch as _rust_bsm_price_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
implied_volatility as _rust_implied_volatility,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
implied_volatility_batch as _rust_implied_volatility_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
iv_percentile as _rust_iv_percentile,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
iv_rank as _rust_iv_rank,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
iv_zscore as _rust_iv_zscore,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
moneyness_labels as _rust_moneyness_labels,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
option_greeks as _rust_option_greeks,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
option_greeks_batch as _rust_option_greeks_batch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
select_strike_delta as _rust_select_strike_delta,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
select_strike_offset as _rust_select_strike_offset,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
smile_metrics as _rust_smile_metrics,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
term_structure_slope as _rust_term_structure_slope,
|
||||
)
|
||||
from ferro_ta._utils import _to_f64
|
||||
from ferro_ta.core.exceptions import (
|
||||
FerroTAInputError,
|
||||
FerroTAValueError,
|
||||
_normalize_rust_error,
|
||||
)
|
||||
|
||||
ScalarOrArray: TypeAlias = float | NDArray[np.float64]
|
||||
|
||||
__all__ = [
|
||||
"OptionGreeks",
|
||||
"SmileMetrics",
|
||||
"black_scholes_price",
|
||||
"black_76_price",
|
||||
"option_price",
|
||||
"greeks",
|
||||
"implied_volatility",
|
||||
"smile_metrics",
|
||||
"term_structure_slope",
|
||||
"label_moneyness",
|
||||
"select_strike",
|
||||
"iv_rank",
|
||||
"iv_percentile",
|
||||
"iv_zscore",
|
||||
]
|
||||
|
||||
|
||||
def _validate_iv(iv_series: NDArray[np.float64], window: int) -> NDArray[np.float64]:
|
||||
"""Validate and convert iv_series; check window."""
|
||||
arr = np.asarray(iv_series, dtype=np.float64)
|
||||
if arr.ndim != 1:
|
||||
raise FerroTAInputError("iv_series must be a 1-D array.")
|
||||
@dataclass(frozen=True)
|
||||
class OptionGreeks:
|
||||
"""Container for first-order Greeks."""
|
||||
|
||||
delta: ScalarOrArray
|
||||
gamma: ScalarOrArray
|
||||
vega: ScalarOrArray
|
||||
theta: ScalarOrArray
|
||||
rho: ScalarOrArray
|
||||
|
||||
def to_dict(self) -> dict[str, ScalarOrArray]:
|
||||
return {
|
||||
"delta": self.delta,
|
||||
"gamma": self.gamma,
|
||||
"vega": self.vega,
|
||||
"theta": self.theta,
|
||||
"rho": self.rho,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmileMetrics:
|
||||
"""Summary metrics for a single smile slice."""
|
||||
|
||||
atm_iv: float
|
||||
risk_reversal_25d: float
|
||||
butterfly_25d: float
|
||||
skew_slope: float
|
||||
convexity: float
|
||||
|
||||
def to_dict(self) -> dict[str, float]:
|
||||
return {
|
||||
"atm_iv": self.atm_iv,
|
||||
"risk_reversal_25d": self.risk_reversal_25d,
|
||||
"butterfly_25d": self.butterfly_25d,
|
||||
"skew_slope": self.skew_slope,
|
||||
"convexity": self.convexity,
|
||||
}
|
||||
|
||||
|
||||
def _validate_option_type(option_type: str) -> str:
|
||||
value = option_type.lower()
|
||||
if value not in {"call", "put"}:
|
||||
raise FerroTAValueError("option_type must be 'call' or 'put'.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_model(model: str) -> str:
|
||||
value = model.lower()
|
||||
aliases = {
|
||||
"bsm": "bsm",
|
||||
"black_scholes": "bsm",
|
||||
"black-scholes": "bsm",
|
||||
"blackscholes": "bsm",
|
||||
"black76": "black76",
|
||||
"black_76": "black76",
|
||||
"black-76": "black76",
|
||||
}
|
||||
if value not in aliases:
|
||||
raise FerroTAValueError(
|
||||
"model must be one of 'bsm', 'black_scholes', or 'black76'."
|
||||
)
|
||||
return aliases[value]
|
||||
|
||||
|
||||
def _coerce_1d(data: ArrayLike | float, *, name: str) -> tuple[np.ndarray, bool]:
|
||||
arr = np.asarray(data, dtype=np.float64)
|
||||
if arr.ndim > 1:
|
||||
raise FerroTAInputError(f"{name} must be a scalar or 1-D array.")
|
||||
return np.ascontiguousarray(arr.reshape(-1)), arr.ndim == 0
|
||||
|
||||
|
||||
def _broadcast_inputs(
|
||||
**kwargs: ArrayLike | float,
|
||||
) -> tuple[dict[str, np.ndarray], bool]:
|
||||
arrays: dict[str, np.ndarray] = {}
|
||||
scalar_flags: list[bool] = []
|
||||
for name, value in kwargs.items():
|
||||
arr, is_scalar = _coerce_1d(value, name=name)
|
||||
arrays[name] = arr
|
||||
scalar_flags.append(is_scalar)
|
||||
try:
|
||||
broadcast = np.broadcast_arrays(*arrays.values())
|
||||
except ValueError as err:
|
||||
raise FerroTAInputError(
|
||||
f"Inputs could not be broadcast together: {', '.join(arrays.keys())}"
|
||||
) from err
|
||||
out = {
|
||||
name: np.ascontiguousarray(arr, dtype=np.float64).reshape(-1)
|
||||
for name, arr in zip(arrays.keys(), broadcast)
|
||||
}
|
||||
return out, all(scalar_flags)
|
||||
|
||||
|
||||
def _result_or_scalar(result: np.ndarray, scalar_mode: bool) -> ScalarOrArray:
|
||||
return float(result[0]) if scalar_mode else result
|
||||
|
||||
|
||||
def iv_rank(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV rank in Rust while preserving the legacy API."""
|
||||
try:
|
||||
arr = _to_f64(iv_series)
|
||||
except ValueError as err:
|
||||
raise FerroTAInputError(str(err)) from err
|
||||
if len(arr) == 0:
|
||||
raise FerroTAInputError("iv_series must not be empty.")
|
||||
if window < 1:
|
||||
raise FerroTAValueError(f"window must be >= 1, got {window}.")
|
||||
return arr
|
||||
try:
|
||||
return np.asarray(_rust_iv_rank(arr, int(window)), dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def iv_rank(
|
||||
iv_series: ArrayLike,
|
||||
window: int = 252,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV rank.
|
||||
|
||||
IV rank measures where today's IV sits relative to the min/max of IV over
|
||||
the look-back *window*. A value of 1.0 means current IV is at its
|
||||
highest, 0.0 means it is at its lowest.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
iv_series : array-like
|
||||
1-D series of implied volatility values (e.g. VIX daily closes or
|
||||
single-name option IV). Any positive numeric values are accepted.
|
||||
window : int
|
||||
Look-back period in bars (default 252 ≈ 1 trading year).
|
||||
|
||||
Returns
|
||||
-------
|
||||
ndarray of float64
|
||||
Rolling IV rank in [0, 1]. NaN for bars where the window is not yet
|
||||
full (i.e. the first ``window - 1`` bars).
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.options import iv_rank
|
||||
>>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0])
|
||||
>>> iv_rank(iv, window=3)
|
||||
array([ nan, nan, 1. , 0. , 0.46666667])
|
||||
"""
|
||||
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan, dtype=np.float64)
|
||||
|
||||
for i in range(window - 1, n):
|
||||
window_slice = arr[i - window + 1 : i + 1]
|
||||
lo = float(np.nanmin(window_slice))
|
||||
hi = float(np.nanmax(window_slice))
|
||||
if hi == lo:
|
||||
out[i] = 0.0
|
||||
else:
|
||||
out[i] = (arr[i] - lo) / (hi - lo)
|
||||
|
||||
return out
|
||||
def iv_percentile(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV percentile in Rust while preserving the legacy API."""
|
||||
try:
|
||||
arr = _to_f64(iv_series)
|
||||
except ValueError as err:
|
||||
raise FerroTAInputError(str(err)) from err
|
||||
if len(arr) == 0:
|
||||
raise FerroTAInputError("iv_series must not be empty.")
|
||||
if window < 1:
|
||||
raise FerroTAValueError(f"window must be >= 1, got {window}.")
|
||||
try:
|
||||
return np.asarray(_rust_iv_percentile(arr, int(window)), dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def iv_percentile(
|
||||
iv_series: ArrayLike,
|
||||
window: int = 252,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV percentile.
|
||||
|
||||
IV percentile measures the fraction of days over the look-back *window*
|
||||
for which IV was *at or below* today's level. Unlike IV rank (which only
|
||||
considers min/max), IV percentile uses the full distribution of values.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
iv_series : array-like
|
||||
1-D series of implied volatility values.
|
||||
window : int
|
||||
Look-back period in bars (default 252).
|
||||
|
||||
Returns
|
||||
-------
|
||||
ndarray of float64
|
||||
Rolling IV percentile in [0, 1]. NaN for bars before the window fills.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.options import iv_percentile
|
||||
>>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0])
|
||||
>>> iv_percentile(iv, window=3)
|
||||
array([ nan, nan, 1. , 0. , 0.33333333])
|
||||
"""
|
||||
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan, dtype=np.float64)
|
||||
|
||||
for i in range(window - 1, n):
|
||||
window_slice = arr[i - window + 1 : i + 1]
|
||||
current = arr[i]
|
||||
out[i] = float(np.sum(window_slice <= current)) / window
|
||||
|
||||
return out
|
||||
def iv_zscore(iv_series: ArrayLike, window: int = 252) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV z-score in Rust while preserving the legacy API."""
|
||||
try:
|
||||
arr = _to_f64(iv_series)
|
||||
except ValueError as err:
|
||||
raise FerroTAInputError(str(err)) from err
|
||||
if len(arr) == 0:
|
||||
raise FerroTAInputError("iv_series must not be empty.")
|
||||
if window < 1:
|
||||
raise FerroTAValueError(f"window must be >= 1, got {window}.")
|
||||
try:
|
||||
return np.asarray(_rust_iv_zscore(arr, int(window)), dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def iv_zscore(
|
||||
iv_series: ArrayLike,
|
||||
window: int = 252,
|
||||
) -> NDArray[np.float64]:
|
||||
"""Compute rolling IV z-score.
|
||||
def black_scholes_price(
|
||||
spot: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
volatility: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
dividend_yield: ArrayLike | float = 0.0,
|
||||
) -> ScalarOrArray:
|
||||
"""Price options under Black-Scholes-Merton."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
arrays, scalar_mode = _broadcast_inputs(
|
||||
spot=spot,
|
||||
strike=strike,
|
||||
rate=rate,
|
||||
time_to_expiry=time_to_expiry,
|
||||
volatility=volatility,
|
||||
dividend_yield=dividend_yield,
|
||||
)
|
||||
try:
|
||||
if scalar_mode:
|
||||
return float(
|
||||
_rust_bsm_price(
|
||||
float(arrays["spot"][0]),
|
||||
float(arrays["strike"][0]),
|
||||
float(arrays["rate"][0]),
|
||||
float(arrays["time_to_expiry"][0]),
|
||||
float(arrays["volatility"][0]),
|
||||
option_type,
|
||||
float(arrays["dividend_yield"][0]),
|
||||
)
|
||||
)
|
||||
out = _rust_bsm_price_batch(
|
||||
arrays["spot"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
arrays["dividend_yield"],
|
||||
option_type,
|
||||
)
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
Measures how many standard deviations today's IV is above (positive) or
|
||||
below (negative) the rolling mean over *window* bars.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
iv_series : array-like
|
||||
1-D series of implied volatility values.
|
||||
window : int
|
||||
Look-back period in bars (default 252).
|
||||
def black_76_price(
|
||||
forward: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
volatility: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
) -> ScalarOrArray:
|
||||
"""Price options under Black-76."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
arrays, scalar_mode = _broadcast_inputs(
|
||||
forward=forward,
|
||||
strike=strike,
|
||||
rate=rate,
|
||||
time_to_expiry=time_to_expiry,
|
||||
volatility=volatility,
|
||||
)
|
||||
try:
|
||||
if scalar_mode:
|
||||
return float(
|
||||
_rust_black76_price(
|
||||
float(arrays["forward"][0]),
|
||||
float(arrays["strike"][0]),
|
||||
float(arrays["rate"][0]),
|
||||
float(arrays["time_to_expiry"][0]),
|
||||
float(arrays["volatility"][0]),
|
||||
option_type,
|
||||
)
|
||||
)
|
||||
out = _rust_black76_price_batch(
|
||||
arrays["forward"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
option_type,
|
||||
)
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
Returns
|
||||
-------
|
||||
ndarray of float64
|
||||
Rolling z-score. NaN during warm-up (first ``window - 1`` bars) and
|
||||
when the rolling standard deviation is zero.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.analysis.options import iv_zscore
|
||||
>>> iv = np.array([20.0, 25.0, 30.0, 15.0, 22.0])
|
||||
>>> z = iv_zscore(iv, window=3)
|
||||
>>> z[2] # (30 - 25) / std([20, 25, 30])
|
||||
np.float64(1.2247...)
|
||||
"""
|
||||
arr = _validate_iv(np.asarray(iv_series, dtype=np.float64), window)
|
||||
n = len(arr)
|
||||
out = np.full(n, np.nan, dtype=np.float64)
|
||||
def option_price(
|
||||
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,
|
||||
) -> ScalarOrArray:
|
||||
"""Model-dispatched option price helper."""
|
||||
model = _validate_model(model)
|
||||
if model == "black76":
|
||||
return black_76_price(
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_type=option_type,
|
||||
)
|
||||
return black_scholes_price(
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_type=option_type,
|
||||
dividend_yield=carry,
|
||||
)
|
||||
|
||||
for i in range(window - 1, n):
|
||||
window_slice = arr[i - window + 1 : i + 1]
|
||||
mu = float(np.nanmean(window_slice))
|
||||
sigma = float(np.nanstd(window_slice, ddof=0))
|
||||
if sigma == 0.0:
|
||||
out[i] = np.nan
|
||||
else:
|
||||
out[i] = (arr[i] - mu) / sigma
|
||||
|
||||
return out
|
||||
def 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,
|
||||
) -> OptionGreeks:
|
||||
"""Return delta, gamma, vega, theta, and rho."""
|
||||
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:
|
||||
delta, gamma, vega, theta, rho = _rust_option_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 OptionGreeks(delta, gamma, vega, theta, rho)
|
||||
|
||||
delta, gamma, vega, theta, rho = _rust_option_greeks_batch(
|
||||
arrays["underlying"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
arrays["volatility"],
|
||||
option_type,
|
||||
model,
|
||||
arrays["carry"],
|
||||
)
|
||||
return OptionGreeks(
|
||||
np.asarray(delta, dtype=np.float64),
|
||||
np.asarray(gamma, dtype=np.float64),
|
||||
np.asarray(vega, dtype=np.float64),
|
||||
np.asarray(theta, dtype=np.float64),
|
||||
np.asarray(rho, dtype=np.float64),
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def implied_volatility(
|
||||
price: ArrayLike | float,
|
||||
underlying: ArrayLike | float,
|
||||
strike: ArrayLike | float,
|
||||
rate: ArrayLike | float,
|
||||
time_to_expiry: ArrayLike | float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
model: str = "bsm",
|
||||
carry: ArrayLike | float = 0.0,
|
||||
initial_guess: ArrayLike | float = 0.2,
|
||||
tolerance: float = 1e-8,
|
||||
max_iterations: int = 100,
|
||||
) -> ScalarOrArray:
|
||||
"""Invert option prices to implied volatility."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
model = _validate_model(model)
|
||||
arrays, scalar_mode = _broadcast_inputs(
|
||||
price=price,
|
||||
underlying=underlying,
|
||||
strike=strike,
|
||||
rate=rate,
|
||||
time_to_expiry=time_to_expiry,
|
||||
carry=carry,
|
||||
initial_guess=initial_guess,
|
||||
)
|
||||
try:
|
||||
if scalar_mode:
|
||||
return float(
|
||||
_rust_implied_volatility(
|
||||
float(arrays["price"][0]),
|
||||
float(arrays["underlying"][0]),
|
||||
float(arrays["strike"][0]),
|
||||
float(arrays["rate"][0]),
|
||||
float(arrays["time_to_expiry"][0]),
|
||||
option_type,
|
||||
model,
|
||||
float(arrays["carry"][0]),
|
||||
float(arrays["initial_guess"][0]),
|
||||
float(tolerance),
|
||||
int(max_iterations),
|
||||
)
|
||||
)
|
||||
out = _rust_implied_volatility_batch(
|
||||
arrays["price"],
|
||||
arrays["underlying"],
|
||||
arrays["strike"],
|
||||
arrays["rate"],
|
||||
arrays["time_to_expiry"],
|
||||
option_type,
|
||||
model,
|
||||
arrays["carry"],
|
||||
arrays["initial_guess"],
|
||||
float(tolerance),
|
||||
int(max_iterations),
|
||||
)
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def smile_metrics(
|
||||
strikes: ArrayLike,
|
||||
vols: ArrayLike,
|
||||
reference_price: float,
|
||||
time_to_expiry: float,
|
||||
*,
|
||||
model: str = "bsm",
|
||||
rate: float = 0.0,
|
||||
carry: float = 0.0,
|
||||
) -> SmileMetrics:
|
||||
"""Compute ATM IV, 25-delta RR/BF, skew slope, and convexity."""
|
||||
model = _validate_model(model)
|
||||
strikes_arr = _to_f64(strikes)
|
||||
vols_arr = _to_f64(vols)
|
||||
order = np.argsort(strikes_arr)
|
||||
strikes_arr = strikes_arr[order]
|
||||
vols_arr = vols_arr[order]
|
||||
try:
|
||||
atm_iv, rr25, bf25, slope, convexity = _rust_smile_metrics(
|
||||
strikes_arr,
|
||||
vols_arr,
|
||||
float(reference_price),
|
||||
float(time_to_expiry),
|
||||
model,
|
||||
float(rate),
|
||||
float(carry),
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
return SmileMetrics(atm_iv, rr25, bf25, slope, convexity)
|
||||
|
||||
|
||||
def term_structure_slope(tenors: ArrayLike, atm_ivs: ArrayLike) -> float:
|
||||
"""Slope of ATM IV against tenor."""
|
||||
try:
|
||||
return float(_rust_term_structure_slope(_to_f64(tenors), _to_f64(atm_ivs)))
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
|
||||
|
||||
def label_moneyness(
|
||||
strikes: ArrayLike,
|
||||
reference_price: float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
) -> NDArray[np.object_]:
|
||||
"""Label strikes as ``ITM``, ``ATM``, or ``OTM``."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
try:
|
||||
codes = np.asarray(
|
||||
_rust_moneyness_labels(
|
||||
_to_f64(strikes), float(reference_price), option_type
|
||||
),
|
||||
dtype=np.int8,
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
mapping = np.array(["OTM", "ATM", "ITM"], dtype=object)
|
||||
return mapping[codes + 1]
|
||||
|
||||
|
||||
def _parse_selector_steps(selector: str) -> int:
|
||||
suffix = selector[3:]
|
||||
if suffix == "":
|
||||
return 1
|
||||
try:
|
||||
return int(suffix)
|
||||
except ValueError as err:
|
||||
raise FerroTAValueError(
|
||||
f"Could not parse strike selector '{selector}'. Expected forms like ATM, ITM1, OTM2."
|
||||
) from err
|
||||
|
||||
|
||||
def select_strike(
|
||||
strikes: ArrayLike,
|
||||
reference_price: float,
|
||||
*,
|
||||
option_type: str = "call",
|
||||
selector: str = "ATM",
|
||||
delta_target: float | None = None,
|
||||
volatilities: ArrayLike | None = None,
|
||||
time_to_expiry: float | None = None,
|
||||
model: str = "bsm",
|
||||
rate: float = 0.0,
|
||||
carry: float = 0.0,
|
||||
) -> float | None:
|
||||
"""Select a strike by ATM/ITM/OTM offset or delta target."""
|
||||
option_type = _validate_option_type(option_type)
|
||||
model = _validate_model(model)
|
||||
strikes_arr = _to_f64(strikes)
|
||||
|
||||
if len(strikes_arr) == 0:
|
||||
raise FerroTAInputError("strikes must not be empty.")
|
||||
|
||||
selector_norm = selector.strip().upper()
|
||||
if delta_target is None and selector_norm.startswith("DELTA"):
|
||||
try:
|
||||
delta_target = float(selector_norm.replace("DELTA", ""))
|
||||
except ValueError as err:
|
||||
raise FerroTAValueError(
|
||||
f"Could not parse delta selector '{selector}'. Example: selector='DELTA0.25'."
|
||||
) from err
|
||||
|
||||
if delta_target is not None:
|
||||
if volatilities is None or time_to_expiry is None:
|
||||
raise FerroTAValueError(
|
||||
"Delta-based strike selection requires volatilities and time_to_expiry."
|
||||
)
|
||||
vols_arr = _to_f64(volatilities)
|
||||
if len(vols_arr) != len(strikes_arr):
|
||||
raise FerroTAInputError(
|
||||
"strikes and volatilities must have the same length."
|
||||
)
|
||||
order = np.argsort(strikes_arr)
|
||||
strikes_arr = strikes_arr[order]
|
||||
vols_arr = vols_arr[order]
|
||||
try:
|
||||
strike = _rust_select_strike_delta(
|
||||
strikes_arr,
|
||||
vols_arr,
|
||||
float(reference_price),
|
||||
float(time_to_expiry),
|
||||
float(delta_target),
|
||||
option_type,
|
||||
model,
|
||||
float(rate),
|
||||
float(carry),
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
return None if strike is None else float(strike)
|
||||
|
||||
order = np.argsort(strikes_arr)
|
||||
sorted_strikes = strikes_arr[order]
|
||||
if selector_norm == "ATM":
|
||||
offset = 0
|
||||
elif selector_norm.startswith("ITM"):
|
||||
steps = _parse_selector_steps(selector_norm)
|
||||
offset = -steps if option_type == "call" else steps
|
||||
elif selector_norm.startswith("OTM"):
|
||||
steps = _parse_selector_steps(selector_norm)
|
||||
offset = steps if option_type == "call" else -steps
|
||||
else:
|
||||
raise FerroTAValueError(
|
||||
f"Unsupported selector '{selector}'. Use ATM, ITM<n>, OTM<n>, or DELTA<x>."
|
||||
)
|
||||
|
||||
try:
|
||||
strike = _rust_select_strike_offset(
|
||||
sorted_strikes, float(reference_price), int(offset)
|
||||
)
|
||||
except ValueError as err:
|
||||
_normalize_rust_error(err)
|
||||
return None if strike is None else float(strike)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
ferro_ta.analysis.options_strategy — Typed strategy parameter schemas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import date
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from ferro_ta.core.exceptions import FerroTAInputError, FerroTAValueError
|
||||
|
||||
__all__ = [
|
||||
"ExpirySelectorKind",
|
||||
"StrikeSelectorKind",
|
||||
"LegPreset",
|
||||
"RiskMode",
|
||||
"ExpirySelector",
|
||||
"StrikeSelector",
|
||||
"RiskControl",
|
||||
"SimulationLimits",
|
||||
"StrategyLeg",
|
||||
"DerivativesStrategy",
|
||||
"build_strategy_preset",
|
||||
]
|
||||
|
||||
|
||||
class ExpirySelectorKind(str, Enum):
|
||||
CURRENT_WEEK = "current_week"
|
||||
NEXT_WEEK = "next_week"
|
||||
CURRENT_MONTH = "current_month"
|
||||
NEXT_MONTH = "next_month"
|
||||
EXPLICIT_DATE = "explicit_date"
|
||||
|
||||
|
||||
class StrikeSelectorKind(str, Enum):
|
||||
ATM = "atm"
|
||||
ITM = "itm"
|
||||
OTM = "otm"
|
||||
DELTA = "delta"
|
||||
EXPLICIT = "explicit"
|
||||
|
||||
|
||||
class LegPreset(str, Enum):
|
||||
STRADDLE = "straddle"
|
||||
STRANGLE = "strangle"
|
||||
IRON_CONDOR = "iron_condor"
|
||||
BULL_CALL_SPREAD = "bull_call_spread"
|
||||
BEAR_PUT_SPREAD = "bear_put_spread"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class RiskMode(str, Enum):
|
||||
PER_LEG = "per_leg"
|
||||
COMBINED_PNL = "combined_pnl"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpirySelector:
|
||||
kind: ExpirySelectorKind | str
|
||||
explicit_date: date | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
kind = ExpirySelectorKind(self.kind)
|
||||
object.__setattr__(self, "kind", kind)
|
||||
if kind is ExpirySelectorKind.EXPLICIT_DATE and self.explicit_date is None:
|
||||
raise FerroTAValueError(
|
||||
"ExpirySelector(kind='explicit_date') requires explicit_date."
|
||||
)
|
||||
if (
|
||||
kind is not ExpirySelectorKind.EXPLICIT_DATE
|
||||
and self.explicit_date is not None
|
||||
):
|
||||
raise FerroTAValueError(
|
||||
"explicit_date is only valid when kind='explicit_date'."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrikeSelector:
|
||||
kind: StrikeSelectorKind | str
|
||||
steps: int = 0
|
||||
delta: float | None = None
|
||||
explicit_strike: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
kind = StrikeSelectorKind(self.kind)
|
||||
object.__setattr__(self, "kind", kind)
|
||||
if self.steps < 0:
|
||||
raise FerroTAValueError("steps must be >= 0.")
|
||||
if kind is StrikeSelectorKind.DELTA and self.delta is None:
|
||||
raise FerroTAValueError(
|
||||
"StrikeSelector(kind='delta') requires a delta target."
|
||||
)
|
||||
if self.delta is not None and not (0.0 < float(self.delta) < 1.0):
|
||||
raise FerroTAValueError("delta must be in the open interval (0, 1).")
|
||||
if kind is StrikeSelectorKind.EXPLICIT and self.explicit_strike is None:
|
||||
raise FerroTAValueError(
|
||||
"StrikeSelector(kind='explicit') requires explicit_strike."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RiskControl:
|
||||
stop_loss_type: str | None = None
|
||||
stop_loss_value: float | None = None
|
||||
target_type: str | None = None
|
||||
target_value: float | None = None
|
||||
trailstop_type: str | None = None
|
||||
trailstop_value: float | None = None
|
||||
breakeven_trigger: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"stop_loss_value",
|
||||
"target_value",
|
||||
"trailstop_value",
|
||||
"breakeven_trigger",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if value is not None and float(value) < 0.0:
|
||||
raise FerroTAValueError(f"{name} must be >= 0.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SimulationLimits:
|
||||
max_premium_outlay: float | None = None
|
||||
max_loss_per_trade: float | None = None
|
||||
daily_max_drawdown: float | None = None
|
||||
cooldown_bars: int = 0
|
||||
reentry_allowed: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"max_premium_outlay",
|
||||
"max_loss_per_trade",
|
||||
"daily_max_drawdown",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if value is not None and float(value) < 0.0:
|
||||
raise FerroTAValueError(f"{name} must be >= 0.")
|
||||
if self.cooldown_bars < 0:
|
||||
raise FerroTAValueError("cooldown_bars must be >= 0.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StrategyLeg:
|
||||
underlying: str
|
||||
expiry_selector: ExpirySelector
|
||||
strike_selector: StrikeSelector
|
||||
option_type: str
|
||||
side: str = "long"
|
||||
quantity: int = 1
|
||||
instrument: str = "option"
|
||||
premium_limit: float | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.underlying.strip() == "":
|
||||
raise FerroTAInputError("underlying must not be empty.")
|
||||
if self.option_type not in {"call", "put"}:
|
||||
raise FerroTAValueError("option_type must be 'call' or 'put'.")
|
||||
if self.side not in {"long", "short"}:
|
||||
raise FerroTAValueError("side must be 'long' or 'short'.")
|
||||
if self.instrument not in {"option", "future"}:
|
||||
raise FerroTAValueError("instrument must be 'option' or 'future'.")
|
||||
if self.quantity == 0:
|
||||
raise FerroTAValueError("quantity must be non-zero.")
|
||||
if self.premium_limit is not None and self.premium_limit < 0.0:
|
||||
raise FerroTAValueError("premium_limit must be >= 0.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DerivativesStrategy:
|
||||
name: str
|
||||
preset: LegPreset | str = LegPreset.CUSTOM
|
||||
legs: tuple[StrategyLeg, ...] = field(default_factory=tuple)
|
||||
risk_controls: RiskControl = field(default_factory=RiskControl)
|
||||
risk_mode: RiskMode | str = RiskMode.COMBINED_PNL
|
||||
commission: float = 0.0
|
||||
slippage: float = 0.0
|
||||
spread_assumption: float = 0.0
|
||||
limits: SimulationLimits = field(default_factory=SimulationLimits)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
preset = LegPreset(self.preset)
|
||||
risk_mode = RiskMode(self.risk_mode)
|
||||
object.__setattr__(self, "preset", preset)
|
||||
object.__setattr__(self, "risk_mode", risk_mode)
|
||||
if self.name.strip() == "":
|
||||
raise FerroTAInputError("name must not be empty.")
|
||||
if len(self.legs) == 0:
|
||||
raise FerroTAInputError("legs must contain at least one strategy leg.")
|
||||
for cost_name in ("commission", "slippage", "spread_assumption"):
|
||||
if float(getattr(self, cost_name)) < 0.0:
|
||||
raise FerroTAValueError(f"{cost_name} must be >= 0.")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def build_strategy_preset(
|
||||
preset: LegPreset | str,
|
||||
*,
|
||||
name: str,
|
||||
underlying: str,
|
||||
expiry_selector: ExpirySelector,
|
||||
base_strike_selector: StrikeSelector | None = None,
|
||||
risk_controls: RiskControl | None = None,
|
||||
risk_mode: RiskMode | str = RiskMode.COMBINED_PNL,
|
||||
commission: float = 0.0,
|
||||
slippage: float = 0.0,
|
||||
spread_assumption: float = 0.0,
|
||||
limits: SimulationLimits | None = None,
|
||||
) -> DerivativesStrategy:
|
||||
"""Build a common research preset using typed leg definitions."""
|
||||
preset = LegPreset(preset)
|
||||
risk_controls = risk_controls or RiskControl()
|
||||
limits = limits or SimulationLimits()
|
||||
atm = base_strike_selector or StrikeSelector(StrikeSelectorKind.ATM)
|
||||
|
||||
if preset is LegPreset.CUSTOM:
|
||||
raise FerroTAValueError(
|
||||
"build_strategy_preset does not construct CUSTOM presets."
|
||||
)
|
||||
|
||||
legs: tuple[StrategyLeg, ...]
|
||||
|
||||
if preset is LegPreset.STRADDLE:
|
||||
legs = (
|
||||
StrategyLeg(underlying, expiry_selector, atm, "call", "long"),
|
||||
StrategyLeg(underlying, expiry_selector, atm, "put", "long"),
|
||||
)
|
||||
elif preset is LegPreset.STRANGLE:
|
||||
legs = (
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"call",
|
||||
"long",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"put",
|
||||
"long",
|
||||
),
|
||||
)
|
||||
elif preset is LegPreset.BULL_CALL_SPREAD:
|
||||
legs = (
|
||||
StrategyLeg(underlying, expiry_selector, atm, "call", "long"),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"call",
|
||||
"short",
|
||||
),
|
||||
)
|
||||
elif preset is LegPreset.BEAR_PUT_SPREAD:
|
||||
legs = (
|
||||
StrategyLeg(underlying, expiry_selector, atm, "put", "long"),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"put",
|
||||
"short",
|
||||
),
|
||||
)
|
||||
elif preset is LegPreset.IRON_CONDOR:
|
||||
legs = (
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"put",
|
||||
"short",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=2),
|
||||
"put",
|
||||
"long",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=1),
|
||||
"call",
|
||||
"short",
|
||||
),
|
||||
StrategyLeg(
|
||||
underlying,
|
||||
expiry_selector,
|
||||
StrikeSelector(StrikeSelectorKind.OTM, steps=2),
|
||||
"call",
|
||||
"long",
|
||||
),
|
||||
)
|
||||
else:
|
||||
raise FerroTAValueError(f"Unsupported preset '{preset.value}'.")
|
||||
|
||||
return DerivativesStrategy(
|
||||
name=name,
|
||||
preset=preset,
|
||||
legs=legs,
|
||||
risk_controls=risk_controls,
|
||||
risk_mode=risk_mode,
|
||||
commission=commission,
|
||||
slippage=slippage,
|
||||
spread_assumption=spread_assumption,
|
||||
limits=limits,
|
||||
)
|
||||
@@ -33,6 +33,7 @@ import numpy as np
|
||||
from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
from ferro_ta._ferro_ta import bottom_n_indices as _rust_bottom_n
|
||||
from ferro_ta._ferro_ta import compose_rank as _rust_compose_rank
|
||||
from ferro_ta._ferro_ta import compose_weighted as _rust_compose_weighted
|
||||
from ferro_ta._ferro_ta import rank_series as _rust_rank_series
|
||||
from ferro_ta._ferro_ta import top_n_indices as _rust_top_n
|
||||
@@ -131,13 +132,7 @@ def compose(
|
||||
w = np.full(n_sigs, 1.0 / n_sigs)
|
||||
return _rust_compose_weighted(arr, w)
|
||||
elif method == "rank":
|
||||
# Replace each column with its rank, then sum (ensure contiguous slices)
|
||||
ranked = np.column_stack(
|
||||
[_rust_rank_series(np.ascontiguousarray(arr[:, j])) for j in range(n_sigs)]
|
||||
)
|
||||
ranked = np.ascontiguousarray(ranked)
|
||||
w = np.full(n_sigs, 1.0)
|
||||
return _rust_compose_weighted(ranked, w)
|
||||
return _rust_compose_rank(arr)
|
||||
else:
|
||||
# weighted (default)
|
||||
if weights is None:
|
||||
|
||||
@@ -29,7 +29,7 @@ Usage
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
@@ -52,6 +52,13 @@ from ferro_ta._ferro_ta import (
|
||||
from ferro_ta._ferro_ta import (
|
||||
batch_stoch as _rust_batch_stoch,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
run_close_indicators as _rust_run_close_indicators,
|
||||
)
|
||||
from ferro_ta._ferro_ta import (
|
||||
run_hlc_indicators as _rust_run_hlc_indicators,
|
||||
)
|
||||
from ferro_ta.core.registry import run as _registry_run
|
||||
from ferro_ta.indicators.momentum import RSI
|
||||
from ferro_ta.indicators.overlap import EMA, SMA
|
||||
|
||||
@@ -60,8 +67,156 @@ __all__ = [
|
||||
"batch_ema",
|
||||
"batch_rsi",
|
||||
"batch_apply",
|
||||
"compute_many",
|
||||
]
|
||||
|
||||
_CLOSE_FASTPATH_DEFAULTS: dict[str, int] = {
|
||||
"SMA": 30,
|
||||
"EMA": 30,
|
||||
"RSI": 14,
|
||||
"STDDEV": 5,
|
||||
"VAR": 5,
|
||||
"LINEARREG": 14,
|
||||
"LINEARREG_SLOPE": 14,
|
||||
"LINEARREG_INTERCEPT": 14,
|
||||
"LINEARREG_ANGLE": 14,
|
||||
"TSF": 14,
|
||||
}
|
||||
|
||||
_HLC_FASTPATH_DEFAULTS: dict[str, int] = {
|
||||
"ATR": 14,
|
||||
"NATR": 14,
|
||||
"ADX": 14,
|
||||
"ADXR": 14,
|
||||
"CCI": 14,
|
||||
"WILLR": 14,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_indicator_spec(
|
||||
spec: str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object],
|
||||
) -> tuple[str, dict[str, object], object | None]:
|
||||
if isinstance(spec, str):
|
||||
return spec, {}, None
|
||||
if len(spec) == 2:
|
||||
name, kwargs = spec
|
||||
return name, kwargs, None
|
||||
name, kwargs, out_key = spec
|
||||
return name, kwargs, out_key
|
||||
|
||||
|
||||
def _extract_timeperiod(
|
||||
name: str, kwargs: dict[str, object], defaults: dict[str, int]
|
||||
) -> int | None:
|
||||
if name not in defaults:
|
||||
return None
|
||||
extra_keys = set(kwargs) - {"timeperiod"}
|
||||
if extra_keys:
|
||||
return None
|
||||
raw_value = kwargs.get("timeperiod", defaults[name])
|
||||
if not isinstance(raw_value, int):
|
||||
return None
|
||||
return raw_value
|
||||
|
||||
|
||||
def compute_many(
|
||||
indicators: Sequence[
|
||||
str | tuple[str, dict[str, object]] | tuple[str, dict[str, object], object]
|
||||
],
|
||||
*,
|
||||
close: ArrayLike,
|
||||
high: ArrayLike | None = None,
|
||||
low: ArrayLike | None = None,
|
||||
volume: ArrayLike | None = None,
|
||||
parallel: bool = True,
|
||||
) -> list[object]:
|
||||
"""Compute multiple indicators over the same arrays with grouped Rust calls.
|
||||
|
||||
Supported single-output indicators are grouped into one Rust boundary crossing
|
||||
per input-shape family (`close` only or `high/low/close`). Unsupported specs
|
||||
fall back to the regular registry path, preserving behavior.
|
||||
"""
|
||||
|
||||
close_arr = np.ascontiguousarray(close, dtype=np.float64)
|
||||
high_arr = None if high is None else np.ascontiguousarray(high, dtype=np.float64)
|
||||
low_arr = None if low is None else np.ascontiguousarray(low, dtype=np.float64)
|
||||
volume_arr = (
|
||||
None if volume is None else np.ascontiguousarray(volume, dtype=np.float64)
|
||||
)
|
||||
|
||||
normalized = [_normalize_indicator_spec(spec) for spec in indicators]
|
||||
results: list[object | None] = [None] * len(normalized)
|
||||
|
||||
close_indices: list[int] = []
|
||||
close_names: list[str] = []
|
||||
close_periods: list[int] = []
|
||||
|
||||
hlc_indices: list[int] = []
|
||||
hlc_names: list[str] = []
|
||||
hlc_periods: list[int] = []
|
||||
|
||||
for idx, (name, kwargs, out_key) in enumerate(normalized):
|
||||
if out_key is None:
|
||||
close_period = _extract_timeperiod(name, kwargs, _CLOSE_FASTPATH_DEFAULTS)
|
||||
if close_period is not None:
|
||||
close_indices.append(idx)
|
||||
close_names.append(name)
|
||||
close_periods.append(close_period)
|
||||
continue
|
||||
|
||||
hlc_period = _extract_timeperiod(name, kwargs, _HLC_FASTPATH_DEFAULTS)
|
||||
if hlc_period is not None and high_arr is not None and low_arr is not None:
|
||||
hlc_indices.append(idx)
|
||||
hlc_names.append(name)
|
||||
hlc_periods.append(hlc_period)
|
||||
continue
|
||||
|
||||
if close_names:
|
||||
grouped = _rust_run_close_indicators(
|
||||
close_arr, close_names, close_periods, parallel
|
||||
)
|
||||
for idx, value in zip(close_indices, grouped):
|
||||
results[idx] = np.asarray(value, dtype=np.float64)
|
||||
|
||||
if hlc_names and high_arr is not None and low_arr is not None:
|
||||
grouped = _rust_run_hlc_indicators(
|
||||
high_arr, low_arr, close_arr, hlc_names, hlc_periods, parallel
|
||||
)
|
||||
for idx, value in zip(hlc_indices, grouped):
|
||||
results[idx] = np.asarray(value, dtype=np.float64)
|
||||
|
||||
for idx, (name, kwargs, _) in enumerate(normalized):
|
||||
if results[idx] is not None:
|
||||
continue
|
||||
try:
|
||||
results[idx] = _registry_run(name, close_arr, **kwargs)
|
||||
continue
|
||||
except (TypeError, Exception):
|
||||
pass
|
||||
|
||||
if high_arr is not None and low_arr is not None:
|
||||
try:
|
||||
results[idx] = _registry_run(
|
||||
name, high_arr, low_arr, close_arr, **kwargs
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
if volume_arr is not None:
|
||||
try:
|
||||
results[idx] = _registry_run(
|
||||
name, high_arr, low_arr, close_arr, volume_arr, **kwargs
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ValueError(
|
||||
f"Cannot call indicator '{name}': insufficient data columns or incompatible parameters."
|
||||
)
|
||||
|
||||
return [result for result in results]
|
||||
|
||||
|
||||
def batch_apply(
|
||||
data: ArrayLike,
|
||||
|
||||
@@ -67,6 +67,7 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ferro_ta as ft
|
||||
from ferro_ta.tools import (
|
||||
compute_indicator,
|
||||
describe_indicator,
|
||||
@@ -392,7 +393,7 @@ def _run_stdio_fallback() -> None: # pragma: no cover
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "ferro-ta", "version": "1.0.0"},
|
||||
"serverInfo": {"name": "ferro-ta", "version": ft.__version__},
|
||||
},
|
||||
}
|
||||
elif method == "tools/list":
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"""
|
||||
ferro_ta.api_info — API discovery helpers.
|
||||
|
||||
Provides :func:`indicators` and :func:`info` for exploring the ferro_ta
|
||||
indicator catalogue without reading source code.
|
||||
Provides :func:`indicators`, :func:`methods`, :func:`about`, and :func:`info`
|
||||
for exploring the ferro_ta public API without reading source code.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import ferro_ta
|
||||
>>> ferro_ta.indicators() # all indicators, sorted
|
||||
>>> ferro_ta.indicators(category="momentum") # filter by category
|
||||
>>> ferro_ta.methods() # public callables across modules
|
||||
>>> ferro_ta.about()["version"] # package metadata summary
|
||||
>>> ferro_ta.info(ferro_ta.SMA) # parameter docs for SMA
|
||||
|
||||
API
|
||||
---
|
||||
indicators(category=None) — Return list of dicts describing every indicator.
|
||||
methods(category=None) — Return list of public callables across modules.
|
||||
about() — Return package/version/module summary metadata.
|
||||
info(func_or_name) — Return a dict with full signature/docstring info.
|
||||
"""
|
||||
|
||||
@@ -23,7 +27,7 @@ import importlib
|
||||
import inspect
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["indicators", "info"]
|
||||
__all__ = ["indicators", "methods", "about", "info"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Category → module mapping used by indicators()
|
||||
@@ -52,6 +56,20 @@ _CATEGORY_MODULES: dict[str, str] = {
|
||||
"regime": "ferro_ta.analysis.regime",
|
||||
}
|
||||
|
||||
_METHOD_MODULES: dict[str, str] = {
|
||||
"top_level": "ferro_ta",
|
||||
**_CATEGORY_MODULES,
|
||||
"options": "ferro_ta.analysis.options",
|
||||
"futures": "ferro_ta.analysis.futures",
|
||||
"backtest": "ferro_ta.analysis.backtest",
|
||||
"options_strategy": "ferro_ta.analysis.options_strategy",
|
||||
"derivatives_payoff": "ferro_ta.analysis.derivatives_payoff",
|
||||
"attribution": "ferro_ta.analysis.attribution",
|
||||
"cross_asset": "ferro_ta.analysis.cross_asset",
|
||||
"tools": "ferro_ta.tools.tools",
|
||||
"viz": "ferro_ta.tools.viz",
|
||||
}
|
||||
|
||||
|
||||
def _iter_module_callables(
|
||||
module_name: str,
|
||||
@@ -137,6 +155,66 @@ def indicators(category: str | None = None) -> list[dict[str, Any]]:
|
||||
return result
|
||||
|
||||
|
||||
def methods(category: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Return public callables across ferro_ta modules.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
category : str | None
|
||||
Optional key from :data:`_METHOD_MODULES`, such as ``"top_level"``,
|
||||
``"options"``, ``"futures"``, or ``"batch"``.
|
||||
"""
|
||||
cats: dict[str, str] = (
|
||||
{category: _METHOD_MODULES[category]}
|
||||
if category is not None
|
||||
else _METHOD_MODULES
|
||||
)
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for cat, mod_name in cats.items():
|
||||
for name, func in _iter_module_callables(mod_name):
|
||||
key = (mod_name, name)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
doc = inspect.getdoc(func) or ""
|
||||
first_line = doc.splitlines()[0] if doc else ""
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
params = list(sig.parameters.keys())
|
||||
except (ValueError, TypeError):
|
||||
params = []
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": cat,
|
||||
"module": mod_name,
|
||||
"doc": first_line,
|
||||
"params": params,
|
||||
}
|
||||
)
|
||||
|
||||
result.sort(key=lambda d: (d["category"], d["name"]))
|
||||
return result
|
||||
|
||||
|
||||
def about() -> dict[str, Any]:
|
||||
"""Return a small metadata summary for the installed ferro_ta package."""
|
||||
import ferro_ta # noqa: PLC0415
|
||||
|
||||
top_level_exports = sorted(getattr(ferro_ta, "__all__", []))
|
||||
return {
|
||||
"name": "ferro-ta",
|
||||
"version": getattr(ferro_ta, "__version__", "0+unknown"),
|
||||
"top_level_export_count": len(top_level_exports),
|
||||
"indicator_count": len(indicators()),
|
||||
"method_count": len(methods()),
|
||||
"categories": sorted(_METHOD_MODULES.keys()),
|
||||
"top_level_exports": top_level_exports,
|
||||
}
|
||||
|
||||
|
||||
def info(func_or_name: Any) -> dict[str, Any]:
|
||||
"""Return detailed information about an indicator function.
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update or verify ferro-ta version strings across release files.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 scripts/bump_version.py 1.0.3
|
||||
python3 scripts/bump_version.py --check
|
||||
python3 scripts/bump_version.py --show
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VersionCarrier:
|
||||
label: str
|
||||
path: Path
|
||||
pattern: str
|
||||
replacement: str
|
||||
|
||||
def read(self) -> str:
|
||||
text = self.path.read_text(encoding="utf-8")
|
||||
match = re.search(self.pattern, text, flags=re.MULTILINE)
|
||||
if not match:
|
||||
raise ValueError(f"Could not find version for {self.label} in {self.path}")
|
||||
return match.group(2)
|
||||
|
||||
def write(self, version: str) -> bool:
|
||||
text = self.path.read_text(encoding="utf-8")
|
||||
updated, count = re.subn(
|
||||
self.pattern,
|
||||
rf"\g<1>{version}\g<3>",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if count != 1:
|
||||
raise ValueError(f"Could not update {self.label} in {self.path}")
|
||||
changed = updated != text
|
||||
if changed:
|
||||
self.path.write_text(updated, encoding="utf-8")
|
||||
return changed
|
||||
|
||||
|
||||
CARRIERS = [
|
||||
VersionCarrier(
|
||||
"cargo_root",
|
||||
ROOT / "Cargo.toml",
|
||||
r'(?m)^(version = ")([^"]+)(")$',
|
||||
r"\g<1>{version}\g<3>",
|
||||
),
|
||||
VersionCarrier(
|
||||
"cargo_core_dep",
|
||||
ROOT / "Cargo.toml",
|
||||
r'(ferro_ta_core = \{ path = "crates/ferro_ta_core", version = ")([^"]+)(" \})',
|
||||
r"\g<1>{version}\g<3>",
|
||||
),
|
||||
VersionCarrier(
|
||||
"cargo_core_crate",
|
||||
ROOT / "crates" / "ferro_ta_core" / "Cargo.toml",
|
||||
r'(?m)^(version = ")([^"]+)(")$',
|
||||
r"\g<1>{version}\g<3>",
|
||||
),
|
||||
VersionCarrier(
|
||||
"cargo_core_readme",
|
||||
ROOT / "crates" / "ferro_ta_core" / "README.md",
|
||||
r'(ferro_ta_core = ")([^"]+)(")',
|
||||
r"\g<1>{version}\g<3>",
|
||||
),
|
||||
VersionCarrier(
|
||||
"pyproject",
|
||||
ROOT / "pyproject.toml",
|
||||
r'(?m)^(version = ")([^"]+)(")$',
|
||||
r"\g<1>{version}\g<3>",
|
||||
),
|
||||
VersionCarrier(
|
||||
"wasm_cargo",
|
||||
ROOT / "wasm" / "Cargo.toml",
|
||||
r'(?m)^(version = ")([^"]+)(")$',
|
||||
r"\g<1>{version}\g<3>",
|
||||
),
|
||||
VersionCarrier(
|
||||
"wasm_package",
|
||||
ROOT / "wasm" / "package.json",
|
||||
r'("version": ")([^"]+)(")',
|
||||
r"\g<1>{version}\g<3>",
|
||||
),
|
||||
VersionCarrier(
|
||||
"conda",
|
||||
ROOT / "conda" / "meta.yaml",
|
||||
r'({% set version = ")([^"]+)(" %})',
|
||||
r"\g<1>{version}\g<3>",
|
||||
),
|
||||
VersionCarrier(
|
||||
"docs_changelog",
|
||||
ROOT / "docs" / "changelog.rst",
|
||||
r"(These docs track package version ``)([^`]+)(``\.)",
|
||||
r"\g<1>{version}\g<3>",
|
||||
),
|
||||
VersionCarrier(
|
||||
"docs_support_matrix",
|
||||
ROOT / "docs" / "support_matrix.rst",
|
||||
r"(These docs track package version ``)([^`]+)(``\.)",
|
||||
r"\g<1>{version}\g<3>",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _read_versions() -> dict[str, str]:
|
||||
return {carrier.label: carrier.read() for carrier in CARRIERS}
|
||||
|
||||
|
||||
def _print_versions(versions: dict[str, str]) -> None:
|
||||
for label, version in versions.items():
|
||||
print(f"{label:20} {version}")
|
||||
|
||||
|
||||
def _check_versions() -> int:
|
||||
versions = _read_versions()
|
||||
unique = sorted(set(versions.values()))
|
||||
_print_versions(versions)
|
||||
if len(unique) != 1:
|
||||
print()
|
||||
print(f"ERROR: version mismatch detected: {', '.join(unique)}")
|
||||
return 1
|
||||
print()
|
||||
print(f"OK: all tracked versions match {unique[0]}")
|
||||
return 0
|
||||
|
||||
|
||||
def _set_version(version: str) -> int:
|
||||
if not SEMVER_RE.match(version):
|
||||
print(f"ERROR: expected MAJOR.MINOR.PATCH, got {version!r}")
|
||||
return 1
|
||||
|
||||
changed_paths: list[Path] = []
|
||||
for carrier in CARRIERS:
|
||||
if carrier.write(version):
|
||||
changed_paths.append(carrier.path)
|
||||
|
||||
if changed_paths:
|
||||
print(f"Updated version to {version}:")
|
||||
for path in sorted(set(changed_paths)):
|
||||
print(f" - {path.relative_to(ROOT)}")
|
||||
else:
|
||||
print(f"No changes needed. All tracked files already use {version}.")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("version", nargs="?", help="New version to write")
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Fail if tracked version strings do not match",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show",
|
||||
action="store_true",
|
||||
help="Print tracked version strings without modifying files",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.check:
|
||||
return _check_versions()
|
||||
if args.show:
|
||||
_print_versions(_read_versions())
|
||||
return 0
|
||||
if args.version:
|
||||
return _set_version(args.version)
|
||||
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+412
-133
@@ -9,11 +9,273 @@
|
||||
//! the sequential path (`parallel = false`) may be faster due to thread-pool
|
||||
//! overhead.
|
||||
|
||||
use ndarray::Array2;
|
||||
use numpy::{IntoPyArray, PyArray2, PyReadonlyArray2};
|
||||
use ndarray::{Array2, ArrayView2};
|
||||
use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
use rayon::prelude::*;
|
||||
use ta::indicators::{Maximum, Minimum};
|
||||
use ta::Next;
|
||||
|
||||
fn transpose_to_series_major(data: ArrayView2<'_, f64>) -> Array2<f64> {
|
||||
let (n_samples, n_series) = data.dim();
|
||||
Array2::from_shape_vec((n_series, n_samples), data.t().iter().copied().collect())
|
||||
.expect("shape matches transposed data")
|
||||
}
|
||||
|
||||
fn validate_same_shape(
|
||||
expected: (usize, usize),
|
||||
actual: (usize, usize),
|
||||
name: &str,
|
||||
) -> PyResult<()> {
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PyValueError::new_err(format!(
|
||||
"{name} must have shape {:?}, got {:?}",
|
||||
expected, actual
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_single_output<'py>(
|
||||
py: Python<'py>,
|
||||
n_samples: usize,
|
||||
n_series: usize,
|
||||
col_results: Vec<Vec<f64>>,
|
||||
) -> Bound<'py, PyArray2<f64>> {
|
||||
let mut result = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
|
||||
for (series_idx, values) in col_results.into_iter().enumerate() {
|
||||
debug_assert_eq!(values.len(), n_samples);
|
||||
for (sample_idx, value) in values.into_iter().enumerate() {
|
||||
result[[sample_idx, series_idx]] = value;
|
||||
}
|
||||
}
|
||||
result.into_pyarray(py)
|
||||
}
|
||||
|
||||
fn finish_pair_output<'py>(
|
||||
py: Python<'py>,
|
||||
n_samples: usize,
|
||||
n_series: usize,
|
||||
col_results: Vec<(Vec<f64>, Vec<f64>)>,
|
||||
) -> (Bound<'py, PyArray2<f64>>, Bound<'py, PyArray2<f64>>) {
|
||||
let mut result_k = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
|
||||
let mut result_d = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
|
||||
|
||||
for (series_idx, (k_values, d_values)) in col_results.into_iter().enumerate() {
|
||||
debug_assert_eq!(k_values.len(), n_samples);
|
||||
debug_assert_eq!(d_values.len(), n_samples);
|
||||
for (sample_idx, value) in k_values.into_iter().enumerate() {
|
||||
result_k[[sample_idx, series_idx]] = value;
|
||||
}
|
||||
for (sample_idx, value) in d_values.into_iter().enumerate() {
|
||||
result_d[[sample_idx, series_idx]] = value;
|
||||
}
|
||||
}
|
||||
|
||||
(result_k.into_pyarray(py), result_d.into_pyarray(py))
|
||||
}
|
||||
|
||||
fn run_unary_batch<'py, F>(
|
||||
py: Python<'py>,
|
||||
data: PyReadonlyArray2<'py, f64>,
|
||||
parallel: bool,
|
||||
process_col: F,
|
||||
) -> Bound<'py, PyArray2<f64>>
|
||||
where
|
||||
F: Fn(&[f64]) -> Vec<f64> + Sync,
|
||||
{
|
||||
let arr = data.as_array();
|
||||
let (n_samples, n_series) = arr.dim();
|
||||
let series_major = transpose_to_series_major(arr);
|
||||
|
||||
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
|
||||
let run = |series_idx: usize| {
|
||||
let column_row = series_major.row(series_idx);
|
||||
let column = column_row
|
||||
.as_slice()
|
||||
.expect("series-major rows are contiguous");
|
||||
process_col(column)
|
||||
};
|
||||
if parallel {
|
||||
(0..n_series).into_par_iter().map(run).collect()
|
||||
} else {
|
||||
(0..n_series).map(run).collect()
|
||||
}
|
||||
});
|
||||
|
||||
finish_single_output(py, n_samples, n_series, col_results)
|
||||
}
|
||||
|
||||
fn validate_indicator_requests(names: &[String], timeperiods: &[usize]) -> PyResult<()> {
|
||||
if names.len() != timeperiods.len() {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"names length ({}) must equal timeperiods length ({})",
|
||||
names.len(),
|
||||
timeperiods.len()
|
||||
)));
|
||||
}
|
||||
for (name, &timeperiod) in names.iter().zip(timeperiods.iter()) {
|
||||
if timeperiod == 0 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"{name}: timeperiod must be >= 1"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let typical_price: Vec<f64> = high
|
||||
.iter()
|
||||
.zip(low.iter())
|
||||
.zip(close.iter())
|
||||
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
|
||||
.collect();
|
||||
|
||||
let mut result = vec![f64::NAN; n];
|
||||
for end in (timeperiod - 1)..n {
|
||||
let window = &typical_price[(end + 1 - timeperiod)..=end];
|
||||
let mean = window.iter().sum::<f64>() / timeperiod as f64;
|
||||
let mad = window
|
||||
.iter()
|
||||
.map(|&value| (value - mean).abs())
|
||||
.sum::<f64>()
|
||||
/ timeperiod as f64;
|
||||
result[end] = if mad != 0.0 {
|
||||
(typical_price[end] - mean) / (0.015 * mad)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn compute_willr(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
) -> PyResult<Vec<f64>> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
let mut max_ind =
|
||||
Maximum::new(timeperiod).map_err(|err| PyValueError::new_err(err.to_string()))?;
|
||||
let mut min_ind =
|
||||
Minimum::new(timeperiod).map_err(|err| PyValueError::new_err(err.to_string()))?;
|
||||
|
||||
for (idx, ((&high_value, &low_value), &close_value)) in
|
||||
high.iter().zip(low.iter()).zip(close.iter()).enumerate()
|
||||
{
|
||||
let highest = max_ind.next(high_value);
|
||||
let lowest = min_ind.next(low_value);
|
||||
if idx + 1 >= timeperiod {
|
||||
let range = highest - lowest;
|
||||
result[idx] = if range != 0.0 {
|
||||
-100.0 * (highest - close_value) / range
|
||||
} else {
|
||||
-50.0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn compute_close_indicator(name: &str, close: &[f64], timeperiod: usize) -> PyResult<Vec<f64>> {
|
||||
match name {
|
||||
"SMA" => Ok(ferro_ta_core::overlap::sma(close, timeperiod)),
|
||||
"EMA" => Ok(ferro_ta_core::overlap::ema(close, timeperiod)),
|
||||
"RSI" => Ok(ferro_ta_core::momentum::rsi(close, timeperiod)),
|
||||
"STDDEV" => Ok(ferro_ta_core::statistic::stddev(close, timeperiod, 1.0)),
|
||||
"VAR" => Ok(ferro_ta_core::statistic::stddev(close, timeperiod, 1.0)
|
||||
.into_iter()
|
||||
.map(|value| if value.is_nan() { value } else { value * value })
|
||||
.collect()),
|
||||
"LINEARREG" => {
|
||||
use crate::statistic::common::rolling_linreg_apply;
|
||||
let last_x = (timeperiod - 1) as f64;
|
||||
Ok(rolling_linreg_apply(
|
||||
close,
|
||||
timeperiod,
|
||||
|slope: f64, intercept: f64| intercept + slope * last_x,
|
||||
))
|
||||
}
|
||||
"LINEARREG_SLOPE" => {
|
||||
use crate::statistic::common::rolling_linreg_apply;
|
||||
Ok(rolling_linreg_apply(
|
||||
close,
|
||||
timeperiod,
|
||||
|slope: f64, _: f64| slope,
|
||||
))
|
||||
}
|
||||
"LINEARREG_INTERCEPT" => {
|
||||
use crate::statistic::common::rolling_linreg_apply;
|
||||
Ok(rolling_linreg_apply(
|
||||
close,
|
||||
timeperiod,
|
||||
|_: f64, intercept: f64| intercept,
|
||||
))
|
||||
}
|
||||
"LINEARREG_ANGLE" => {
|
||||
use crate::statistic::common::rolling_linreg_apply;
|
||||
Ok(rolling_linreg_apply(
|
||||
close,
|
||||
timeperiod,
|
||||
|slope: f64, _: f64| slope.atan() * 180.0 / std::f64::consts::PI,
|
||||
))
|
||||
}
|
||||
"TSF" => {
|
||||
use crate::statistic::common::rolling_linreg_apply;
|
||||
let forecast_x = timeperiod as f64;
|
||||
Ok(rolling_linreg_apply(
|
||||
close,
|
||||
timeperiod,
|
||||
|slope: f64, intercept: f64| intercept + slope * forecast_x,
|
||||
))
|
||||
}
|
||||
_ => Err(PyValueError::new_err(format!(
|
||||
"unsupported close indicator for grouped execution: {name}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_hlc_indicator(
|
||||
name: &str,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
) -> PyResult<Vec<f64>> {
|
||||
match name {
|
||||
"ATR" => Ok(ferro_ta_core::volatility::atr(high, low, close, timeperiod)),
|
||||
"NATR" => {
|
||||
let atr = ferro_ta_core::volatility::atr(high, low, close, timeperiod);
|
||||
Ok(atr
|
||||
.into_iter()
|
||||
.zip(close.iter())
|
||||
.map(|(atr_value, &close_value)| {
|
||||
if atr_value.is_nan() || close_value == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
(atr_value / close_value) * 100.0
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
"ADX" => Ok(ferro_ta_core::momentum::adx(high, low, close, timeperiod)),
|
||||
"ADXR" => Ok(ferro_ta_core::momentum::adxr(high, low, close, timeperiod)),
|
||||
"CCI" => Ok(compute_cci(high, low, close, timeperiod)),
|
||||
"WILLR" => compute_willr(high, low, close, timeperiod),
|
||||
_ => Err(PyValueError::new_err(format!(
|
||||
"unsupported HLC indicator for grouped execution: {name}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
type IndicatorArrayList = Vec<Py<PyArray1<f64>>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch_sma
|
||||
@@ -43,34 +305,13 @@ pub fn batch_sma<'py>(
|
||||
if timeperiod == 0 {
|
||||
return Err(PyValueError::new_err("timeperiod must be >= 1"));
|
||||
}
|
||||
let arr = data.as_array();
|
||||
let (n_samples, n_series) = arr.dim();
|
||||
let (n_samples, n_series) = data.as_array().dim();
|
||||
log::debug!(
|
||||
"batch_sma: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
|
||||
);
|
||||
|
||||
// Extract columns to owned Vecs so we can release the GIL for parallel work.
|
||||
let columns: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr[[i, j]]).collect())
|
||||
.collect();
|
||||
|
||||
let process_col = |col: &Vec<f64>| -> Vec<f64> { ferro_ta_core::overlap::sma(col, timeperiod) };
|
||||
|
||||
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
|
||||
if parallel {
|
||||
columns.par_iter().map(process_col).collect()
|
||||
} else {
|
||||
columns.iter().map(process_col).collect()
|
||||
}
|
||||
});
|
||||
|
||||
let mut result = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
|
||||
for (j, col_result) in col_results.iter().enumerate() {
|
||||
for (i, &val) in col_result.iter().enumerate() {
|
||||
result[[i, j]] = val;
|
||||
}
|
||||
}
|
||||
Ok(result.into_pyarray(py))
|
||||
Ok(run_unary_batch(py, data, parallel, |col| {
|
||||
ferro_ta_core::overlap::sma(col, timeperiod)
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -100,33 +341,13 @@ pub fn batch_ema<'py>(
|
||||
if timeperiod == 0 {
|
||||
return Err(PyValueError::new_err("timeperiod must be >= 1"));
|
||||
}
|
||||
let arr = data.as_array();
|
||||
let (n_samples, n_series) = arr.dim();
|
||||
let (n_samples, n_series) = data.as_array().dim();
|
||||
log::debug!(
|
||||
"batch_ema: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
|
||||
);
|
||||
|
||||
let columns: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr[[i, j]]).collect())
|
||||
.collect();
|
||||
|
||||
let process_col = |col: &Vec<f64>| -> Vec<f64> { ferro_ta_core::overlap::ema(col, timeperiod) };
|
||||
|
||||
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
|
||||
if parallel {
|
||||
columns.par_iter().map(process_col).collect()
|
||||
} else {
|
||||
columns.iter().map(process_col).collect()
|
||||
}
|
||||
});
|
||||
|
||||
let mut result = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
|
||||
for (j, col_result) in col_results.iter().enumerate() {
|
||||
for (i, &val) in col_result.iter().enumerate() {
|
||||
result[[i, j]] = val;
|
||||
}
|
||||
}
|
||||
Ok(result.into_pyarray(py))
|
||||
Ok(run_unary_batch(py, data, parallel, |col| {
|
||||
ferro_ta_core::overlap::ema(col, timeperiod)
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -157,18 +378,13 @@ pub fn batch_rsi<'py>(
|
||||
if timeperiod == 0 {
|
||||
return Err(PyValueError::new_err("timeperiod must be >= 1"));
|
||||
}
|
||||
let arr = data.as_array();
|
||||
let (n_samples, n_series) = arr.dim();
|
||||
let (n_samples, n_series) = data.as_array().dim();
|
||||
log::debug!(
|
||||
"batch_rsi: timeperiod={timeperiod}, shape=({n_samples}, {n_series}), parallel={parallel}"
|
||||
);
|
||||
|
||||
let columns: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr[[i, j]]).collect())
|
||||
.collect();
|
||||
|
||||
let period_f = timeperiod as f64;
|
||||
let process_col = |col: &Vec<f64>| -> Vec<f64> {
|
||||
Ok(run_unary_batch(py, data, parallel, |col| {
|
||||
let mut col_result = vec![f64::NAN; n_samples];
|
||||
if n_samples <= timeperiod {
|
||||
return col_result;
|
||||
@@ -208,23 +424,7 @@ pub fn batch_rsi<'py>(
|
||||
col_result[i] = 100.0 - 100.0 / (1.0 + rs);
|
||||
}
|
||||
col_result
|
||||
};
|
||||
|
||||
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
|
||||
if parallel {
|
||||
columns.par_iter().map(process_col).collect()
|
||||
} else {
|
||||
columns.iter().map(process_col).collect()
|
||||
}
|
||||
});
|
||||
|
||||
let mut result = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
|
||||
for (j, col_result) in col_results.iter().enumerate() {
|
||||
for (i, &val) in col_result.iter().enumerate() {
|
||||
result[[i, j]] = val;
|
||||
}
|
||||
}
|
||||
Ok(result.into_pyarray(py))
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -248,20 +448,28 @@ pub fn batch_atr<'py>(
|
||||
let arr_l = low.as_array();
|
||||
let arr_c = close.as_array();
|
||||
let (n_samples, n_series) = arr_h.dim();
|
||||
validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?;
|
||||
validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?;
|
||||
|
||||
let cols_h: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect())
|
||||
.collect();
|
||||
let cols_l: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr_l[[i, j]]).collect())
|
||||
.collect();
|
||||
let cols_c: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr_c[[i, j]]).collect())
|
||||
.collect();
|
||||
let high_by_series = transpose_to_series_major(arr_h);
|
||||
let low_by_series = transpose_to_series_major(arr_l);
|
||||
let close_by_series = transpose_to_series_major(arr_c);
|
||||
|
||||
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
|
||||
let process_col = |j: usize| -> Vec<f64> {
|
||||
ferro_ta_core::volatility::atr(&cols_h[j], &cols_l[j], &cols_c[j], timeperiod)
|
||||
let process_col = |series_idx: usize| -> Vec<f64> {
|
||||
let high_row = high_by_series.row(series_idx);
|
||||
let low_row = low_by_series.row(series_idx);
|
||||
let close_row = close_by_series.row(series_idx);
|
||||
let high_col = high_row
|
||||
.as_slice()
|
||||
.expect("series-major rows are contiguous");
|
||||
let low_col = low_row
|
||||
.as_slice()
|
||||
.expect("series-major rows are contiguous");
|
||||
let close_col = close_row
|
||||
.as_slice()
|
||||
.expect("series-major rows are contiguous");
|
||||
ferro_ta_core::volatility::atr(high_col, low_col, close_col, timeperiod)
|
||||
};
|
||||
if parallel {
|
||||
(0..n_series).into_par_iter().map(process_col).collect()
|
||||
@@ -269,14 +477,7 @@ pub fn batch_atr<'py>(
|
||||
(0..n_series).map(process_col).collect()
|
||||
}
|
||||
});
|
||||
|
||||
let mut result = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
|
||||
for (j, col_result) in col_results.iter().enumerate() {
|
||||
for (i, &val) in col_result.iter().enumerate() {
|
||||
result[[i, j]] = val;
|
||||
}
|
||||
}
|
||||
Ok(result.into_pyarray(py))
|
||||
Ok(finish_single_output(py, n_samples, n_series, col_results))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -303,23 +504,31 @@ pub fn batch_stoch<'py>(
|
||||
let arr_l = low.as_array();
|
||||
let arr_c = close.as_array();
|
||||
let (n_samples, n_series) = arr_h.dim();
|
||||
validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?;
|
||||
validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?;
|
||||
|
||||
let cols_h: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect())
|
||||
.collect();
|
||||
let cols_l: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr_l[[i, j]]).collect())
|
||||
.collect();
|
||||
let cols_c: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr_c[[i, j]]).collect())
|
||||
.collect();
|
||||
let high_by_series = transpose_to_series_major(arr_h);
|
||||
let low_by_series = transpose_to_series_major(arr_l);
|
||||
let close_by_series = transpose_to_series_major(arr_c);
|
||||
|
||||
let col_results: Vec<(Vec<f64>, Vec<f64>)> = py.allow_threads(|| {
|
||||
let process_col = |j: usize| -> (Vec<f64>, Vec<f64>) {
|
||||
let process_col = |series_idx: usize| -> (Vec<f64>, Vec<f64>) {
|
||||
let high_row = high_by_series.row(series_idx);
|
||||
let low_row = low_by_series.row(series_idx);
|
||||
let close_row = close_by_series.row(series_idx);
|
||||
let high_col = high_row
|
||||
.as_slice()
|
||||
.expect("series-major rows are contiguous");
|
||||
let low_col = low_row
|
||||
.as_slice()
|
||||
.expect("series-major rows are contiguous");
|
||||
let close_col = close_row
|
||||
.as_slice()
|
||||
.expect("series-major rows are contiguous");
|
||||
ferro_ta_core::momentum::stoch(
|
||||
&cols_h[j],
|
||||
&cols_l[j],
|
||||
&cols_c[j],
|
||||
high_col,
|
||||
low_col,
|
||||
close_col,
|
||||
fastk_period,
|
||||
slowk_period,
|
||||
slowd_period,
|
||||
@@ -331,16 +540,7 @@ pub fn batch_stoch<'py>(
|
||||
(0..n_series).map(process_col).collect()
|
||||
}
|
||||
});
|
||||
|
||||
let mut result_k = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
|
||||
let mut result_d = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
|
||||
for (j, (k_col, d_col)) in col_results.iter().enumerate() {
|
||||
for i in 0..n_samples {
|
||||
result_k[[i, j]] = k_col[i];
|
||||
result_d[[i, j]] = d_col[i];
|
||||
}
|
||||
}
|
||||
Ok((result_k.into_pyarray(py), result_d.into_pyarray(py)))
|
||||
Ok(finish_pair_output(py, n_samples, n_series, col_results))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -364,20 +564,28 @@ pub fn batch_adx<'py>(
|
||||
let arr_l = low.as_array();
|
||||
let arr_c = close.as_array();
|
||||
let (n_samples, n_series) = arr_h.dim();
|
||||
validate_same_shape((n_samples, n_series), arr_l.dim(), "low")?;
|
||||
validate_same_shape((n_samples, n_series), arr_c.dim(), "close")?;
|
||||
|
||||
let cols_h: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr_h[[i, j]]).collect())
|
||||
.collect();
|
||||
let cols_l: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr_l[[i, j]]).collect())
|
||||
.collect();
|
||||
let cols_c: Vec<Vec<f64>> = (0..n_series)
|
||||
.map(|j| (0..n_samples).map(|i| arr_c[[i, j]]).collect())
|
||||
.collect();
|
||||
let high_by_series = transpose_to_series_major(arr_h);
|
||||
let low_by_series = transpose_to_series_major(arr_l);
|
||||
let close_by_series = transpose_to_series_major(arr_c);
|
||||
|
||||
let col_results: Vec<Vec<f64>> = py.allow_threads(|| {
|
||||
let process_col = |j: usize| -> Vec<f64> {
|
||||
ferro_ta_core::momentum::adx(&cols_h[j], &cols_l[j], &cols_c[j], timeperiod)
|
||||
let process_col = |series_idx: usize| -> Vec<f64> {
|
||||
let high_row = high_by_series.row(series_idx);
|
||||
let low_row = low_by_series.row(series_idx);
|
||||
let close_row = close_by_series.row(series_idx);
|
||||
let high_col = high_row
|
||||
.as_slice()
|
||||
.expect("series-major rows are contiguous");
|
||||
let low_col = low_row
|
||||
.as_slice()
|
||||
.expect("series-major rows are contiguous");
|
||||
let close_col = close_row
|
||||
.as_slice()
|
||||
.expect("series-major rows are contiguous");
|
||||
ferro_ta_core::momentum::adx(high_col, low_col, close_col, timeperiod)
|
||||
};
|
||||
if parallel {
|
||||
(0..n_series).into_par_iter().map(process_col).collect()
|
||||
@@ -385,14 +593,83 @@ pub fn batch_adx<'py>(
|
||||
(0..n_series).map(process_col).collect()
|
||||
}
|
||||
});
|
||||
Ok(finish_single_output(py, n_samples, n_series, col_results))
|
||||
}
|
||||
|
||||
let mut result = Array2::<f64>::from_elem((n_samples, n_series), f64::NAN);
|
||||
for (j, col_result) in col_results.iter().enumerate() {
|
||||
for (i, &val) in col_result.iter().enumerate() {
|
||||
result[[i, j]] = val;
|
||||
// ---------------------------------------------------------------------------
|
||||
// grouped 1-D execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (close, names, timeperiods, parallel = true))]
|
||||
pub fn run_close_indicators<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
names: Vec<String>,
|
||||
timeperiods: Vec<usize>,
|
||||
parallel: bool,
|
||||
) -> PyResult<IndicatorArrayList> {
|
||||
validate_indicator_requests(&names, &timeperiods)?;
|
||||
let close_values = close.as_slice()?;
|
||||
|
||||
let results: Vec<PyResult<Vec<f64>>> = py.allow_threads(|| {
|
||||
let run = |idx: usize| compute_close_indicator(&names[idx], close_values, timeperiods[idx]);
|
||||
if parallel {
|
||||
(0..names.len()).into_par_iter().map(run).collect()
|
||||
} else {
|
||||
(0..names.len()).map(run).collect()
|
||||
}
|
||||
});
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.map(|result| result.map(|values| values.into_pyarray(py).unbind()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (high, low, close, names, timeperiods, parallel = true))]
|
||||
pub fn run_hlc_indicators<'py>(
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
names: Vec<String>,
|
||||
timeperiods: Vec<usize>,
|
||||
parallel: bool,
|
||||
) -> PyResult<IndicatorArrayList> {
|
||||
validate_indicator_requests(&names, &timeperiods)?;
|
||||
let high_values = high.as_slice()?;
|
||||
let low_values = low.as_slice()?;
|
||||
let close_values = close.as_slice()?;
|
||||
|
||||
if high_values.len() != low_values.len() || high_values.len() != close_values.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, and close must have equal length",
|
||||
));
|
||||
}
|
||||
Ok(result.into_pyarray(py))
|
||||
|
||||
let results: Vec<PyResult<Vec<f64>>> = py.allow_threads(|| {
|
||||
let run = |idx: usize| {
|
||||
compute_hlc_indicator(
|
||||
&names[idx],
|
||||
high_values,
|
||||
low_values,
|
||||
close_values,
|
||||
timeperiods[idx],
|
||||
)
|
||||
};
|
||||
if parallel {
|
||||
(0..names.len()).into_par_iter().map(run).collect()
|
||||
} else {
|
||||
(0..names.len()).map(run).collect()
|
||||
}
|
||||
});
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.map(|result| result.map(|values| values.into_pyarray(py).unbind()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -406,5 +683,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(pyo3::wrap_pyfunction!(batch_atr, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(batch_stoch, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(batch_adx, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(run_close_indicators, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(run_hlc_indicators, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
use pyo3::prelude::*;
|
||||
|
||||
#[pyfunction]
|
||||
pub fn futures_basis(spot: f64, future: f64) -> PyResult<f64> {
|
||||
Ok(ferro_ta_core::futures::basis::basis(spot, future))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> PyResult<f64> {
|
||||
Ok(ferro_ta_core::futures::basis::annualized_basis(
|
||||
spot,
|
||||
future,
|
||||
time_to_expiry,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn implied_carry_rate(spot: f64, future: f64, time_to_expiry: f64) -> PyResult<f64> {
|
||||
Ok(ferro_ta_core::futures::basis::implied_carry_rate(
|
||||
spot,
|
||||
future,
|
||||
time_to_expiry,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn carry_spread(spot: f64, future: f64, rate: f64, time_to_expiry: f64) -> PyResult<f64> {
|
||||
Ok(ferro_ta_core::futures::basis::carry_spread(
|
||||
spot,
|
||||
future,
|
||||
rate,
|
||||
time_to_expiry,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::validation;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
#[pyfunction]
|
||||
pub fn calendar_spreads<'py>(
|
||||
py: Python<'py>,
|
||||
futures_prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
Ok(
|
||||
ferro_ta_core::futures::curve::calendar_spreads(futures_prices.as_slice()?)
|
||||
.into_pyarray(py),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn curve_slope<'py>(
|
||||
tenors: PyReadonlyArray1<'py, f64>,
|
||||
futures_prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<f64> {
|
||||
let tenors = tenors.as_slice()?;
|
||||
let futures_prices = futures_prices.as_slice()?;
|
||||
validation::validate_equal_length(&[
|
||||
(tenors.len(), "tenors"),
|
||||
(futures_prices.len(), "futures_prices"),
|
||||
])?;
|
||||
Ok(ferro_ta_core::futures::curve::curve_slope(
|
||||
tenors,
|
||||
futures_prices,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn curve_summary<'py>(
|
||||
spot: f64,
|
||||
tenors: PyReadonlyArray1<'py, f64>,
|
||||
futures_prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<(f64, f64, f64, bool)> {
|
||||
let tenors = tenors.as_slice()?;
|
||||
let futures_prices = futures_prices.as_slice()?;
|
||||
validation::validate_equal_length(&[
|
||||
(tenors.len(), "tenors"),
|
||||
(futures_prices.len(), "futures_prices"),
|
||||
])?;
|
||||
let summary = ferro_ta_core::futures::curve::curve_summary(spot, tenors, futures_prices);
|
||||
Ok((
|
||||
summary.front_basis,
|
||||
summary.average_basis,
|
||||
summary.slope,
|
||||
summary.is_contango,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! PyO3 wrappers for futures analytics.
|
||||
|
||||
mod basis;
|
||||
mod curve;
|
||||
mod roll;
|
||||
mod synthetic;
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::synthetic::synthetic_forward,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::synthetic::synthetic_spot, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::synthetic::parity_gap, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::basis::futures_basis, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::basis::annualized_basis, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::basis::implied_carry_rate, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::basis::carry_spread, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::roll::weighted_continuous_contract,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::roll::back_adjusted_continuous_contract,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::roll::ratio_adjusted_continuous_contract,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::roll::roll_yield, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::curve::calendar_spreads, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::curve::curve_slope, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::curve::curve_summary, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use crate::validation;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
#[pyfunction]
|
||||
pub fn weighted_continuous_contract<'py>(
|
||||
py: Python<'py>,
|
||||
front: PyReadonlyArray1<'py, f64>,
|
||||
next: PyReadonlyArray1<'py, f64>,
|
||||
next_weights: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let front = front.as_slice()?;
|
||||
let next = next.as_slice()?;
|
||||
let next_weights = next_weights.as_slice()?;
|
||||
validation::validate_equal_length(&[
|
||||
(front.len(), "front"),
|
||||
(next.len(), "next"),
|
||||
(next_weights.len(), "next_weights"),
|
||||
])?;
|
||||
Ok(
|
||||
ferro_ta_core::futures::roll::weighted_continuous(front, next, next_weights)
|
||||
.into_pyarray(py),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn back_adjusted_continuous_contract<'py>(
|
||||
py: Python<'py>,
|
||||
front: PyReadonlyArray1<'py, f64>,
|
||||
next: PyReadonlyArray1<'py, f64>,
|
||||
next_weights: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let front = front.as_slice()?;
|
||||
let next = next.as_slice()?;
|
||||
let next_weights = next_weights.as_slice()?;
|
||||
validation::validate_equal_length(&[
|
||||
(front.len(), "front"),
|
||||
(next.len(), "next"),
|
||||
(next_weights.len(), "next_weights"),
|
||||
])?;
|
||||
Ok(
|
||||
ferro_ta_core::futures::roll::back_adjusted_continuous(front, next, next_weights)
|
||||
.into_pyarray(py),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn ratio_adjusted_continuous_contract<'py>(
|
||||
py: Python<'py>,
|
||||
front: PyReadonlyArray1<'py, f64>,
|
||||
next: PyReadonlyArray1<'py, f64>,
|
||||
next_weights: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let front = front.as_slice()?;
|
||||
let next = next.as_slice()?;
|
||||
let next_weights = next_weights.as_slice()?;
|
||||
validation::validate_equal_length(&[
|
||||
(front.len(), "front"),
|
||||
(next.len(), "next"),
|
||||
(next_weights.len(), "next_weights"),
|
||||
])?;
|
||||
Ok(
|
||||
ferro_ta_core::futures::roll::ratio_adjusted_continuous(front, next, next_weights)
|
||||
.into_pyarray(py),
|
||||
)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn roll_yield(front_price: f64, next_price: f64, time_to_expiry: f64) -> PyResult<f64> {
|
||||
Ok(ferro_ta_core::futures::roll::roll_yield(
|
||||
front_price,
|
||||
next_price,
|
||||
time_to_expiry,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use pyo3::prelude::*;
|
||||
|
||||
#[pyfunction]
|
||||
pub fn synthetic_forward(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
) -> PyResult<f64> {
|
||||
Ok(ferro_ta_core::futures::synthetic::synthetic_forward(
|
||||
call_price,
|
||||
put_price,
|
||||
strike,
|
||||
rate,
|
||||
time_to_expiry,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (call_price, put_price, strike, rate, time_to_expiry, carry = 0.0))]
|
||||
pub fn synthetic_spot(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
carry: f64,
|
||||
) -> PyResult<f64> {
|
||||
Ok(ferro_ta_core::futures::synthetic::synthetic_spot(
|
||||
call_price,
|
||||
put_price,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (call_price, put_price, spot, strike, rate, time_to_expiry, carry = 0.0))]
|
||||
pub fn parity_gap(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
carry: f64,
|
||||
) -> PyResult<f64> {
|
||||
Ok(ferro_ta_core::futures::synthetic::parity_gap(
|
||||
call_price,
|
||||
put_price,
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
))
|
||||
}
|
||||
@@ -6,8 +6,10 @@ pub mod chunked;
|
||||
pub mod crypto;
|
||||
pub mod cycle;
|
||||
pub mod extended;
|
||||
pub mod futures;
|
||||
pub mod math_ops;
|
||||
pub mod momentum;
|
||||
pub mod options;
|
||||
pub mod overlap;
|
||||
pub mod pattern;
|
||||
pub mod portfolio;
|
||||
@@ -57,6 +59,8 @@ fn _ferro_ta(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
streaming::register(m)?;
|
||||
extended::register(m)?;
|
||||
math_ops::register(m)?;
|
||||
options::register(m)?;
|
||||
futures::register(m)?;
|
||||
resampling::register(m)?;
|
||||
aggregation::register(m)?;
|
||||
portfolio::register(m)?;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
use crate::validation;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (strikes, reference_price, option_type = "call"))]
|
||||
pub fn moneyness_labels<'py>(
|
||||
py: Python<'py>,
|
||||
strikes: PyReadonlyArray1<'py, f64>,
|
||||
reference_price: f64,
|
||||
option_type: &str,
|
||||
) -> PyResult<Bound<'py, PyArray1<i8>>> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let strikes = strikes.as_slice()?;
|
||||
let labels = ferro_ta_core::options::chain::label_moneyness(strikes, reference_price, kind);
|
||||
Ok(labels.into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn select_strike_offset<'py>(
|
||||
strikes: PyReadonlyArray1<'py, f64>,
|
||||
reference_price: f64,
|
||||
offset: isize,
|
||||
) -> PyResult<Option<f64>> {
|
||||
Ok(ferro_ta_core::options::chain::select_strike_by_offset(
|
||||
strikes.as_slice()?,
|
||||
reference_price,
|
||||
offset,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (strikes, vols, reference_price, time_to_expiry, target_delta, option_type = "call", model = "bsm", rate = 0.0, carry = 0.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn select_strike_delta<'py>(
|
||||
strikes: PyReadonlyArray1<'py, f64>,
|
||||
vols: PyReadonlyArray1<'py, f64>,
|
||||
reference_price: f64,
|
||||
time_to_expiry: f64,
|
||||
target_delta: f64,
|
||||
option_type: &str,
|
||||
model: &str,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
) -> PyResult<Option<f64>> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let model = super::parse_pricing_model(model)?;
|
||||
let strikes = strikes.as_slice()?;
|
||||
let vols = vols.as_slice()?;
|
||||
validation::validate_equal_length(&[(strikes.len(), "strikes"), (vols.len(), "vols")])?;
|
||||
Ok(ferro_ta_core::options::chain::select_strike_by_delta(
|
||||
strikes,
|
||||
vols,
|
||||
ferro_ta_core::options::ChainGreeksContext {
|
||||
model,
|
||||
reference_price,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind,
|
||||
},
|
||||
target_delta,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
use crate::validation;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
type GreekArrays<'py> = (
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
Bound<'py, PyArray1<f64>>,
|
||||
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", model = "bsm", carry = 0.0))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn option_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 greeks =
|
||||
ferro_ta_core::options::greeks::model_greeks(ferro_ta_core::options::OptionEvaluation {
|
||||
contract: ferro_ta_core::options::OptionContract {
|
||||
model,
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind,
|
||||
},
|
||||
volatility,
|
||||
});
|
||||
Ok((
|
||||
greeks.delta,
|
||||
greeks.gamma,
|
||||
greeks.vega,
|
||||
greeks.theta,
|
||||
greeks.rho,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = None))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn option_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<GreekArrays<'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 delta = Vec::with_capacity(underlying.len());
|
||||
let mut gamma = Vec::with_capacity(underlying.len());
|
||||
let mut vega = Vec::with_capacity(underlying.len());
|
||||
let mut theta = Vec::with_capacity(underlying.len());
|
||||
let mut rho = 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 g = ferro_ta_core::options::greeks::model_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,
|
||||
},
|
||||
);
|
||||
delta.push(g.delta);
|
||||
gamma.push(g.gamma);
|
||||
vega.push(g.vega);
|
||||
theta.push(g.theta);
|
||||
rho.push(g.rho);
|
||||
}
|
||||
|
||||
Ok((
|
||||
delta.into_pyarray(py),
|
||||
gamma.into_pyarray(py),
|
||||
vega.into_pyarray(py),
|
||||
theta.into_pyarray(py),
|
||||
rho.into_pyarray(py),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
use crate::validation;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (price, underlying, strike, rate, time_to_expiry, option_type = "call", model = "bsm", carry = 0.0, initial_guess = 0.2, tolerance = 1e-8, max_iterations = 100))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn implied_volatility(
|
||||
price: f64,
|
||||
underlying: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
option_type: &str,
|
||||
model: &str,
|
||||
carry: f64,
|
||||
initial_guess: f64,
|
||||
tolerance: f64,
|
||||
max_iterations: usize,
|
||||
) -> PyResult<f64> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let model = super::parse_pricing_model(model)?;
|
||||
Ok(ferro_ta_core::options::iv::implied_volatility(
|
||||
ferro_ta_core::options::OptionContract {
|
||||
model,
|
||||
underlying,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind,
|
||||
},
|
||||
price,
|
||||
ferro_ta_core::options::IvSolverConfig {
|
||||
initial_guess,
|
||||
tolerance,
|
||||
max_iterations,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (price, underlying, strike, rate, time_to_expiry, option_type = "call", model = "bsm", carry = None, initial_guess = None, tolerance = 1e-8, max_iterations = 100))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn implied_volatility_batch<'py>(
|
||||
py: Python<'py>,
|
||||
price: PyReadonlyArray1<'py, f64>,
|
||||
underlying: PyReadonlyArray1<'py, f64>,
|
||||
strike: PyReadonlyArray1<'py, f64>,
|
||||
rate: PyReadonlyArray1<'py, f64>,
|
||||
time_to_expiry: PyReadonlyArray1<'py, f64>,
|
||||
option_type: &str,
|
||||
model: &str,
|
||||
carry: Option<PyReadonlyArray1<'py, f64>>,
|
||||
initial_guess: Option<PyReadonlyArray1<'py, f64>>,
|
||||
tolerance: f64,
|
||||
max_iterations: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let model = super::parse_pricing_model(model)?;
|
||||
let price = price.as_slice()?;
|
||||
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 carry_vec = match carry {
|
||||
Some(array) => array.as_slice()?.to_vec(),
|
||||
None => vec![0.0; price.len()],
|
||||
};
|
||||
let guess_vec = match initial_guess {
|
||||
Some(array) => array.as_slice()?.to_vec(),
|
||||
None => vec![0.2; price.len()],
|
||||
};
|
||||
validation::validate_equal_length(&[
|
||||
(price.len(), "price"),
|
||||
(underlying.len(), "underlying"),
|
||||
(strike.len(), "strike"),
|
||||
(rate.len(), "rate"),
|
||||
(time_to_expiry.len(), "time_to_expiry"),
|
||||
(carry_vec.len(), "carry"),
|
||||
(guess_vec.len(), "initial_guess"),
|
||||
])?;
|
||||
|
||||
let out: Vec<f64> = price
|
||||
.iter()
|
||||
.zip(underlying.iter())
|
||||
.zip(strike.iter())
|
||||
.zip(rate.iter())
|
||||
.zip(time_to_expiry.iter())
|
||||
.zip(carry_vec.iter())
|
||||
.zip(guess_vec.iter())
|
||||
.map(|((((((&p, &u), &k), &r), &t), &c), &guess)| {
|
||||
ferro_ta_core::options::iv::implied_volatility(
|
||||
ferro_ta_core::options::OptionContract {
|
||||
model,
|
||||
underlying: u,
|
||||
strike: k,
|
||||
rate: r,
|
||||
carry: c,
|
||||
time_to_expiry: t,
|
||||
kind,
|
||||
},
|
||||
p,
|
||||
ferro_ta_core::options::IvSolverConfig {
|
||||
initial_guess: guess,
|
||||
tolerance,
|
||||
max_iterations,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (iv_series, window = 252))]
|
||||
pub fn iv_rank<'py>(
|
||||
py: Python<'py>,
|
||||
iv_series: PyReadonlyArray1<'py, f64>,
|
||||
window: i64,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let window = validation::parse_timeperiod(window, "window", 1)?;
|
||||
let out = ferro_ta_core::options::iv::iv_rank(iv_series.as_slice()?, window);
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (iv_series, window = 252))]
|
||||
pub fn iv_percentile<'py>(
|
||||
py: Python<'py>,
|
||||
iv_series: PyReadonlyArray1<'py, f64>,
|
||||
window: i64,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let window = validation::parse_timeperiod(window, "window", 1)?;
|
||||
let out = ferro_ta_core::options::iv::iv_percentile(iv_series.as_slice()?, window);
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (iv_series, window = 252))]
|
||||
pub fn iv_zscore<'py>(
|
||||
py: Python<'py>,
|
||||
iv_series: PyReadonlyArray1<'py, f64>,
|
||||
window: i64,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let window = validation::parse_timeperiod(window, "window", 1)?;
|
||||
let out = ferro_ta_core::options::iv::iv_zscore(iv_series.as_slice()?, window);
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! PyO3 wrappers for options analytics.
|
||||
|
||||
mod chain;
|
||||
mod greeks;
|
||||
mod iv;
|
||||
mod pricing;
|
||||
mod surface;
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
pub(crate) fn parse_option_kind(option_type: &str) -> PyResult<ferro_ta_core::options::OptionKind> {
|
||||
match option_type.to_ascii_lowercase().as_str() {
|
||||
"call" | "c" => Ok(ferro_ta_core::options::OptionKind::Call),
|
||||
"put" | "p" => Ok(ferro_ta_core::options::OptionKind::Put),
|
||||
_ => Err(PyValueError::new_err(format!(
|
||||
"option_type must be 'call' or 'put', got {option_type}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_pricing_model(model: &str) -> PyResult<ferro_ta_core::options::PricingModel> {
|
||||
match model.to_ascii_lowercase().as_str() {
|
||||
"bsm" | "black_scholes" | "black-scholes" | "blackscholes" => {
|
||||
Ok(ferro_ta_core::options::PricingModel::BlackScholes)
|
||||
}
|
||||
"black76" | "black_76" | "black-76" => Ok(ferro_ta_core::options::PricingModel::Black76),
|
||||
_ => Err(PyValueError::new_err(format!(
|
||||
"model must be one of 'bsm'/'black_scholes' or 'black76', got {model}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::pricing::bsm_price, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::pricing::black76_price, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::pricing::bsm_price_batch, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::pricing::black76_price_batch,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::greeks::option_greeks, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::greeks::option_greeks_batch,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::iv::implied_volatility, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::iv::implied_volatility_batch,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_rank, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_percentile, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_zscore, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::surface::smile_metrics, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::surface::term_structure_slope,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::chain::moneyness_labels, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::chain::select_strike_offset,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::chain::select_strike_delta, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use crate::validation;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::prelude::*;
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (spot, strike, rate, time_to_expiry, volatility, option_type = "call", dividend_yield = 0.0))]
|
||||
pub fn bsm_price(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_type: &str,
|
||||
dividend_yield: f64,
|
||||
) -> PyResult<f64> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
Ok(ferro_ta_core::options::pricing::black_scholes_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
dividend_yield,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
kind,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))]
|
||||
pub fn black76_price(
|
||||
forward: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_type: &str,
|
||||
) -> PyResult<f64> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
Ok(ferro_ta_core::options::pricing::black_76_price(
|
||||
forward,
|
||||
strike,
|
||||
rate,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
kind,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (spot, strike, rate, time_to_expiry, volatility, dividend_yield, option_type = "call"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn bsm_price_batch<'py>(
|
||||
py: Python<'py>,
|
||||
spot: PyReadonlyArray1<'py, f64>,
|
||||
strike: PyReadonlyArray1<'py, f64>,
|
||||
rate: PyReadonlyArray1<'py, f64>,
|
||||
time_to_expiry: PyReadonlyArray1<'py, f64>,
|
||||
volatility: PyReadonlyArray1<'py, f64>,
|
||||
dividend_yield: PyReadonlyArray1<'py, f64>,
|
||||
option_type: &str,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let spot = spot.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 dividend_yield = dividend_yield.as_slice()?;
|
||||
validation::validate_equal_length(&[
|
||||
(spot.len(), "spot"),
|
||||
(strike.len(), "strike"),
|
||||
(rate.len(), "rate"),
|
||||
(time_to_expiry.len(), "time_to_expiry"),
|
||||
(volatility.len(), "volatility"),
|
||||
(dividend_yield.len(), "dividend_yield"),
|
||||
])?;
|
||||
|
||||
let out: Vec<f64> = spot
|
||||
.iter()
|
||||
.zip(strike.iter())
|
||||
.zip(rate.iter())
|
||||
.zip(time_to_expiry.iter())
|
||||
.zip(volatility.iter())
|
||||
.zip(dividend_yield.iter())
|
||||
.map(|(((((&s, &k), &r), &t), &vol), &q)| {
|
||||
ferro_ta_core::options::pricing::black_scholes_price(s, k, r, q, t, vol, kind)
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))]
|
||||
pub fn black76_price_batch<'py>(
|
||||
py: Python<'py>,
|
||||
forward: PyReadonlyArray1<'py, f64>,
|
||||
strike: PyReadonlyArray1<'py, f64>,
|
||||
rate: PyReadonlyArray1<'py, f64>,
|
||||
time_to_expiry: PyReadonlyArray1<'py, f64>,
|
||||
volatility: PyReadonlyArray1<'py, f64>,
|
||||
option_type: &str,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let kind = super::parse_option_kind(option_type)?;
|
||||
let forward = forward.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()?;
|
||||
validation::validate_equal_length(&[
|
||||
(forward.len(), "forward"),
|
||||
(strike.len(), "strike"),
|
||||
(rate.len(), "rate"),
|
||||
(time_to_expiry.len(), "time_to_expiry"),
|
||||
(volatility.len(), "volatility"),
|
||||
])?;
|
||||
|
||||
let out: Vec<f64> = forward
|
||||
.iter()
|
||||
.zip(strike.iter())
|
||||
.zip(rate.iter())
|
||||
.zip(time_to_expiry.iter())
|
||||
.zip(volatility.iter())
|
||||
.map(|((((&f, &k), &r), &t), &vol)| {
|
||||
ferro_ta_core::options::pricing::black_76_price(f, k, r, t, vol, kind)
|
||||
})
|
||||
.collect();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::validation;
|
||||
use numpy::PyReadonlyArray1;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (strikes, vols, reference_price, time_to_expiry, model = "bsm", rate = 0.0, carry = 0.0))]
|
||||
pub fn smile_metrics<'py>(
|
||||
strikes: PyReadonlyArray1<'py, f64>,
|
||||
vols: PyReadonlyArray1<'py, f64>,
|
||||
reference_price: f64,
|
||||
time_to_expiry: f64,
|
||||
model: &str,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
) -> PyResult<(f64, f64, f64, f64, f64)> {
|
||||
let strikes = strikes.as_slice()?;
|
||||
let vols = vols.as_slice()?;
|
||||
validation::validate_equal_length(&[(strikes.len(), "strikes"), (vols.len(), "vols")])?;
|
||||
let model = super::parse_pricing_model(model)?;
|
||||
let metrics = ferro_ta_core::options::surface::smile_metrics(
|
||||
strikes,
|
||||
vols,
|
||||
reference_price,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
model,
|
||||
);
|
||||
Ok((
|
||||
metrics.atm_iv,
|
||||
metrics.risk_reversal_25d,
|
||||
metrics.butterfly_25d,
|
||||
metrics.skew_slope,
|
||||
metrics.convexity,
|
||||
))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn term_structure_slope<'py>(
|
||||
tenors: PyReadonlyArray1<'py, f64>,
|
||||
atm_ivs: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<f64> {
|
||||
let tenors = tenors.as_slice()?;
|
||||
let atm_ivs = atm_ivs.as_slice()?;
|
||||
validation::validate_equal_length(&[(tenors.len(), "tenors"), (atm_ivs.len(), "atm_ivs")])?;
|
||||
Ok(ferro_ta_core::options::surface::term_structure_slope(
|
||||
tenors, atm_ivs,
|
||||
))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user