Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29c6f5cf84 | |||
| 58a1dc2308 | |||
| b66b05682e | |||
| e4ac7dd4d3 | |||
| 1f053c1001 | |||
| 13cb0dc95a | |||
| 0382c4e302 | |||
| 7736effc27 | |||
| 9ec3bc3f7a | |||
| a055b4cf89 | |||
| ee6a0b3570 | |||
| 602d675749 | |||
| 2d5000262f | |||
| 9011250f99 | |||
| ba8019ad27 | |||
| 682bf063ca |
+177
-338
@@ -10,43 +10,8 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
|
||||
jobs:
|
||||
# -------------------------------------------------------------------------
|
||||
# Dependency audits — cargo audit (Rust) and pip-audit (Python)
|
||||
# -------------------------------------------------------------------------
|
||||
audit:
|
||||
name: Dependency audit (cargo + pip)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install cargo-audit and run cargo audit
|
||||
run: |
|
||||
cargo install cargo-audit
|
||||
cargo audit
|
||||
|
||||
- name: Install cargo-deny and run cargo deny check
|
||||
run: |
|
||||
cargo install cargo-deny --locked
|
||||
cargo deny check
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Install pip-audit via uv and run pip-audit
|
||||
run: |
|
||||
uv run --with pip-audit pip-audit
|
||||
|
||||
- name: Verify uv.lock is up-to-date
|
||||
run: uv lock --check
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Version consistency — Cargo.toml and pyproject.toml must have same version
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -76,285 +41,28 @@ jobs:
|
||||
fi
|
||||
echo "OK: versions match ($CARGO_VERSION)"
|
||||
|
||||
rust:
|
||||
name: Rust (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --release -- -D warnings
|
||||
- name: Verify benchmarks compile (ferro_ta_core)
|
||||
run: cargo bench -p ferro_ta_core --no-run
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Rust coverage — optional, runs in a separate non-blocking job
|
||||
# Modularized CI suites.
|
||||
# Keep release publishing jobs in this file so trusted-publisher bindings
|
||||
# remain anchored to CI.yml for PyPI and crates.io.
|
||||
# -------------------------------------------------------------------------
|
||||
rust-coverage:
|
||||
name: Rust coverage (tarpaulin, optional)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Install cargo-tarpaulin
|
||||
run: cargo install cargo-tarpaulin --locked
|
||||
- name: Collect Rust coverage (ferro_ta_core)
|
||||
run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/
|
||||
- name: Upload Rust coverage artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: rust-coverage
|
||||
path: coverage/
|
||||
if-no-files-found: ignore
|
||||
rust-suite:
|
||||
name: Rust and audit
|
||||
uses: ./.github/workflows/ci-rust.yml
|
||||
|
||||
lint:
|
||||
name: Lint (ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
python-suite:
|
||||
name: Python quality and tests
|
||||
uses: ./.github/workflows/ci-python.yml
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Run ruff check via uv
|
||||
run: uv run --with ruff ruff check python/ tests/
|
||||
- name: Run ruff format check via uv
|
||||
run: uv run --with ruff ruff format --check python/ tests/
|
||||
|
||||
typecheck:
|
||||
name: Type checking (mypy + pyright)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Run mypy on ferro_ta via uv
|
||||
run: uv run --with mypy --with numpy mypy python/ferro_ta --ignore-missing-imports --no-error-summary
|
||||
|
||||
- name: Run pyright on ferro_ta via uv
|
||||
run: uv run --with pyright pyright python/ferro_ta
|
||||
|
||||
test:
|
||||
name: Test (ubuntu-latest / Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
# Python 3.10–3.13 supported (requires-python in pyproject.toml)
|
||||
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: Install maturin and test dependencies
|
||||
run: |
|
||||
pip install maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml
|
||||
|
||||
- name: Build and install ferro_ta (dev mode)
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: matrix.python-version == '3.12'
|
||||
with:
|
||||
name: coverage-python-${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# WASM binding — build and test with wasm-pack
|
||||
# -------------------------------------------------------------------------
|
||||
wasm:
|
||||
name: WASM binding (wasm-pack test --node)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
name: WASM binding
|
||||
uses: ./.github/workflows/ci-wasm.yml
|
||||
|
||||
- name: Install Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
- name: Build and test WASM binding
|
||||
working-directory: wasm
|
||||
run: wasm-pack test --node
|
||||
|
||||
- name: Build WASM package (nodejs target)
|
||||
working-directory: wasm
|
||||
run: wasm-pack build --target nodejs --out-dir pkg
|
||||
|
||||
- name: Upload WASM package artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-pkg
|
||||
path: wasm/pkg/
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# ferro_ta vs TA-Lib speed comparison — required for PRs.
|
||||
# Performance regressions block merging.
|
||||
# -------------------------------------------------------------------------
|
||||
benchmark-vs-talib:
|
||||
name: Benchmark vs TA-Lib
|
||||
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 TA-Lib C library (Ubuntu)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential curl
|
||||
curl -sL https://sourceforge.net/projects/ta-lib/files/ta-lib/0.4.0/ta-lib-0.4.0-src.tar.gz/download -o ta-lib-0.4.0-src.tar.gz
|
||||
tar -xzf ta-lib-0.4.0-src.tar.gz
|
||||
cd ta-lib
|
||||
./configure --prefix=/usr
|
||||
make
|
||||
sudo make install
|
||||
sudo ldconfig
|
||||
|
||||
- name: Install maturin and ta-lib Python package
|
||||
run: |
|
||||
pip install maturin numpy
|
||||
pip install ta-lib
|
||||
|
||||
- name: Build and install ferro_ta
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Run benchmark comparison
|
||||
run: |
|
||||
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
|
||||
- name: Enforce benchmark regression policy
|
||||
run: |
|
||||
python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: benchmark-vs-talib
|
||||
path: benchmark_vs_talib.json
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Pure Rust core library — build and test without PyO3 / numpy
|
||||
# -------------------------------------------------------------------------
|
||||
rust-core:
|
||||
name: Rust core library (ferro_ta_core)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Build core crate
|
||||
run: cargo build -p ferro_ta_core
|
||||
- name: Test core crate
|
||||
run: cargo test -p ferro_ta_core
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Fuzzing (short run in CI to catch panics) — optional, non-blocking
|
||||
# This job is explicitly marked continue-on-error because cargo-fuzz
|
||||
# requires nightly Rust and may not be available in all environments.
|
||||
# Failures here are visible in the job log but do not block the PR.
|
||||
# -------------------------------------------------------------------------
|
||||
fuzz:
|
||||
name: Fuzz targets (short CI run, optional)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- name: Install cargo-fuzz
|
||||
run: cargo install cargo-fuzz --locked
|
||||
- name: Run fuzz_sma (10000 iterations)
|
||||
working-directory: fuzz
|
||||
run: cargo fuzz run fuzz_sma -- -runs=10000 -max_len=512
|
||||
- name: Run fuzz_rsi (10000 iterations)
|
||||
working-directory: fuzz
|
||||
run: cargo fuzz run fuzz_rsi -- -runs=10000 -max_len=512
|
||||
- name: Upload fuzz artifacts on crash
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: fuzz-artifacts
|
||||
path: fuzz/artifacts/
|
||||
if-no-files-found: ignore
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Sphinx documentation build
|
||||
# -------------------------------------------------------------------------
|
||||
docs:
|
||||
name: Documentation (Sphinx build)
|
||||
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 docs dependencies
|
||||
run: |
|
||||
pip install maturin numpy
|
||||
pip install sphinx sphinx-rtd-theme
|
||||
|
||||
- name: Build and install ferro_ta wheel
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Build Sphinx documentation
|
||||
run: sphinx-build -b html docs docs/_build -W --keep-going
|
||||
|
||||
- name: Upload docs artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sphinx-docs
|
||||
path: docs/_build/
|
||||
|
||||
- name: Upload GitHub Pages artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: docs/_build/
|
||||
name: Documentation
|
||||
uses: ./.github/workflows/ci-docs.yml
|
||||
with:
|
||||
upload-pages-artifact: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Deploy docs to GitHub Pages (on push to main only)
|
||||
@@ -384,16 +92,11 @@ jobs:
|
||||
name: CI complete (required gate)
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- audit
|
||||
- changelog-check
|
||||
- version-check
|
||||
- rust
|
||||
- rust-core
|
||||
- lint
|
||||
- typecheck
|
||||
- test
|
||||
- rust-suite
|
||||
- python-suite
|
||||
- wasm
|
||||
- benchmark-vs-talib
|
||||
- docs
|
||||
if: always()
|
||||
steps:
|
||||
@@ -401,48 +104,134 @@ jobs:
|
||||
run: |
|
||||
results='${{ toJSON(needs) }}'
|
||||
echo "Job results: $results"
|
||||
failed=$(echo "$results" | python3 -c "
|
||||
import json, sys
|
||||
needs = json.load(sys.stdin)
|
||||
failed = [name for name, data in needs.items() if data['result'] not in ('success', 'skipped')]
|
||||
failed="$(
|
||||
RESULTS_JSON="$results" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
needs = json.loads(os.environ["RESULTS_JSON"])
|
||||
failed = [
|
||||
name
|
||||
for name, data in needs.items()
|
||||
if data["result"] not in ("success", "skipped")
|
||||
]
|
||||
if failed:
|
||||
print(' '.join(failed))
|
||||
")
|
||||
if [ -n \"\$failed\" ]; then
|
||||
echo \"FAILED jobs: \$failed\"
|
||||
print(" ".join(failed))
|
||||
PY
|
||||
)"
|
||||
if [ -n "$failed" ]; then
|
||||
echo "FAILED jobs: $failed"
|
||||
exit 1
|
||||
fi
|
||||
echo \"All required CI jobs passed.\"
|
||||
echo "All required CI jobs passed."
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Build wheels for all platforms and publish to PyPI on release
|
||||
# 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
|
||||
@@ -450,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
|
||||
@@ -465,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
|
||||
|
||||
@@ -494,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
|
||||
@@ -503,7 +342,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -516,7 +355,7 @@ jobs:
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Generate Python SBOM (CycloneDX via anchore/sbom-action)
|
||||
uses: anchore/sbom-action@v0.17
|
||||
uses: anchore/sbom-action@v0.24.0
|
||||
with:
|
||||
artifact-name: ferro-ta-python-sbom.spdx.json
|
||||
output-file: ferro-ta-python-sbom.spdx.json
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: CI Docs
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
upload-pages-artifact:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docs:
|
||||
name: Documentation (Sphinx build)
|
||||
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 docs dependencies
|
||||
run: |
|
||||
pip install maturin numpy
|
||||
pip install sphinx sphinx-rtd-theme
|
||||
|
||||
- name: Build and install ferro_ta wheel
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Build Sphinx documentation
|
||||
run: sphinx-build -b html docs docs/_build -W --keep-going
|
||||
|
||||
- name: Upload docs artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sphinx-docs
|
||||
path: docs/_build/
|
||||
|
||||
- name: Upload GitHub Pages artifact
|
||||
if: ${{ inputs.upload-pages-artifact }}
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: docs/_build/
|
||||
@@ -0,0 +1,168 @@
|
||||
name: CI Python
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint (ruff)
|
||||
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 uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Run ruff check via uv
|
||||
run: uv run --with ruff ruff check python/ tests/
|
||||
- name: Run ruff format check via uv
|
||||
run: uv run --with ruff ruff format --check python/ tests/
|
||||
|
||||
typecheck:
|
||||
name: Type checking (mypy + pyright)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Run mypy on ferro_ta via uv
|
||||
run: uv run --with mypy --with numpy mypy python/ferro_ta --ignore-missing-imports --no-error-summary
|
||||
|
||||
- name: Run pyright on ferro_ta via uv
|
||||
run: uv run --with pyright pyright python/ferro_ta
|
||||
|
||||
test:
|
||||
name: Test (ubuntu-latest / Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
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: Install maturin and test dependencies
|
||||
run: |
|
||||
pip install maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml
|
||||
|
||||
- name: Build and install ferro_ta (dev mode)
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: matrix.python-version == '3.12'
|
||||
with:
|
||||
name: coverage-python-${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
|
||||
benchmark-vs-talib:
|
||||
name: Benchmark vs TA-Lib
|
||||
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 TA-Lib C library (Ubuntu)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential curl
|
||||
curl -sL https://sourceforge.net/projects/ta-lib/files/ta-lib/0.4.0/ta-lib-0.4.0-src.tar.gz/download -o ta-lib-0.4.0-src.tar.gz
|
||||
tar -xzf ta-lib-0.4.0-src.tar.gz
|
||||
cd ta-lib
|
||||
./configure --prefix=/usr
|
||||
make
|
||||
sudo make install
|
||||
sudo ldconfig
|
||||
|
||||
- name: Install maturin and ta-lib Python package
|
||||
run: |
|
||||
pip install maturin numpy
|
||||
pip install ta-lib
|
||||
|
||||
- name: Build and install ferro_ta
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Run benchmark comparison
|
||||
run: |
|
||||
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
|
||||
- name: Enforce benchmark regression policy
|
||||
run: python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: benchmark-vs-talib
|
||||
path: benchmark_vs_talib.json
|
||||
|
||||
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/
|
||||
@@ -0,0 +1,110 @@
|
||||
name: CI Rust
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Dependency audit (cargo + pip)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install cargo-audit and run cargo audit
|
||||
run: |
|
||||
cargo install cargo-audit
|
||||
cargo audit
|
||||
|
||||
- name: Install cargo-deny and run cargo deny check
|
||||
run: |
|
||||
cargo install cargo-deny --locked
|
||||
cargo deny check
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Install pip-audit via uv and run pip-audit
|
||||
run: uv run --with pip-audit pip-audit
|
||||
|
||||
- name: Verify uv.lock is up-to-date
|
||||
run: uv lock --check
|
||||
|
||||
rust:
|
||||
name: Rust (fmt + clippy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --release -- -D warnings
|
||||
- name: Verify benchmarks compile (ferro_ta_core)
|
||||
run: cargo bench -p ferro_ta_core --no-run
|
||||
|
||||
rust-core:
|
||||
name: Rust core library (ferro_ta_core)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Build core crate
|
||||
run: cargo build -p ferro_ta_core
|
||||
- name: Test core crate
|
||||
run: cargo test -p ferro_ta_core
|
||||
|
||||
rust-coverage:
|
||||
name: Rust coverage (tarpaulin, optional)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Install cargo-tarpaulin
|
||||
run: cargo install cargo-tarpaulin --locked
|
||||
- name: Collect Rust coverage (ferro_ta_core)
|
||||
run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/
|
||||
- name: Upload Rust coverage artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: rust-coverage
|
||||
path: coverage/
|
||||
if-no-files-found: ignore
|
||||
|
||||
fuzz:
|
||||
name: Fuzz targets (short CI run, optional)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- name: Install cargo-fuzz
|
||||
run: cargo install cargo-fuzz --locked
|
||||
- name: Run fuzz_sma (10000 iterations)
|
||||
working-directory: fuzz
|
||||
run: cargo fuzz run fuzz_sma -- -runs=10000 -max_len=512
|
||||
- name: Run fuzz_rsi (10000 iterations)
|
||||
working-directory: fuzz
|
||||
run: cargo fuzz run fuzz_rsi -- -runs=10000 -max_len=512
|
||||
- name: Upload fuzz artifacts on crash
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: fuzz-artifacts
|
||||
path: fuzz/artifacts/
|
||||
if-no-files-found: ignore
|
||||
@@ -0,0 +1,52 @@
|
||||
name: CI WASM
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
wasm:
|
||||
name: WASM binding (wasm-pack test --node)
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
toolchain: stable
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
- name: Build and test WASM binding
|
||||
working-directory: wasm
|
||||
run: wasm-pack test --node
|
||||
|
||||
- name: Build WASM package (nodejs target)
|
||||
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,60 @@
|
||||
name: Release
|
||||
|
||||
# Triggered when a version tag is pushed (e.g. v1.0.3).
|
||||
# Creates a GitHub Release marked as "published", which in turn
|
||||
# triggers the build-wheels and publish jobs in CI.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
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}"
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Expected a semantic-version tag like v1.0.3 or v1.0.3-rc1, got: $GITHUB_REF_NAME"
|
||||
exit 1
|
||||
fi
|
||||
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, '-') }}
|
||||
@@ -4,6 +4,10 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and publish to npm
|
||||
@@ -12,13 +16,15 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install Rust and wasm-pack
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- run: cargo install wasm-pack
|
||||
|
||||
- name: Build WASM package
|
||||
@@ -29,5 +35,3 @@ jobs:
|
||||
- name: Publish to npm
|
||||
working-directory: wasm
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
+3
-1
@@ -31,6 +31,8 @@ env/
|
||||
# WASM build output
|
||||
wasm/pkg/
|
||||
wasm/pkg-web/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
.hypothesis/
|
||||
|
||||
@@ -39,4 +41,4 @@ coverage.xml
|
||||
|
||||
# DS Store in all directories
|
||||
.DS_Store
|
||||
*.DS_Store
|
||||
*.DS_Store
|
||||
|
||||
+108
-1
@@ -9,6 +9,109 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.4] — 2026-03-24
|
||||
|
||||
### Added
|
||||
|
||||
- The optional MCP server now exposes the broad public ferro-ta callable
|
||||
surface, including exact top-level exports, non-top-level public analysis and
|
||||
tooling functions, and generic stored-instance tools for stateful classes and
|
||||
returned callables.
|
||||
- Added a dedicated `TA_LIB_COMPATIBILITY.md` document so the full TA-Lib
|
||||
coverage matrix remains available without bloating the project homepage.
|
||||
|
||||
### Changed
|
||||
|
||||
- Reworked the root README into a shorter product-first landing page with a
|
||||
compatibility summary and docs map, and refreshed MCP documentation to match
|
||||
the expanded server behavior.
|
||||
- Updated the MCP implementation to use generated tool registration over the
|
||||
public API while keeping the legacy lowercase aliases (`sma`, `ema`, `rsi`,
|
||||
`macd`, `backtest`) available for existing clients.
|
||||
- Refreshed locked Python dependency resolutions for the latest low-risk direct
|
||||
updates in this release cycle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The repository no longer tracks the stray `.coverage` artifact, and coverage
|
||||
outputs are now ignored consistently.
|
||||
- MCP tests now cover generated tool discovery, stored-instance workflows, and
|
||||
callable-reference execution paths so the broader server surface does not
|
||||
regress silently.
|
||||
|
||||
## [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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Python CI now recognizes the top-level metadata API in type stubs, and the
|
||||
derivatives benchmark smoke test no longer depends on importing the
|
||||
`benchmarks` package from an installed wheel layout.
|
||||
- The tag-driven GitHub Release workflow now uses a valid glob trigger and an
|
||||
explicit semantic-version validation step, so pushing `v1.0.3`-style tags
|
||||
correctly creates the release that fans out into the publish jobs.
|
||||
|
||||
## [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
|
||||
|
||||
- `crates/ferro_ta_core/README.md` is now shipped with the published Rust crate, and
|
||||
`ferro_ta_core` metadata now points documentation to docs.rs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CI has been modularized into focused workflow files (`ci-rust.yml`,
|
||||
`ci-python.yml`, `ci-wasm.yml`, `ci-docs.yml`) while keeping the release
|
||||
publishing jobs in `CI.yml` for PyPI and crates.io trusted-publisher
|
||||
compatibility.
|
||||
- The `ci-complete` gate no longer fails successful runs because of an escaped
|
||||
shell variable, and the release SBOM job now uses a valid `anchore/sbom-action`
|
||||
version.
|
||||
- The npm publish workflow now uses GitHub OIDC trusted publishing, installs
|
||||
the `wasm32-unknown-unknown` target, and no longer depends on an `NPM_TOKEN`
|
||||
secret.
|
||||
- The WASM npm package now removes the generated `pkg/.gitignore` during
|
||||
`prepack`, so the published tarball includes the built `pkg/` artifacts.
|
||||
|
||||
## [1.0.0] — 2026-03-23 *(initial stable release)*
|
||||
|
||||
### Performance
|
||||
@@ -252,5 +355,9 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.0...HEAD
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.4...HEAD
|
||||
[1.0.4]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.3...v1.0.4
|
||||
[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.0"
|
||||
version = "1.0.4"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ferro_ta_core",
|
||||
@@ -222,7 +222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.0.0"
|
||||
version = "1.0.4"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"wide",
|
||||
|
||||
+9
-2
@@ -5,9 +5,16 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "ferro_ta"
|
||||
version = "1.0.0"
|
||||
version = "1.0.4"
|
||||
edition = "2021"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/pratikbhadane24/ferro-ta"
|
||||
homepage = "https://github.com/pratikbhadane24/ferro-ta#readme"
|
||||
documentation = "https://pratikbhadane24.github.io/ferro-ta/"
|
||||
keywords = ["technical-analysis", "trading", "indicators", "finance", "ta-lib"]
|
||||
categories = ["finance", "mathematics"]
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
@@ -23,7 +30,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.0" }
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.4" }
|
||||
|
||||
[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
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# TA-Lib Compatibility
|
||||
|
||||
`ferro-ta` covers **100% of TA-Lib's function set** (`162+` indicators). This
|
||||
file keeps the full GitHub-facing parity matrix in one place so the root
|
||||
`README.md` can stay product-focused.
|
||||
|
||||
See also:
|
||||
|
||||
- [docs/migration_talib.rst](docs/migration_talib.rst)
|
||||
- [docs/compatibility/talib.md](docs/compatibility/talib.md)
|
||||
- [docs/support_matrix.rst](docs/support_matrix.rst)
|
||||
|
||||
## Legend
|
||||
|
||||
|
||||
| Symbol | Meaning |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| ✅ Exact | Values match TA-Lib to floating-point precision |
|
||||
| ✅ Close | Values match after a short convergence window (EMA-seed difference) |
|
||||
| ⚠️ Corr | Strong correlation (> 0.95) but not numerically identical (Wilder smoothing seed or algorithm variant) |
|
||||
| ⚠️ Shape | Same output shape / NaN structure; values differ due to algorithm variant |
|
||||
| ❌ | Not yet implemented |
|
||||
|
||||
|
||||
## Overlap Studies
|
||||
|
||||
|
||||
| TA-Lib Function | ferro-ta | Accuracy | Notes |
|
||||
| --------------- | -------- | -------- | ----------------------------------------------------- |
|
||||
| `BBANDS` | ✅ | ✅ Exact | Bollinger Bands |
|
||||
| `DEMA` | ✅ | ✅ Close | Double EMA; converges after ~20 bars |
|
||||
| `EMA` | ✅ | ✅ Close | Exponential Moving Average; converges after ~20 bars |
|
||||
| `KAMA` | ✅ | ✅ Exact | Kaufman Adaptive MA (values match after seed bar) |
|
||||
| `MA` | ✅ | ✅ Exact | Moving average (generic, type-selectable) |
|
||||
| `MAMA` | ✅ | ⚠️ Corr | MESA Adaptive MA |
|
||||
| `MAVP` | ✅ | ✅ Exact | MA with variable period |
|
||||
| `MIDPOINT` | ✅ | ✅ Exact | Midpoint over period |
|
||||
| `MIDPRICE` | ✅ | ✅ Exact | Midpoint price over period |
|
||||
| `SAR` | ✅ | ⚠️ Shape | Parabolic SAR (same shape; reversal history diverges) |
|
||||
| `SAREXT` | ✅ | ⚠️ Shape | Parabolic SAR Extended |
|
||||
| `SMA` | ✅ | ✅ Exact | Simple Moving Average |
|
||||
| `T3` | ✅ | ✅ Close | Triple Exponential MA (T3); converges after ~50 bars |
|
||||
| `TEMA` | ✅ | ✅ Close | Triple EMA; converges after ~20 bars |
|
||||
| `TRIMA` | ✅ | ✅ Exact | Triangular Moving Average |
|
||||
| `WMA` | ✅ | ✅ Exact | Weighted Moving Average |
|
||||
|
||||
|
||||
## Momentum Indicators
|
||||
|
||||
|
||||
| TA-Lib Function | ferro-ta | Accuracy | Notes |
|
||||
| --------------- | -------- | -------- | ---------------------------------------------------------------------------- |
|
||||
| `ADX` | ✅ | ✅ Close | Avg Directional Movement Index (TA-Lib Wilder sum-seeding) |
|
||||
| `ADXR` | ✅ | ✅ Close | ADX Rating (inherits ADX; TA-Lib seeding) |
|
||||
| `APO` | ✅ | ✅ Close | Absolute Price Oscillator (EMA-based) |
|
||||
| `AROON` | ✅ | ✅ Exact | Aroon Up/Down |
|
||||
| `AROONOSC` | ✅ | ✅ Exact | Aroon Oscillator |
|
||||
| `BOP` | ✅ | ✅ Exact | Balance Of Power |
|
||||
| `CCI` | ✅ | ✅ Exact | Commodity Channel Index (TA-Lib-compatible MAD formula) |
|
||||
| `CMO` | ✅ | ✅ Close | Chande Momentum Oscillator (rolling window, TA-Lib-compatible) |
|
||||
| `DX` | ✅ | ✅ Close | Directional Movement Index (TA-Lib Wilder sum-seeding) |
|
||||
| `MACD` | ✅ | ✅ Close | MACD (EMA-based; converges after ~30 bars) |
|
||||
| `MACDEXT` | ✅ | ✅ Close | MACD with controllable MA type (EMA-based; converges) |
|
||||
| `MACDFIX` | ✅ | ✅ Close | MACD Fixed 12/26 (EMA-based; converges) |
|
||||
| `MFI` | ✅ | ✅ Exact | Money Flow Index |
|
||||
| `MINUS_DI` | ✅ | ✅ Close | Minus Directional Indicator (TA-Lib Wilder sum-seeding) |
|
||||
| `MINUS_DM` | ✅ | ✅ Close | Minus Directional Movement (TA-Lib Wilder sum-seeding) |
|
||||
| `MOM` | ✅ | ✅ Exact | Momentum |
|
||||
| `PLUS_DI` | ✅ | ✅ Close | Plus Directional Indicator (TA-Lib Wilder sum-seeding) |
|
||||
| `PLUS_DM` | ✅ | ✅ Close | Plus Directional Movement (TA-Lib Wilder sum-seeding) |
|
||||
| `PPO` | ✅ | ✅ Close | Percentage Price Oscillator (EMA-based) |
|
||||
| `ROC` | ✅ | ✅ Exact | Rate of Change |
|
||||
| `ROCP` | ✅ | ✅ Exact | Rate of Change Percentage |
|
||||
| `ROCR` | ✅ | ✅ Exact | Rate of Change Ratio |
|
||||
| `ROCR100` | ✅ | ✅ Exact | Rate of Change Ratio × 100 |
|
||||
| `RSI` | ✅ | ✅ Close | Relative Strength Index (TA-Lib Wilder seeding; converges after ~1 seed bar) |
|
||||
| `STOCH` | ✅ | ✅ Close | Stochastic (TA-Lib-compatible SMA smoothing for slowk and slowd) |
|
||||
| `STOCHF` | ✅ | ✅ Exact | Stochastic Fast (%K exact; %D NaN offset ±2) |
|
||||
| `STOCHRSI` | ✅ | ✅ Close | Stochastic RSI (TA-Lib-compatible; SMA fastd, Wilder-seeded RSI) |
|
||||
| `TRIX` | ✅ | ✅ Close | 1-day ROC of Triple EMA (EMA-based; converges) |
|
||||
| `ULTOSC` | ✅ | ✅ Exact | Ultimate Oscillator |
|
||||
| `WILLR` | ✅ | ✅ Exact | Williams' %R |
|
||||
|
||||
|
||||
## Volume Indicators
|
||||
|
||||
|
||||
| TA-Lib Function | ferro-ta | Accuracy | Notes |
|
||||
| --------------- | -------- | -------- | ------------------------------------------------------------------ |
|
||||
| `AD` | ✅ | ✅ Exact | Chaikin A/D Line |
|
||||
| `ADOSC` | ✅ | ✅ Exact | Chaikin A/D Oscillator |
|
||||
| `OBV` | ✅ | ✅ Exact | On Balance Volume (increments identical; constant offset at bar 0) |
|
||||
|
||||
|
||||
## Volatility Indicators
|
||||
|
||||
|
||||
| TA-Lib Function | ferro-ta | Accuracy | Notes |
|
||||
| --------------- | -------- | -------- | ----------------------------------------------------------------------- |
|
||||
| `ATR` | ✅ | ✅ Close | Average True Range (TA-Lib Wilder seeding; matches from bar timeperiod) |
|
||||
| `NATR` | ✅ | ✅ Close | Normalized ATR (TA-Lib Wilder seeding) |
|
||||
| `TRANGE` | ✅ | ✅ Exact | True Range (bar 0 differs; all others identical) |
|
||||
|
||||
|
||||
## Cycle Indicators
|
||||
|
||||
|
||||
| TA-Lib Function | ferro-ta | Accuracy | Notes |
|
||||
| --------------- | -------- | -------- | ---------------------------------------------------------- |
|
||||
| `HT_DCPERIOD` | ✅ | ⚠️ Shape | Hilbert Transform Dominant Cycle Period (Ehlers algorithm) |
|
||||
| `HT_DCPHASE` | ✅ | ⚠️ Shape | Hilbert Transform Dominant Cycle Phase |
|
||||
| `HT_PHASOR` | ✅ | ⚠️ Shape | Hilbert Transform Phasor Components (inphase, quadrature) |
|
||||
| `HT_SINE` | ✅ | ⚠️ Shape | Hilbert Transform SineWave (sine, leadsine) |
|
||||
| `HT_TRENDLINE` | ✅ | ⚠️ Shape | Hilbert Transform Instantaneous Trendline |
|
||||
| `HT_TRENDMODE` | ✅ | ⚠️ Shape | Hilbert Transform Trend vs Cycle Mode (1=trend, 0=cycle) |
|
||||
|
||||
|
||||
## Price Transformations
|
||||
|
||||
|
||||
| TA-Lib Function | ferro-ta | Accuracy | Notes |
|
||||
| --------------- | -------- | -------- | -------------------- |
|
||||
| `AVGPRICE` | ✅ | ✅ Exact | Average Price |
|
||||
| `MEDPRICE` | ✅ | ✅ Exact | Median Price |
|
||||
| `TYPPRICE` | ✅ | ✅ Exact | Typical Price |
|
||||
| `WCLPRICE` | ✅ | ✅ Exact | Weighted Close Price |
|
||||
|
||||
|
||||
## Statistic Functions
|
||||
|
||||
|
||||
| TA-Lib Function | ferro-ta | Accuracy | Notes |
|
||||
| --------------------- | -------- | -------- | ----------------------------------------------------------- |
|
||||
| `BETA` | ✅ | ✅ Close | Beta coefficient (returns-based regression matching TA-Lib) |
|
||||
| `CORREL` | ✅ | ✅ Exact | Pearson Correlation Coefficient |
|
||||
| `LINEARREG` | ✅ | ✅ Exact | Linear Regression |
|
||||
| `LINEARREG_ANGLE` | ✅ | ✅ Exact | Linear Regression Angle |
|
||||
| `LINEARREG_INTERCEPT` | ✅ | ✅ Exact | Linear Regression Intercept |
|
||||
| `LINEARREG_SLOPE` | ✅ | ✅ Exact | Linear Regression Slope |
|
||||
| `STDDEV` | ✅ | ✅ Exact | Standard Deviation |
|
||||
| `TSF` | ✅ | ✅ Exact | Time Series Forecast |
|
||||
| `VAR` | ✅ | ✅ Exact | Variance |
|
||||
|
||||
|
||||
## Pattern Recognition
|
||||
|
||||
`ferro-ta` implements all 61 candlestick patterns. All return the same
|
||||
`{-100, 0, 100}` convention as TA-Lib. Pattern thresholds may differ slightly
|
||||
from the full TA-Lib implementation.
|
||||
|
||||
|
||||
| TA-Lib Function | ferro-ta | Notes |
|
||||
| --------------------- | -------- | --------------------------------------------------- |
|
||||
| `CDL2CROWS` | ✅ | Two Crows |
|
||||
| `CDL3BLACKCROWS` | ✅ | Three Black Crows |
|
||||
| `CDL3INSIDE` | ✅ | Three Inside Up/Down |
|
||||
| `CDL3LINESTRIKE` | ✅ | Three-Line Strike |
|
||||
| `CDL3OUTSIDE` | ✅ | Three Outside Up/Down |
|
||||
| `CDL3STARSINSOUTH` | ✅ | Three Stars In The South |
|
||||
| `CDL3WHITESOLDIERS` | ✅ | Three Advancing White Soldiers |
|
||||
| `CDLABANDONEDBABY` | ✅ | Abandoned Baby |
|
||||
| `CDLADVANCEBLOCK` | ✅ | Advance Block |
|
||||
| `CDLBELTHOLD` | ✅ | Belt-hold |
|
||||
| `CDLBREAKAWAY` | ✅ | Breakaway |
|
||||
| `CDLCLOSINGMARUBOZU` | ✅ | Closing Marubozu |
|
||||
| `CDLCONCEALBABYSWALL` | ✅ | Concealing Baby Swallow |
|
||||
| `CDLCOUNTERATTACK` | ✅ | Counterattack |
|
||||
| `CDLDARKCLOUDCOVER` | ✅ | Dark Cloud Cover |
|
||||
| `CDLDOJI` | ✅ | Doji |
|
||||
| `CDLDOJISTAR` | ✅ | Doji Star |
|
||||
| `CDLDRAGONFLYDOJI` | ✅ | Dragonfly Doji |
|
||||
| `CDLENGULFING` | ✅ | Engulfing Pattern |
|
||||
| `CDLEVENINGDOJISTAR` | ✅ | Evening Doji Star |
|
||||
| `CDLEVENINGSTAR` | ✅ | Evening Star |
|
||||
| `CDLGAPSIDESIDEWHITE` | ✅ | Up/Down-gap side-by-side white lines |
|
||||
| `CDLGRAVESTONEDOJI` | ✅ | Gravestone Doji |
|
||||
| `CDLHAMMER` | ✅ | Hammer |
|
||||
| `CDLHANGINGMAN` | ✅ | Hanging Man |
|
||||
| `CDLHARAMI` | ✅ | Harami Pattern |
|
||||
| `CDLHARAMICROSS` | ✅ | Harami Cross Pattern |
|
||||
| `CDLHIGHWAVE` | ✅ | High-Wave Candle |
|
||||
| `CDLHIKKAKE` | ✅ | Hikkake Pattern |
|
||||
| `CDLHIKKAKEMOD` | ✅ | Modified Hikkake Pattern |
|
||||
| `CDLHOMINGPIGEON` | ✅ | Homing Pigeon |
|
||||
| `CDLIDENTICAL3CROWS` | ✅ | Identical Three Crows |
|
||||
| `CDLINNECK` | ✅ | In-Neck Pattern |
|
||||
| `CDLINVERTEDHAMMER` | ✅ | Inverted Hammer |
|
||||
| `CDLKICKING` | ✅ | Kicking |
|
||||
| `CDLKICKINGBYLENGTH` | ✅ | Kicking by the longer Marubozu |
|
||||
| `CDLLADDERBOTTOM` | ✅ | Ladder Bottom |
|
||||
| `CDLLONGLEGGEDDOJI` | ✅ | Long Legged Doji |
|
||||
| `CDLLONGLINE` | ✅ | Long Line Candle |
|
||||
| `CDLMARUBOZU` | ✅ | Marubozu |
|
||||
| `CDLMATCHINGLOW` | ✅ | Matching Low |
|
||||
| `CDLMATHOLD` | ✅ | Mat Hold |
|
||||
| `CDLMORNINGDOJISTAR` | ✅ | Morning Doji Star |
|
||||
| `CDLMORNINGSTAR` | ✅ | Morning Star |
|
||||
| `CDLONNECK` | ✅ | On-Neck Pattern |
|
||||
| `CDLPIERCING` | ✅ | Piercing Pattern |
|
||||
| `CDLRICKSHAWMAN` | ✅ | Rickshaw Man |
|
||||
| `CDLRISEFALL3METHODS` | ✅ | Rising/Falling Three Methods |
|
||||
| `CDLSEPARATINGLINES` | ✅ | Separating Lines |
|
||||
| `CDLSHOOTINGSTAR` | ✅ | Shooting Star |
|
||||
| `CDLSHORTLINE` | ✅ | Short Line Candle |
|
||||
| `CDLSPINNINGTOP` | ✅ | Spinning Top |
|
||||
| `CDLSTALLEDPATTERN` | ✅ | Stalled Pattern |
|
||||
| `CDLSTICKSANDWICH` | ✅ | Stick Sandwich |
|
||||
| `CDLTAKURI` | ✅ | Takuri (Dragonfly Doji with very long lower shadow) |
|
||||
| `CDLTASUKIGAP` | ✅ | Tasuki Gap |
|
||||
| `CDLTHRUSTING` | ✅ | Thrusting Pattern |
|
||||
| `CDLTRISTAR` | ✅ | Tristar Pattern |
|
||||
| `CDLUNIQUE3RIVER` | ✅ | Unique 3 River |
|
||||
| `CDLUPSIDEGAP2CROWS` | ✅ | Upside Gap Two Crows |
|
||||
| `CDLXSIDEGAP3METHODS` | ✅ | Upside/Downside Gap Three Methods |
|
||||
|
||||
|
||||
## Math Operators / Math Transforms
|
||||
|
||||
`ferro-ta` provides TA-Lib-compatible wrappers for all arithmetic and
|
||||
math-transform functions. Rolling functions (`SUM`, `MAX`, `MIN`) produce `NaN`
|
||||
for the first `timeperiod - 1` bars.
|
||||
|
||||
|
||||
| TA-Lib Function | ferro-ta | Notes |
|
||||
| ------------------------ | -------- | ----------------------------- |
|
||||
| `ADD` | ✅ | Element-wise addition |
|
||||
| `SUB` | ✅ | Element-wise subtraction |
|
||||
| `MULT` | ✅ | Element-wise multiplication |
|
||||
| `DIV` | ✅ | Element-wise division |
|
||||
| `SUM` | ✅ | Rolling sum over *timeperiod* |
|
||||
| `MAX` / `MAXINDEX` | ✅ | Rolling maximum / index |
|
||||
| `MIN` / `MININDEX` | ✅ | Rolling minimum / index |
|
||||
| `ACOS` / `ASIN` / `ATAN` | ✅ | Arc trig transforms |
|
||||
| `CEIL` / `FLOOR` | ✅ | Round up / down |
|
||||
| `COS` / `SIN` / `TAN` | ✅ | Trig transforms |
|
||||
| `COSH` / `SINH` / `TANH` | ✅ | Hyperbolic transforms |
|
||||
| `EXP` / `LN` / `LOG10` | ✅ | Exponential / log transforms |
|
||||
| `SQRT` | ✅ | Square root |
|
||||
|
||||
|
||||
## Implementation Coverage Summary
|
||||
|
||||
|
||||
| Category | Implemented | Not Implemented |
|
||||
| --------------------------- | ----------- | --------------- |
|
||||
| Overlap Studies | 19 | 0 |
|
||||
| Momentum Indicators | 28 | 0 |
|
||||
| Volume Indicators | 3 | 0 |
|
||||
| Volatility Indicators | 3 | 0 |
|
||||
| Cycle Indicators | 6 | 0 |
|
||||
| Price Transforms | 4 | 0 |
|
||||
| Statistic Functions | 9 | 0 |
|
||||
| Pattern Recognition | 61 | 0 |
|
||||
| Math Operators / Transforms | 24 | 0 |
|
||||
| Extended Indicators | 10 | - |
|
||||
| Streaming Classes | 9 | - |
|
||||
| **Total** | **162+** | **0** |
|
||||
|
||||
|
||||
> `ferro-ta` implements 100% of TA-Lib's function set. NaN values are placed
|
||||
> at the beginning of each output array for the warmup period.
|
||||
|
||||
+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,124 @@
|
||||
"""
|
||||
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 sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
# Ensure direct benchmark test runs can import local package from `python/`.
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PYTHON_SRC = ROOT / "python"
|
||||
if str(PYTHON_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(PYTHON_SRC))
|
||||
|
||||
HAS_FERRO_EXTENSION = True
|
||||
try:
|
||||
from ferro_ta.analysis.options import implied_volatility, option_price
|
||||
except ModuleNotFoundError:
|
||||
HAS_FERRO_EXTENSION = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not HAS_FERRO_EXTENSION, reason="ferro_ta extension is not built"
|
||||
)
|
||||
|
||||
|
||||
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.4" %}
|
||||
|
||||
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,12 +1,13 @@
|
||||
[package]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.0.0"
|
||||
version = "1.0.4"
|
||||
edition = "2021"
|
||||
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/pratikbhadane24/ferro-ta"
|
||||
homepage = "https://github.com/pratikbhadane24/ferro-ta#readme"
|
||||
documentation = "https://github.com/pratikbhadane24/ferro-ta#readme"
|
||||
documentation = "https://docs.rs/ferro_ta_core"
|
||||
keywords = ["technical-analysis", "trading", "indicators", "finance", "ta-lib"]
|
||||
categories = ["finance", "mathematics"]
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# ferro_ta_core
|
||||
|
||||
`ferro_ta_core` is the pure Rust indicator engine behind [`ferro-ta`](https://github.com/pratikbhadane24/ferro-ta).
|
||||
|
||||
It provides allocation-friendly indicator functions over `&[f64]` slices without any
|
||||
PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
|
||||
|
||||
- Rust-native technical analysis workloads
|
||||
- custom services and backtesting engines
|
||||
- future non-Python bindings such as WASM and other FFI layers
|
||||
|
||||
## Installation
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ferro_ta_core = "1.0.4"
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
- Pure functions over Rust slices
|
||||
- No Python or NumPy dependency
|
||||
- Shared core for the Python package and WASM bindings
|
||||
- Output shape matches TA-Lib-style full-length series with `NaN` warm-up values where applicable
|
||||
|
||||
## Modules
|
||||
|
||||
- `overlap` - moving averages, MACD, Bollinger Bands
|
||||
- `momentum` - RSI, MOM
|
||||
- `volatility` - ATR, TRANGE
|
||||
- `volume` - OBV
|
||||
- `statistic` - STDDEV
|
||||
- `math` - rolling SUM/MAX/MIN helpers
|
||||
|
||||
## Example
|
||||
|
||||
```rust
|
||||
use ferro_ta_core::overlap;
|
||||
|
||||
fn main() {
|
||||
let close = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let sma = overlap::sma(&close, 3);
|
||||
|
||||
assert!(sma[0].is_nan());
|
||||
assert!(sma[1].is_nan());
|
||||
assert!((sma[2] - 2.0).abs() < 1e-10);
|
||||
}
|
||||
```
|
||||
|
||||
## Relationship To `ferro-ta`
|
||||
|
||||
The published Python package:
|
||||
|
||||
- crate: `ferro_ta`
|
||||
- PyPI package: `ferro-ta`
|
||||
|
||||
wraps this crate with PyO3 bindings and adds:
|
||||
|
||||
- NumPy conversion
|
||||
- pandas/polars wrappers
|
||||
- streaming classes
|
||||
- batch helpers
|
||||
- higher-level Python tooling
|
||||
|
||||
If you only need Rust indicator functions, use `ferro_ta_core` directly.
|
||||
|
||||
## Development
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
cargo build -p ferro_ta_core
|
||||
cargo test -p ferro_ta_core
|
||||
cargo bench -p ferro_ta_core --no-run
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -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,48 @@
|
||||
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
|
||||
- FastMCP-based server exposing 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.
|
||||
+1
-1
@@ -198,6 +198,6 @@ while True:
|
||||
|
||||
- `ferro_ta.tools` — module source.
|
||||
- `ferro_ta.workflow` — module source.
|
||||
- `docs/mcp.md` — MCP server for Cursor/Claude integration.
|
||||
- `docs/mcp.md` — MCP server for MCP-compatible clients.
|
||||
- `ferro_ta.backtest` — backtest harness.
|
||||
- `ferro_ta.registry` — indicator registry.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+45
-43
@@ -1,55 +1,57 @@
|
||||
Changelog
|
||||
=========
|
||||
Release Notes
|
||||
=============
|
||||
|
||||
1.0.0 (2026)
|
||||
------------
|
||||
These docs track package version ``1.0.4``.
|
||||
|
||||
**Candlestick Pattern Parity (61/61)**
|
||||
1.0.4 (2026-03-24)
|
||||
------------------
|
||||
|
||||
- All 61 TA-Lib candlestick patterns implemented in Rust
|
||||
- ``{-100, 0, 100}`` convention, consistent with TA-Lib
|
||||
- Expanded the optional MCP server from a small hand-written subset to the
|
||||
broader public ferro-ta callable surface, including stateful class support
|
||||
through stored-instance management tools.
|
||||
- Split the root documentation so the full TA-Lib compatibility matrix lives in
|
||||
``TA_LIB_COMPATIBILITY.md`` while the README stays product-first and shorter.
|
||||
- Refreshed MCP docs/tests and updated locked low-risk Python dependencies as
|
||||
part of the release cleanup pass.
|
||||
- Stopped tracking the stray ``.coverage`` artifact and aligned ignore rules
|
||||
for local coverage outputs.
|
||||
|
||||
**Numerical Parity**
|
||||
1.0.3 (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
|
||||
- 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.
|
||||
- Fixed Python CI/type-stub gaps around the new metadata API and corrected the
|
||||
tag-driven GitHub Release workflow trigger used for publish automation.
|
||||
|
||||
**Streaming / Incremental API**
|
||||
1.0.2 (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 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.
|
||||
|
||||
**Pandas Integration**
|
||||
1.0.1 (2026-03-24)
|
||||
------------------
|
||||
|
||||
- All indicators transparently accept ``pandas.Series`` and return ``Series`` with original index preserved
|
||||
- Multi-output functions return tuples of ``Series``
|
||||
- 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.
|
||||
|
||||
**Math Operators / Transforms**
|
||||
1.0.0 (2026-03-23)
|
||||
------------------
|
||||
|
||||
- 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)
|
||||
- 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.
|
||||
|
||||
**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`
|
||||
+40
-16
@@ -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
|
||||
- MCP server for MCP-compatible clients — see `MCP guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_
|
||||
- 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
|
||||
==================
|
||||
|
||||
+120
-54
@@ -1,42 +1,56 @@
|
||||
# MCP Server — Connect ferro-ta in Cursor
|
||||
# MCP Server
|
||||
|
||||
ferro-ta ships an MCP (Model Context Protocol) server that exposes
|
||||
indicators and backtest tools to AI agents. This guide shows how to run
|
||||
the server and connect it to Cursor or any MCP-compatible client.
|
||||
ferro-ta ships an optional MCP (Model Context Protocol) server built on the
|
||||
official Python SDK's FastMCP layer. The server now exposes the broad public
|
||||
ferro-ta callable surface instead of a tiny hand-picked subset.
|
||||
|
||||
That means MCP clients can use:
|
||||
|
||||
- Exact top-level ferro-ta exports such as `SMA`, `RSI`, `MACD`, `about`,
|
||||
`methods`, `info`, `benchmark`, and `traced`
|
||||
- Non-top-level public tools such as `compute_indicator`, `run_backtest`,
|
||||
`check_cross`, `aggregate_ticks`, `TickAggregator`, and `AlertManager`
|
||||
- Legacy lowercase convenience aliases: `sma`, `ema`, `rsi`, `macd`,
|
||||
and `backtest`
|
||||
- Generic instance tools for stateful classes and stored callables:
|
||||
`list_instances`, `describe_instance`, `call_instance_method`,
|
||||
`call_stored_callable`, and `delete_instance`
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
The MCP server requires no additional dependencies beyond ferro_ta itself.
|
||||
For the full MCP SDK integration (recommended), install the optional extra:
|
||||
Install the optional MCP extra:
|
||||
|
||||
```bash
|
||||
pip install "ferro-ta[mcp]"
|
||||
```
|
||||
|
||||
or install the `mcp` package separately:
|
||||
If you are working from this repository, you can install the same extra into
|
||||
the project environment with:
|
||||
|
||||
```bash
|
||||
pip install "mcp>=1.0"
|
||||
uv sync --extra mcp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running the server
|
||||
|
||||
Run the server over stdio:
|
||||
|
||||
```bash
|
||||
python -m ferro_ta.mcp
|
||||
```
|
||||
|
||||
The server listens on stdin/stdout using JSON-RPC 2.0 (the MCP protocol).
|
||||
The command exits immediately with an install hint if the optional `mcp`
|
||||
dependency is missing.
|
||||
|
||||
---
|
||||
|
||||
## Connect in Cursor
|
||||
|
||||
1. Open Cursor settings (Command Palette → "Open User Settings (JSON)").
|
||||
2. Find or create the `mcpServers` section:
|
||||
Add the server to Cursor's MCP settings:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -44,82 +58,134 @@ The server listens on stdin/stdout using JSON-RPC 2.0 (the MCP protocol).
|
||||
"ferro-ta": {
|
||||
"command": "python",
|
||||
"args": ["-m", "ferro_ta.mcp"],
|
||||
"description": "ferro_ta — Technical Analysis MCP server"
|
||||
"description": "ferro-ta technical analysis tools"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Reload Cursor (Command Palette → "Developer: Reload Window").
|
||||
4. The ferro-ta tools will appear in the Tools panel.
|
||||
You can place this in your user settings JSON or in a workspace-level
|
||||
`.cursor/mcp.json`.
|
||||
|
||||
### Workspace-level config
|
||||
---
|
||||
|
||||
You can also add the config to your project's `.cursor/mcp.json`:
|
||||
## Tool naming
|
||||
|
||||
The MCP server prefers the real ferro-ta API names.
|
||||
|
||||
- Use exact public names when possible, for example `SMA`, `MACD`,
|
||||
`compute_indicator`, `trade_stats`, `TickAggregator`, or `AlertManager`
|
||||
- Use the legacy lowercase aliases only when you want the old MCP-friendly
|
||||
shortcuts and result shapes
|
||||
- Use `about`, `methods`, `indicators`, and `info` to discover what is
|
||||
available from inside an MCP client
|
||||
|
||||
---
|
||||
|
||||
## Stateful classes and object references
|
||||
|
||||
Class tools return stored object references instead of plain text placeholders.
|
||||
For example, calling `TickAggregator` or `AlertManager` returns a payload like:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ferro-ta": {
|
||||
"command": "python",
|
||||
"args": ["-m", "ferro_ta.mcp"]
|
||||
}
|
||||
}
|
||||
"instance_id": "tickaggregator-0001",
|
||||
"type": "ferro_ta.data.aggregation.TickAggregator",
|
||||
"repr": "TickAggregator(rule='tick:2')"
|
||||
}
|
||||
```
|
||||
|
||||
Use that `instance_id` with:
|
||||
|
||||
- `describe_instance` to inspect the stored object and list public methods
|
||||
- `call_instance_method` to call methods like `aggregate`, `update`,
|
||||
`run_backtest`, or `to_dict`
|
||||
- `delete_instance` to remove stored objects when you are done
|
||||
|
||||
If a tool returns a stored callable, use `call_stored_callable`.
|
||||
|
||||
---
|
||||
|
||||
## Callable references
|
||||
|
||||
Some ferro-ta APIs accept other callables, for example `benchmark`,
|
||||
`log_call`, `traced`, or `multi_timeframe(indicator=...)`.
|
||||
|
||||
Pass public ferro-ta callables using:
|
||||
|
||||
```json
|
||||
{"callable": "SMA"}
|
||||
```
|
||||
|
||||
Pass stored objects using:
|
||||
|
||||
```json
|
||||
{"instance_id": "function-0001"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example prompts
|
||||
|
||||
Once connected, you can ask Claude (or any MCP-enabled AI) things like:
|
||||
Once connected, you can ask an MCP-compatible client things like:
|
||||
|
||||
> "Compute RSI(14) on this price series: [100, 102, 101, 105, 108, 104, 107]"
|
||||
> "Run `SMA` with `close=[100, 101, 102, 103, 104]` and `timeperiod=3`."
|
||||
|
||||
> "Run a backtest with the rsi_30_70 strategy on [100, 101, 99, 103, 106, 102, 108, 105, 109, 112, 108, 111]"
|
||||
> "Use `compute_indicator` to calculate `MACD` for this close series."
|
||||
|
||||
> "List all available ferro_ta indicators"
|
||||
> "Call `about` and summarize the current ferro-ta API surface."
|
||||
|
||||
> "What does the SMA indicator do?"
|
||||
> "Create a `TickAggregator` with `rule='tick:50'`, aggregate this tick data,
|
||||
> then delete the instance."
|
||||
|
||||
> "Benchmark `SMA` over this price series using a callable reference."
|
||||
|
||||
---
|
||||
|
||||
## Available tools
|
||||
## Programmatic use
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `sma` | Simple Moving Average |
|
||||
| `ema` | Exponential Moving Average |
|
||||
| `rsi` | Relative Strength Index |
|
||||
| `macd` | MACD line, signal, histogram |
|
||||
| `backtest` | Vectorized backtest (rsi_30_70, sma_crossover, macd_crossover) |
|
||||
| `list_indicators` | List all registered indicators |
|
||||
| `describe_indicator` | Describe a named indicator |
|
||||
|
||||
---
|
||||
|
||||
## Programmatic use (Python client)
|
||||
|
||||
You can also use the MCP handlers directly in Python without the server:
|
||||
Use the server entrypoint:
|
||||
|
||||
```python
|
||||
from ferro_ta.mcp import handle_list_tools, handle_call_tool
|
||||
import numpy as np
|
||||
from ferro_ta.mcp import create_server
|
||||
|
||||
server = create_server()
|
||||
# server.run(transport="stdio")
|
||||
```
|
||||
|
||||
Or call the handlers directly without starting the server:
|
||||
|
||||
```python
|
||||
from ferro_ta.mcp import handle_call_tool, handle_list_tools
|
||||
import json
|
||||
|
||||
# List tools
|
||||
tools = handle_list_tools()
|
||||
print([t["name"] for t in tools["tools"]])
|
||||
print(len(tools["tools"]))
|
||||
|
||||
# Call RSI
|
||||
close = list(np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 50)) * 100)
|
||||
result = handle_call_tool("rsi", {"close": close, "timeperiod": 14})
|
||||
print(result)
|
||||
close = [100, 101, 102, 103, 104]
|
||||
result = handle_call_tool("SMA", {"close": close, "timeperiod": 3})
|
||||
print(json.loads(result["content"][0]["text"]))
|
||||
|
||||
aggregator = json.loads(
|
||||
handle_call_tool("TickAggregator", {"rule": "tick:2"})["content"][0]["text"]
|
||||
)
|
||||
bars = handle_call_tool(
|
||||
"call_instance_method",
|
||||
{
|
||||
"instance_id": aggregator["instance_id"],
|
||||
"method": "aggregate",
|
||||
"args": [{"price": [1, 2, 3, 4], "size": [1, 1, 1, 1]}],
|
||||
},
|
||||
)
|
||||
print(json.loads(bars["content"][0]["text"]))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- `ferro_ta.mcp` — module source.
|
||||
- `ferro_ta.tools` — underlying tool functions.
|
||||
- `docs/agentic.md` — LangChain and workflow integration.
|
||||
- `python -m ferro_ta.mcp` - stdio MCP entrypoint
|
||||
- `ferro_ta.mcp.create_server()` - FastMCP server factory
|
||||
- `ferro_ta.tools.api_info` - API discovery helpers used by the MCP catalog
|
||||
- `ferro_ta.tools` - stable wrappers such as `compute_indicator`
|
||||
- `docs/agentic.md` - workflow and agent integration notes
|
||||
|
||||
+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.4``.
|
||||
|
||||
- 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
|
||||
}
|
||||
]
|
||||
}
|
||||
+3
-2
@@ -4,8 +4,8 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "ferro-ta"
|
||||
version = "1.0.0"
|
||||
description = "A fast Technical Analysis library — TA-Lib alternative powered by Rust and PyO3"
|
||||
version = "1.0.4"
|
||||
description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.10"
|
||||
@@ -104,6 +104,7 @@ ignore = ["E501", "UP006", "UP045", "UP007"]
|
||||
"tests/unit/*" = ["N801", "N802", "N806", "E402", "E741", "F811"]
|
||||
"tests/integration/*" = ["N801", "N802", "N806", "E402", "E741", "F811"]
|
||||
"python/ferro_ta/*.py" = ["N802"] # Public API: SMA, RSI, ATR, etc.
|
||||
"python/ferro_ta/__init__.py" = ["E402", "N802"] # Re-export surface by design
|
||||
"python/ferro_ta/__init__.pyi" = ["N802", "E402"]
|
||||
|
||||
[tool.ruff.format]
|
||||
|
||||
@@ -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,7 +59,51 @@ array([ nan, nan, 11. , 12. , 13. , 13.5, 13.33...])
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re as _re
|
||||
import sys as _sys
|
||||
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
||||
from importlib.metadata import version as _dist_version
|
||||
from pathlib import Path as _Path
|
||||
|
||||
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,
|
||||
|
||||
@@ -13,6 +13,8 @@ from numpy.typing import ArrayLike, NDArray
|
||||
|
||||
_F = TypeVar("_F", bound=Callable[..., Any])
|
||||
|
||||
__version__: str
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Overlap Studies
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -710,6 +712,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)
|
||||
@@ -738,8 +741,10 @@ class FerroTAInputError(FerroTAError, ValueError):
|
||||
# API discovery (ferro_ta.api_info)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def about() -> dict[str, Any]: ...
|
||||
def indicators(category: str | None = None) -> list[dict[str, Any]]: ...
|
||||
def info(func_or_name: Callable[..., Any] | str) -> dict[str, Any]: ...
|
||||
def methods(category: str | None = None) -> list[dict[str, Any]]: ...
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging utilities (ferro_ta.logging_utils)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1294
-391
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user