Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd1bb137d6 | |||
| 388dc05c89 | |||
| 0ee5f246ed | |||
| 500716177e | |||
| 06c536bcb7 | |||
| 3e0f289d51 | |||
| 125eb32d9f | |||
| 3b6c7a15a3 | |||
| cc584dbff9 | |||
| 58516672d7 | |||
| 7b560463f6 | |||
| 6cd1714a9f | |||
| 6e45da2636 | |||
| cf5d7764ba | |||
| e370120f4e | |||
| f9575df3f6 | |||
| 139f7f26e0 | |||
| 45ee06f4fc | |||
| 149c91d111 | |||
| b06acb9654 | |||
| 7a589c725f | |||
| 262f0a2916 | |||
| 5015716692 | |||
| ec9bf0410f | |||
| 70b99ad870 | |||
| 3ab6daa853 | |||
| 436954138f | |||
| 2d776b6f90 | |||
| 71b6343e92 | |||
| 53566b9d82 | |||
| ba77fbd418 | |||
| 29c6f5cf84 | |||
| 58a1dc2308 | |||
| b66b05682e | |||
| e4ac7dd4d3 | |||
| 1f053c1001 | |||
| 13cb0dc95a | |||
| 0382c4e302 | |||
| 7736effc27 | |||
| 9ec3bc3f7a | |||
| a055b4cf89 | |||
| ee6a0b3570 | |||
| 602d675749 | |||
| 2d5000262f | |||
| 9011250f99 | |||
| ba8019ad27 |
@@ -0,0 +1,17 @@
|
||||
# Local development build configuration for ferro-ta.
|
||||
#
|
||||
# Enables target-cpu=native so the compiler can emit instructions for the
|
||||
# host machine (AVX2, NEON, etc.). This primarily benefits release builds
|
||||
# where LTO and codegen-units=1 are active (see Cargo.toml [profile.release]).
|
||||
#
|
||||
# Cargo config.toml does not support per-profile rustflags, so this applies
|
||||
# to both debug and release profiles. The impact on debug builds is negligible.
|
||||
#
|
||||
# WASM targets are excluded so wasm-pack / wasm32-unknown-unknown builds
|
||||
# are unaffected.
|
||||
#
|
||||
# CI may override RUSTFLAGS or use a separate .cargo/config.toml to produce
|
||||
# portable binaries for distribution.
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))']
|
||||
rustflags = ["-C", "target-cpu=native"]
|
||||
+152
-18
@@ -7,6 +7,12 @@ on:
|
||||
branches: ["main"]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
description: "Simulate a release publish (runs build + publish jobs)"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -130,32 +136,108 @@ jobs:
|
||||
# Keep release jobs here because trusted-publisher configuration points to
|
||||
# CI.yml specifically.
|
||||
# -------------------------------------------------------------------------
|
||||
build-wheels:
|
||||
name: Build wheels (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
build-wheels-linux:
|
||||
name: Build wheels (linux / py${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
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') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
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') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: --out dist
|
||||
|
||||
- name: Upload sdist as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sdist
|
||||
path: dist/*.tar.gz
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Publish to PyPI
|
||||
@@ -163,8 +245,12 @@ jobs:
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheels
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
needs:
|
||||
- build-wheels-linux
|
||||
- build-wheels-macos
|
||||
- build-wheels-windows
|
||||
- build-sdist
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/ferro-ta
|
||||
@@ -178,6 +264,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
|
||||
|
||||
@@ -187,7 +319,7 @@ jobs:
|
||||
publish-cratesio:
|
||||
name: Publish to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -207,8 +339,10 @@ jobs:
|
||||
sbom:
|
||||
name: Generate SBOM (Python + Rust)
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheels
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
needs:
|
||||
- publish
|
||||
- publish-cratesio
|
||||
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
@@ -244,7 +378,7 @@ jobs:
|
||||
run: cargo install cargo-cyclonedx --locked
|
||||
|
||||
- name: Generate Rust SBOM (CycloneDX)
|
||||
run: cargo cyclonedx --format json --output-cdx ferro-ta-rust-sbom.cdx.json
|
||||
run: cargo cyclonedx --format json --override-filename ferro-ta-rust-sbom.cdx
|
||||
|
||||
- name: Upload Python SBOM to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
+117
-70
@@ -7,121 +7,168 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# -------------------------------------------------------------------------
|
||||
# Lint — no Rust, no wheel build, very fast
|
||||
# -------------------------------------------------------------------------
|
||||
lint:
|
||||
name: Lint (ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install uv
|
||||
- run: uv run --with ruff ruff check python/ tests/
|
||||
- run: uv run --with ruff ruff format --check python/ tests/
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Run ruff check via uv
|
||||
run: uv run --with ruff ruff check python/ tests/
|
||||
- name: Run ruff format check via uv
|
||||
run: uv run --with ruff ruff format --check python/ tests/
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Type checking — needs wheel; build once with cache
|
||||
# -------------------------------------------------------------------------
|
||||
typecheck:
|
||||
name: Type checking (mypy + pyright)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: pip install maturin numpy
|
||||
- run: maturin build --release --out dist && pip install dist/*.whl
|
||||
- run: pip install uv
|
||||
- run: uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
|
||||
- run: uv run --with pyright python -m pyright python/ferro_ta
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Run mypy on ferro_ta via uv
|
||||
run: uv run --with mypy --with numpy 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 }})
|
||||
# -------------------------------------------------------------------------
|
||||
# Build wheel artifact (once per run, reused by all test matrix entries)
|
||||
# -------------------------------------------------------------------------
|
||||
build-wheel:
|
||||
name: Build wheel (ubuntu / Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: "wheel-${{ matrix.python-version }}"
|
||||
- run: pip install maturin numpy
|
||||
- run: maturin build --release --out dist
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wheel-${{ matrix.python-version }}
|
||||
path: dist/*.whl
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Test matrix — downloads pre-built wheel, no Rust compilation
|
||||
# -------------------------------------------------------------------------
|
||||
test:
|
||||
name: Test (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheel
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install maturin and test dependencies
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wheel-${{ matrix.python-version }}
|
||||
path: dist
|
||||
- name: Install wheel + test deps
|
||||
run: |
|
||||
pip install maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml
|
||||
|
||||
- name: Build and install ferro_ta (dev mode)
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
pip install pytest pytest-cov pandas polars hypothesis pyyaml mcp scipy
|
||||
- name: Run tests with coverage
|
||||
run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v7
|
||||
- name: Check API manifest is current
|
||||
if: matrix.python-version == '3.12'
|
||||
run: python scripts/check_api_manifest.py
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: matrix.python-version == '3.12'
|
||||
with:
|
||||
name: coverage-python-${{ matrix.python-version }}
|
||||
path: coverage.xml
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Benchmark vs TA-Lib (downloads wheel from build-wheel job)
|
||||
# -------------------------------------------------------------------------
|
||||
benchmark-vs-talib:
|
||||
name: Benchmark vs TA-Lib
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheel
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install TA-Lib C library (Ubuntu)
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wheel-3.12
|
||||
path: dist
|
||||
- name: Install TA-Lib C library
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential curl
|
||||
curl -sL https://sourceforge.net/projects/ta-lib/files/ta-lib/0.4.0/ta-lib-0.4.0-src.tar.gz/download -o ta-lib-0.4.0-src.tar.gz
|
||||
tar -xzf ta-lib-0.4.0-src.tar.gz
|
||||
cd ta-lib
|
||||
./configure --prefix=/usr
|
||||
make
|
||||
sudo make install
|
||||
sudo ldconfig
|
||||
|
||||
- name: Install maturin and ta-lib Python package
|
||||
run: |
|
||||
pip install maturin numpy
|
||||
pip install ta-lib
|
||||
|
||||
- name: Build and install ferro_ta
|
||||
run: |
|
||||
maturin build --release --out dist
|
||||
pip install dist/*.whl
|
||||
|
||||
- name: Run benchmark comparison
|
||||
run: |
|
||||
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
|
||||
- name: Enforce benchmark regression policy
|
||||
run: python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@v7
|
||||
cd ta-lib && ./configure --prefix=/usr && make && sudo make install && sudo ldconfig
|
||||
- run: pip install dist/*.whl numpy ta-lib
|
||||
- run: python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
|
||||
- run: python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: benchmark-vs-talib
|
||||
path: benchmark_vs_talib.json
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Performance smoke (downloads wheel from build-wheel job)
|
||||
# -------------------------------------------------------------------------
|
||||
perf-smoke:
|
||||
name: Performance smoke and contracts
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-wheel
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wheel-3.12
|
||||
path: dist
|
||||
- run: pip install dist/*.whl numpy pytest
|
||||
- run: |
|
||||
python benchmarks/run_perf_contract.py \
|
||||
--output-dir perf-contract \
|
||||
--skip-talib \
|
||||
--skip-simd \
|
||||
--batch-samples 20000 \
|
||||
--batch-series 32 \
|
||||
--streaming-bars 20000 \
|
||||
--price-bars 20000 \
|
||||
--iv-bars 50000
|
||||
- run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: perf-contract
|
||||
path: perf-contract/
|
||||
|
||||
@@ -7,102 +7,119 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Dependency audit (cargo + pip)
|
||||
# -------------------------------------------------------------------------
|
||||
# cargo-deny — uses the official pre-built action (no cargo install needed)
|
||||
# -------------------------------------------------------------------------
|
||||
cargo-deny:
|
||||
name: cargo deny check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
|
||||
- name: Install cargo-audit and run cargo audit
|
||||
run: |
|
||||
cargo install cargo-audit
|
||||
cargo audit
|
||||
|
||||
- name: Install cargo-deny and run cargo deny check
|
||||
run: |
|
||||
cargo install cargo-deny --locked
|
||||
cargo deny check
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v6
|
||||
# -------------------------------------------------------------------------
|
||||
# pip-audit + uv.lock freshness check (lightweight, no Rust needed)
|
||||
# -------------------------------------------------------------------------
|
||||
pip-audit:
|
||||
name: pip-audit + uv.lock check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install uv
|
||||
- run: uv run --with pip-audit pip-audit --skip-editable
|
||||
- run: uv lock --check
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Install pip-audit via uv and run pip-audit
|
||||
run: uv run --with pip-audit pip-audit
|
||||
|
||||
- name: Verify uv.lock is up-to-date
|
||||
run: uv lock --check
|
||||
|
||||
rust:
|
||||
name: Rust (fmt + clippy)
|
||||
# -------------------------------------------------------------------------
|
||||
# fmt + clippy (with Rust cache so subsequent runs skip recompilation)
|
||||
# -------------------------------------------------------------------------
|
||||
rust-lint:
|
||||
name: Rust fmt + clippy
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo fmt --all -- --check
|
||||
- run: cargo clippy --release -- -D warnings
|
||||
- name: Verify benchmarks compile (ferro_ta_core)
|
||||
- name: Verify benchmarks compile
|
||||
run: cargo bench -p ferro_ta_core --no-run
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Build + test ferro_ta_core (with Rust cache)
|
||||
# -------------------------------------------------------------------------
|
||||
rust-core:
|
||||
name: Rust core library (ferro_ta_core)
|
||||
name: Rust core (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Build core crate
|
||||
run: cargo build -p ferro_ta_core
|
||||
- name: Test core crate
|
||||
run: cargo test -p ferro_ta_core
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build -p ferro_ta_core
|
||||
- run: cargo test -p ferro_ta_core
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Coverage (optional, cached)
|
||||
# -------------------------------------------------------------------------
|
||||
rust-coverage:
|
||||
name: Rust coverage (tarpaulin, optional)
|
||||
name: Rust coverage (tarpaulin)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: Install cargo-tarpaulin
|
||||
run: cargo install cargo-tarpaulin --locked
|
||||
- name: Collect Rust coverage (ferro_ta_core)
|
||||
run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/
|
||||
- name: Upload Rust coverage artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-tarpaulin
|
||||
- run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: rust-coverage
|
||||
path: coverage/
|
||||
if-no-files-found: ignore
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Fuzz (optional, nightly, cached)
|
||||
# -------------------------------------------------------------------------
|
||||
fuzz:
|
||||
name: Fuzz targets (short CI run, optional)
|
||||
name: Fuzz targets (short CI run)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: nightly
|
||||
- name: Install cargo-fuzz
|
||||
run: cargo install cargo-fuzz --locked
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-fuzz
|
||||
- name: Run fuzz_sma (10000 iterations)
|
||||
working-directory: fuzz
|
||||
run: cargo fuzz run fuzz_sma -- -runs=10000 -max_len=512
|
||||
run: cargo fuzz run fuzz_sma --target x86_64-unknown-linux-gnu -- -runs=10000 -max_len=512
|
||||
- name: Run fuzz_rsi (10000 iterations)
|
||||
working-directory: fuzz
|
||||
run: cargo fuzz run fuzz_rsi -- -runs=10000 -max_len=512
|
||||
- name: Upload fuzz artifacts on crash
|
||||
uses: actions/upload-artifact@v7
|
||||
run: cargo fuzz run fuzz_rsi --target x86_64-unknown-linux-gnu -- -runs=10000 -max_len=512
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: fuzz-artifacts
|
||||
|
||||
@@ -8,30 +8,43 @@ permissions:
|
||||
|
||||
jobs:
|
||||
wasm:
|
||||
name: WASM binding (wasm-pack test --node)
|
||||
name: WASM (test + build + bench)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Rust (stable)
|
||||
uses: dtolnay/rust-toolchain@v1
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
- uses: dtolnay/rust-toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Install wasm-pack
|
||||
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||
|
||||
- name: Build and test WASM binding
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: wasm-pack
|
||||
- name: Test WASM binding
|
||||
working-directory: wasm
|
||||
run: wasm-pack test --node
|
||||
|
||||
- name: Build WASM package (nodejs target)
|
||||
- name: Build WASM packages (node + web)
|
||||
working-directory: wasm
|
||||
run: wasm-pack build --target nodejs --out-dir pkg
|
||||
|
||||
- name: Upload WASM package artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
run: npm run build
|
||||
- name: Check API manifest is current
|
||||
run: python3 scripts/check_api_manifest.py
|
||||
- name: Benchmark WASM
|
||||
working-directory: wasm
|
||||
run: node bench.js --json ../wasm_benchmark.json
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-pkg
|
||||
path: wasm/pkg/
|
||||
name: wasm-pkg-node
|
||||
path: wasm/node/
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-pkg-web
|
||||
path: wasm/web/
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wasm-benchmark
|
||||
path: wasm_benchmark.json
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
|
||||
- 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, '-') }}
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
@@ -3,6 +3,12 @@ name: Publish WASM to npm
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
description: "Publish WASM package to npm"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,13 +18,16 @@ jobs:
|
||||
publish:
|
||||
name: Build and publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'release' && github.event.action == 'published')
|
||||
|| (github.event_name == 'workflow_dispatch' && inputs.release)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Install Rust and wasm-pack
|
||||
@@ -27,6 +36,10 @@ jobs:
|
||||
targets: wasm32-unknown-unknown
|
||||
- run: cargo install wasm-pack
|
||||
|
||||
- name: Run WASM tests
|
||||
working-directory: wasm
|
||||
run: wasm-pack test --node
|
||||
|
||||
- name: Build WASM package
|
||||
run: |
|
||||
cd wasm
|
||||
@@ -34,4 +47,4 @@ jobs:
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: wasm
|
||||
run: npm publish --access public
|
||||
run: npm publish --access public --provenance
|
||||
|
||||
+14
-1
@@ -7,6 +7,12 @@ wasm/target/
|
||||
*.pyd
|
||||
*.dll
|
||||
|
||||
# macOS dSYM debug symbols (generated by maturin develop)
|
||||
*.dSYM/
|
||||
|
||||
#
|
||||
/plans/
|
||||
|
||||
# Maturin / wheel build outputs
|
||||
dist/
|
||||
*.egg-info/
|
||||
@@ -31,6 +37,13 @@ env/
|
||||
# WASM build output
|
||||
wasm/pkg/
|
||||
wasm/pkg-web/
|
||||
wasm/node/
|
||||
wasm/web/
|
||||
benchmark_vs_talib.json
|
||||
wasm_benchmark.json
|
||||
.wasm_benchmark.prepush.json
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.xml
|
||||
.hypothesis/
|
||||
|
||||
@@ -39,4 +52,4 @@ coverage.xml
|
||||
|
||||
# DS Store in all directories
|
||||
.DS_Store
|
||||
*.DS_Store
|
||||
*.DS_Store
|
||||
|
||||
+13
-2
@@ -1,12 +1,12 @@
|
||||
# Pre-commit hooks for ferro-ta
|
||||
# Install: pre-commit install
|
||||
# Install: pre-commit install --hook-type pre-commit --hook-type pre-push
|
||||
# Run: pre-commit run --all-files
|
||||
default_language_version:
|
||||
python: python3
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.0
|
||||
rev: v0.15.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
@@ -18,6 +18,7 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
exclude: ^conda/meta\.yaml$
|
||||
- id: check-added-large-files
|
||||
args: [--maxkb=1000]
|
||||
- id: check-merge-conflict
|
||||
@@ -31,3 +32,13 @@ repos:
|
||||
# entry: mypy python/ferro_ta --ignore-missing-imports
|
||||
# language: system
|
||||
# pass_filenames: false
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ci-basic-pre-push
|
||||
name: ci basic pre-push
|
||||
entry: scripts/pre_push_checks.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-push]
|
||||
|
||||
+205
-1
@@ -9,6 +9,206 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.3] — 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Stock instrument** (`instrument="stock"`) in `PayoffLeg` and `StrategyLeg`
|
||||
for modelling equity-holding strategies (Covered Call, Protective Put, Collar,
|
||||
Covered Strangle, Stock + Spread). Linear payoff identical to futures.
|
||||
Exposed in all three layers: Rust core, Python, and WASM.
|
||||
- **Extended Greeks** (`extended_greeks`): closed-form vanna (∂Δ/∂σ), volga
|
||||
(∂²V/∂σ²), charm (∂Δ/∂t), speed (∂Γ/∂S), and color (∂Γ/∂t) for BSM.
|
||||
Batch vectorisation supported.
|
||||
- **Digital options** (`digital_option_price`, `digital_option_greeks`):
|
||||
cash-or-nothing and asset-or-nothing pricing (BSM closed-form) plus
|
||||
numerical delta / gamma / vega. Scalar and batch variants.
|
||||
- **American options** (`american_option_price`, `early_exercise_premium`):
|
||||
Barone-Adesi-Whaley (1987) quadratic approximation — O(1) per evaluation.
|
||||
Scalar and batch variants.
|
||||
- **Historical volatility estimators** (all rolling, annualised): close-to-close,
|
||||
Parkinson, Garman-Klass, Rogers-Satchell, Yang-Zhang. Yang-Zhang is
|
||||
~14× more efficient than close-to-close and handles overnight gaps.
|
||||
- **Volatility cone** (`vol_cone`): min / p25 / median / p75 / max distribution
|
||||
of realised vol across user-specified window lengths — contextualises current
|
||||
IV against historical norms.
|
||||
- **`strategy_value`**: pre-expiry BSM mid-price value of a multi-leg strategy
|
||||
over a spot grid (time value included), complementing `strategy_payoff`
|
||||
(expiry intrinsic).
|
||||
- **`expected_move`**: log-normal ±1σ expected price range over N days.
|
||||
- **`put_call_parity_deviation`**: detects stale quotes or data errors by
|
||||
computing C − P − (S·e^{−qT} − K·e^{−rT}).
|
||||
- All new analytics exposed to **WASM** (`wasm/src/lib.rs`):
|
||||
`extended_greeks`, `digital_price`, `digital_greeks`, `american_price`,
|
||||
`early_exercise_premium`, `close_to_close_vol`, `parkinson_vol`,
|
||||
`garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol`, `vol_cone`,
|
||||
`expected_move`, `put_call_parity_deviation`, `strategy_payoff_dense`,
|
||||
`aggregate_greeks_dense`, `strategy_value_grid`.
|
||||
- `aggregate_greeks_dense` added to `ferro_ta_core::options::payoff` (pure
|
||||
Rust, no PyO3/numpy dependency) enabling WASM reuse.
|
||||
- Comprehensive docstrings (NumPy style with Parameters / Returns / Notes /
|
||||
Examples) on all new Python functions.
|
||||
- Accuracy test suite `tests/unit/test_derivatives_accuracy.py` validates
|
||||
digital options, extended Greeks, American options, and vol estimators
|
||||
against scipy and analytical reference formulas.
|
||||
- scipy added to `dev` optional dependencies for reference testing.
|
||||
|
||||
### Changed
|
||||
|
||||
- `StrategyLeg.expiry_selector`, `StrategyLeg.strike_selector`, and
|
||||
`StrategyLeg.option_type` are now `Optional` (None allowed for stock legs).
|
||||
Existing option legs are unaffected.
|
||||
- `docs/derivatives-analytics.md` rewritten to cover all new features with
|
||||
runnable examples and an efficiency comparison table for vol estimators.
|
||||
|
||||
## [1.1.2] — 2026-04-01
|
||||
|
||||
### Changed
|
||||
|
||||
- WASM npm package now ships both Node.js (CommonJS) and browser/web worker
|
||||
(ESM) builds via conditional exports in package.json.
|
||||
- Fixed wasm-publish.yml: added job condition, workflow_dispatch inputs, and
|
||||
pre-publish test gate.
|
||||
- Fixed CI.yml: SBOM job now waits for both PyPI and crates.io publish.
|
||||
- Aligned Node.js version to 20 across all CI workflows.
|
||||
- Rewrote wasm/README.md and ferro_ta_core/README.md to reflect full feature
|
||||
parity (200+ WASM exports, 22 core modules).
|
||||
|
||||
## [1.1.1] — 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- Full feature parity across Rust core, Python, and WASM targets.
|
||||
- 56 new pure-Rust indicator functions in ferro_ta_core: ROC/ROCP/ROCR/ROCR100,
|
||||
WILLR, AROON/AROONOSC, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC,
|
||||
DEMA, TEMA, TRIMA, KAMA, T3, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE,
|
||||
MACDFIX, MACDEXT, MA (generic dispatcher), MAVP, VAR, LINEARREG variants,
|
||||
TSF, BETA, CORREL, NATR, and 19 math operators/transforms.
|
||||
- 120+ new WASM bindings: all 61 candlestick patterns (via macro), 9 streaming
|
||||
API structs, options pricing/greeks/IV/chain/surface, futures basis/roll/curve/
|
||||
synthetic, backtest engine (close-only + OHLCV), walk-forward analysis,
|
||||
Monte Carlo bootstrap, performance metrics, batch operations, portfolio
|
||||
analytics, and signal utilities.
|
||||
- `workflow_dispatch` trigger added to `wasm-publish.yml` for manual npm
|
||||
publishing.
|
||||
|
||||
## [1.0.6] — 2026-03-24
|
||||
|
||||
### Added
|
||||
|
||||
- Added a repo-managed pre-push gate that mirrors the core local CI and
|
||||
release checks, including version and changelog validation, Rust formatting
|
||||
and clippy, Python linting and type checks, tests, docs, and the WASM smoke
|
||||
suite.
|
||||
- Added generated API manifest tooling and CI coverage so Python and WASM
|
||||
export drift is detected before release candidates are pushed.
|
||||
- Expanded the Rust-backed implementation surface for analysis and data-heavy
|
||||
workflows, including backtest signal generation, portfolio loops, payoff and
|
||||
Greeks aggregation, chunked indicator execution, and related helper paths.
|
||||
- Expanded the WASM package surface with additional indicator exports such as
|
||||
`WMA`, `ADX`, and `MFI`, along with refreshed Node examples and conformance
|
||||
coverage against the Python package.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed benchmark wrappers, perf-contract artifacts, and benchmark
|
||||
comparison helpers so the checked-in performance evidence stays aligned with
|
||||
the current feature set.
|
||||
- Hardened Python CI and local tooling so they run the same typecheck and test
|
||||
entrypoints, including installing the optional MCP dependency needed by the
|
||||
MCP server tests.
|
||||
- Updated local pre-commit integration to match the current Ruff
|
||||
configuration and refreshed locked dependencies to pick up the audited
|
||||
PyJWT security fix.
|
||||
|
||||
### Fixed
|
||||
|
||||
- One-off benchmark output files produced in the repository root are now
|
||||
ignored so local benchmarking no longer dirties the repo by default.
|
||||
- Tightened API typing and MCP helper behavior so the stricter lint and
|
||||
typecheck pipeline passes consistently before release.
|
||||
|
||||
## [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
|
||||
@@ -274,6 +474,10 @@ and the project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.1...HEAD
|
||||
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.6...HEAD
|
||||
[1.0.6]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.4...v1.0.6
|
||||
[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
|
||||
|
||||
@@ -36,6 +36,34 @@ uv run ruff check python/ tests/
|
||||
uv run mypy python/ferro_ta --ignore-missing-imports
|
||||
```
|
||||
|
||||
## Git hooks and pre-push checks
|
||||
|
||||
Install the repo-managed git hooks after syncing your environment:
|
||||
|
||||
```bash
|
||||
make hooks
|
||||
```
|
||||
|
||||
That installs both the existing `pre-commit` hook and a `pre-push` hook that
|
||||
runs the local CI gate before anything is pushed.
|
||||
|
||||
To run the same gate manually:
|
||||
|
||||
```bash
|
||||
make prepush
|
||||
```
|
||||
|
||||
To run only part of it while iterating:
|
||||
|
||||
```bash
|
||||
make prepush CHECKS="version changelog python_lint"
|
||||
```
|
||||
|
||||
The pre-push runner covers the basic required CI categories we can execute
|
||||
locally: version/changelog checks, Rust fmt/clippy/core checks, Python
|
||||
lint/typecheck/tests, docs, and WASM. It intentionally skips the multi-version
|
||||
matrix, audit, and benchmark-regression jobs.
|
||||
|
||||
## Alternative: set up with plain pip
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+28
-26
@@ -34,9 +34,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.0"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
|
||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
@@ -67,9 +67,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.57"
|
||||
version = "1.2.59"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta"
|
||||
version = "1.0.1"
|
||||
version = "1.1.4"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ferro_ta_core",
|
||||
@@ -222,9 +222,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.0.1"
|
||||
version = "1.1.4"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wide",
|
||||
]
|
||||
|
||||
@@ -277,9 +279,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.91"
|
||||
version = "0.3.94"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
||||
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
@@ -287,9 +289,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
version = "0.2.184"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
@@ -593,9 +595,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
@@ -727,9 +729,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.114"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
||||
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -740,9 +742,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.114"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
||||
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -750,9 +752,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.114"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
||||
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -763,18 +765,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.114"
|
||||
version = "0.2.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
||||
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.91"
|
||||
version = "0.3.94"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
||||
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -838,18 +840,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.47"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.47"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+9
-2
@@ -5,9 +5,16 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "ferro_ta"
|
||||
version = "1.0.1"
|
||||
version = "1.1.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.1" }
|
||||
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.1.4", features = ["serde"] }
|
||||
|
||||
[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 audit prepush hooks
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@@ -15,13 +15,16 @@ 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 prepush Run the local pre-push CI gate (set CHECKS='version rust_fmt' to scope it)"
|
||||
@echo " make hooks Install pre-commit and pre-push git hooks"
|
||||
@echo " make clean Remove build artefacts"
|
||||
|
||||
dev:
|
||||
pip install uv
|
||||
uv pip install --system maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml \
|
||||
sphinx sphinx-rtd-theme ruff mypy pyright
|
||||
sphinx sphinx-rtd-theme ruff mypy pyright pre-commit
|
||||
|
||||
build:
|
||||
maturin develop --release
|
||||
@@ -48,10 +51,20 @@ 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
|
||||
|
||||
prepush:
|
||||
bash scripts/pre_push_checks.sh $(CHECKS)
|
||||
|
||||
hooks:
|
||||
uv run --with pre-commit pre-commit install --hook-type pre-commit --hook-type pre-push
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
rm -rf dist/ docs/_build/ coverage.xml .coverage *.egg-info
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -70,23 +70,23 @@ rustflags = ["-C", "target-cpu=native"]
|
||||
### Phase 3: Algorithm-Level Optimizations (Target: 5-10x improvement)
|
||||
|
||||
#### SMA — O(n) running sum
|
||||
Current: recomputes each window.
|
||||
Current: recomputes each window.
|
||||
Target: single-pass running sum (already done in Rust — verify SIMD path is hit).
|
||||
|
||||
#### BBANDS — Welford's algorithm
|
||||
Current: compute mean, then variance in two passes.
|
||||
Current: compute mean, then variance in two passes.
|
||||
Target: Welford's online algorithm — single pass, better cache utilization.
|
||||
|
||||
#### ATR/ADX — Avoid redundant True Range calculations
|
||||
Current: ATR → ADX each compute TR independently.
|
||||
Current: ATR → ADX each compute TR independently.
|
||||
Target: Compute TR once, share with ATR, NATR, +DI, -DI, ADX in a single pass.
|
||||
|
||||
#### MACD — Reuse EMA computations
|
||||
Current: Compute fast EMA and slow EMA separately.
|
||||
Current: Compute fast EMA and slow EMA separately.
|
||||
Target: Single function computes both EMAs in one pass.
|
||||
|
||||
#### Candlestick Patterns — Batch lookup table
|
||||
Current: Sequential condition checks per bar.
|
||||
Current: Sequential condition checks per bar.
|
||||
Target: Pre-compute body/shadow ratios, vectorized pattern matching.
|
||||
|
||||
### Phase 4: Streaming Precomputation (Target: 100x for incremental updates)
|
||||
|
||||
+8
-8
@@ -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
|
||||
|
||||
@@ -71,6 +71,6 @@ pip install ferro-ta --no-binary ferro-ta
|
||||
|
||||
## Known limitations
|
||||
|
||||
- WASM binding: only 6 indicators exposed (see `wasm/README.md`).
|
||||
- WASM binding: full feature parity with 200+ exports including all TA-Lib indicators, candlestick patterns, streaming API, options, futures, and backtesting (see `wasm/README.md`).
|
||||
- Python 3.14+: untested; may work with `PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1`.
|
||||
- 32-bit platforms: not officially supported; source builds may succeed.
|
||||
|
||||
+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,261 @@
|
||||
# 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.
|
||||
|
||||
+19
-19
@@ -77,7 +77,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -117,12 +117,12 @@ app = FastAPI(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _nan_to_none(arr: np.ndarray) -> List[Optional[float]]:
|
||||
def _nan_to_none(arr: np.ndarray) -> list[float | None]:
|
||||
"""Convert numpy array to list, replacing NaN/Inf with None."""
|
||||
return [None if not math.isfinite(v) else float(v) for v in arr]
|
||||
|
||||
|
||||
def _validate_series(close: List[float]) -> np.ndarray:
|
||||
def _validate_series(close: list[float]) -> np.ndarray:
|
||||
if len(close) > MAX_SERIES_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
@@ -142,54 +142,54 @@ def _validate_series(close: List[float]) -> np.ndarray:
|
||||
|
||||
|
||||
class IndicatorRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
close: list[float] = Field(..., description="Close price series")
|
||||
timeperiod: int = Field(default=14, ge=1, description="Look-back period")
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
def close_must_be_finite(cls, v: list[float]) -> list[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
|
||||
|
||||
class MACDRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
close: list[float] = Field(..., description="Close price series")
|
||||
fastperiod: int = Field(default=12, ge=1)
|
||||
slowperiod: int = Field(default=26, ge=1)
|
||||
signalperiod: int = Field(default=9, ge=1)
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
def close_must_be_finite(cls, v: list[float]) -> list[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
|
||||
|
||||
class BBANDSRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
close: list[float] = Field(..., description="Close price series")
|
||||
timeperiod: int = Field(default=5, ge=2)
|
||||
nbdevup: float = Field(default=2.0, gt=0)
|
||||
nbdevdn: float = Field(default=2.0, gt=0)
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
def close_must_be_finite(cls, v: list[float]) -> list[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
|
||||
|
||||
class BacktestRequest(BaseModel):
|
||||
close: List[float] = Field(..., description="Close price series")
|
||||
close: list[float] = Field(..., description="Close price series")
|
||||
strategy: str = Field(default="rsi_30_70")
|
||||
commission_per_trade: float = Field(default=0.0, ge=0.0)
|
||||
slippage_bps: float = Field(default=0.0, ge=0.0)
|
||||
|
||||
@field_validator("close")
|
||||
@classmethod
|
||||
def close_must_be_finite(cls, v: List[float]) -> List[float]:
|
||||
def close_must_be_finite(cls, v: list[float]) -> list[float]:
|
||||
if not all(math.isfinite(x) for x in v):
|
||||
raise ValueError("close series must contain only finite values")
|
||||
return v
|
||||
@@ -201,13 +201,13 @@ class BacktestRequest(BaseModel):
|
||||
|
||||
|
||||
@app.get("/health", summary="Health check")
|
||||
def health() -> Dict[str, str]:
|
||||
def health() -> dict[str, str]:
|
||||
"""Readiness / liveness probe."""
|
||||
return {"status": "ok", "version": app.version}
|
||||
|
||||
|
||||
@app.post("/indicators/sma", summary="Simple Moving Average")
|
||||
def compute_sma(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
def compute_sma(req: IndicatorRequest) -> dict[str, Any]:
|
||||
"""Compute Simple Moving Average (SMA).
|
||||
|
||||
Returns ``result``: list of floats (null for warm-up bars).
|
||||
@@ -218,7 +218,7 @@ def compute_sma(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/indicators/ema", summary="Exponential Moving Average")
|
||||
def compute_ema(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
def compute_ema(req: IndicatorRequest) -> dict[str, Any]:
|
||||
"""Compute Exponential Moving Average (EMA)."""
|
||||
c = _validate_series(req.close)
|
||||
out = np.asarray(ft.EMA(c, timeperiod=req.timeperiod), dtype=np.float64)
|
||||
@@ -226,7 +226,7 @@ def compute_ema(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/indicators/rsi", summary="Relative Strength Index")
|
||||
def compute_rsi(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
def compute_rsi(req: IndicatorRequest) -> dict[str, Any]:
|
||||
"""Compute Relative Strength Index (RSI)."""
|
||||
c = _validate_series(req.close)
|
||||
out = np.asarray(ft.RSI(c, timeperiod=req.timeperiod), dtype=np.float64)
|
||||
@@ -234,7 +234,7 @@ def compute_rsi(req: IndicatorRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/indicators/macd", summary="MACD")
|
||||
def compute_macd(req: MACDRequest) -> Dict[str, Any]:
|
||||
def compute_macd(req: MACDRequest) -> dict[str, Any]:
|
||||
"""Compute MACD (line, signal, histogram).
|
||||
|
||||
Returns ``result`` with keys ``macd``, ``signal``, ``hist``.
|
||||
@@ -256,7 +256,7 @@ def compute_macd(req: MACDRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/indicators/bbands", summary="Bollinger Bands")
|
||||
def compute_bbands(req: BBANDSRequest) -> Dict[str, Any]:
|
||||
def compute_bbands(req: BBANDSRequest) -> dict[str, Any]:
|
||||
"""Compute Bollinger Bands (upper, middle, lower).
|
||||
|
||||
Returns ``result`` with keys ``upper``, ``middle``, ``lower``.
|
||||
@@ -278,7 +278,7 @@ def compute_bbands(req: BBANDSRequest) -> Dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/backtest", summary="Vectorized backtest")
|
||||
def run_backtest(req: BacktestRequest) -> Dict[str, Any]:
|
||||
def run_backtest(req: BacktestRequest) -> dict[str, Any]:
|
||||
"""Run a vectorized backtest using a named strategy.
|
||||
|
||||
Strategies: ``rsi_30_70``, ``sma_crossover``, ``macd_crossover``.
|
||||
|
||||
+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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"metadata": {
|
||||
"suite": "backtest",
|
||||
"runtime": {
|
||||
"generated_at_utc": "2026-03-27T16:31:53.866252+00:00",
|
||||
"python_version": "3.13.5",
|
||||
"python_implementation": "CPython",
|
||||
"python_executable": "/Users/pratikbhadane/Work/Projects/ferro-ta/.venv/bin/python3",
|
||||
"platform": "macOS-26.3.1-arm64-arm-64bit-Mach-O",
|
||||
"system": "Darwin",
|
||||
"release": "25.3.0",
|
||||
"machine": "arm64",
|
||||
"processor": "arm",
|
||||
"cpu_model": "Apple M3 Max",
|
||||
"cpu_count_logical": 14,
|
||||
"total_memory_bytes": 38654705664
|
||||
},
|
||||
"git": {
|
||||
"commit": "2d776b6f908fd1a4f30a696972b7df5e5fe2ca00",
|
||||
"dirty": true,
|
||||
"branch": "main"
|
||||
},
|
||||
"build": {
|
||||
"rustc": "rustc 1.93.1 (01f6ddf75 2026-02-11)\nbinary: rustc\ncommit-hash: 01f6ddf7588f42ae2d7eb0a2f21d44e8e96674cf\ncommit-date: 2026-02-11\nhost: aarch64-apple-darwin\nrelease: 1.93.1\nLLVM version: 21.1.8",
|
||||
"cargo": "cargo 1.93.1 (083ac5135 2025-12-15)",
|
||||
"cargo_release_profile": {
|
||||
"lto": true,
|
||||
"codegen-units": 1
|
||||
},
|
||||
"rustflags": null,
|
||||
"cargo_build_rustflags": null,
|
||||
"maturin_flags": null
|
||||
},
|
||||
"packages": {
|
||||
"numpy": "2.2.6",
|
||||
"ferro-ta": "1.0.6"
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
"backtest_core_single": [
|
||||
{
|
||||
"n_bars": 10000,
|
||||
"ferro_ta_ms": 0.024,
|
||||
"ferro_ta_mbars_s": 415.9388,
|
||||
"vectorbt_ms": 1.2843,
|
||||
"speedup_vs_vectorbt": 53.4187
|
||||
},
|
||||
{
|
||||
"n_bars": 100000,
|
||||
"ferro_ta_ms": 0.1964,
|
||||
"ferro_ta_mbars_s": 509.1209,
|
||||
"vectorbt_ms": 3.047,
|
||||
"speedup_vs_vectorbt": 15.5129
|
||||
}
|
||||
],
|
||||
"backtest_ohlcv_core": [
|
||||
{
|
||||
"n_bars": 10000,
|
||||
"ferro_ta_ms": 0.0573,
|
||||
"ferro_ta_mbars_s": 174.4166
|
||||
},
|
||||
{
|
||||
"n_bars": 100000,
|
||||
"ferro_ta_ms": 0.7068,
|
||||
"ferro_ta_mbars_s": 141.4927
|
||||
}
|
||||
],
|
||||
"performance_metrics": [
|
||||
{
|
||||
"n_bars": 10000,
|
||||
"ferro_ta_ms": 0.2182,
|
||||
"numpy_partial_ms": 0.0496,
|
||||
"speedup_vs_numpy": 0.2272,
|
||||
"note": "numpy_partial only computes sharpe+max_dd (2/23 metrics)"
|
||||
},
|
||||
{
|
||||
"n_bars": 100000,
|
||||
"ferro_ta_ms": 3.0303,
|
||||
"numpy_partial_ms": 0.351,
|
||||
"speedup_vs_numpy": 0.1158,
|
||||
"note": "numpy_partial only computes sharpe+max_dd (2/23 metrics)"
|
||||
}
|
||||
],
|
||||
"multi_asset": [
|
||||
{
|
||||
"n_bars": 10000,
|
||||
"n_assets": 50,
|
||||
"parallel_ms": 2.4245,
|
||||
"serial_ms": 4.1751,
|
||||
"loop_ms": 2.0349,
|
||||
"parallel_speedup_vs_loop": 0.8393,
|
||||
"parallel_speedup_vs_serial": 1.722
|
||||
},
|
||||
{
|
||||
"n_bars": 100000,
|
||||
"n_assets": 50,
|
||||
"parallel_ms": 24.0349,
|
||||
"serial_ms": 47.9311,
|
||||
"loop_ms": 24.7476,
|
||||
"parallel_speedup_vs_loop": 1.0297,
|
||||
"parallel_speedup_vs_serial": 1.9942
|
||||
}
|
||||
],
|
||||
"monte_carlo": [
|
||||
{
|
||||
"n_bars": 10000,
|
||||
"n_sims": 500,
|
||||
"ferro_ta_ms": 3.862,
|
||||
"numpy_loop_ms": 51.1589,
|
||||
"speedup_vs_numpy": 13.2469
|
||||
},
|
||||
{
|
||||
"n_bars": 100000,
|
||||
"n_sims": 500,
|
||||
"ferro_ta_ms": 26.0019,
|
||||
"numpy_loop_ms": 310.582,
|
||||
"speedup_vs_numpy": 11.9446
|
||||
}
|
||||
],
|
||||
"engine_full_pipeline": [
|
||||
{
|
||||
"n_bars": 10000,
|
||||
"ferro_ta_ms": 0.4402,
|
||||
"description": "Full pipeline: signals + OHLCV fill + 23 metrics + trades + drawdown"
|
||||
},
|
||||
{
|
||||
"n_bars": 100000,
|
||||
"ferro_ta_ms": 4.445,
|
||||
"description": "Full pipeline: signals + OHLCV fill + 23 metrics + trades + drawdown"
|
||||
}
|
||||
],
|
||||
"walk_forward_indices": [
|
||||
{
|
||||
"n_bars": 10000,
|
||||
"train_bars": 2000,
|
||||
"test_bars": 500,
|
||||
"ferro_ta_us": 0.333
|
||||
},
|
||||
{
|
||||
"n_bars": 100000,
|
||||
"train_bars": 20000,
|
||||
"test_bars": 5000,
|
||||
"ferro_ta_us": 0.292
|
||||
}
|
||||
],
|
||||
"kelly_fraction": [
|
||||
{
|
||||
"n_calls": 1000,
|
||||
"ferro_ta_us": 86.458
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
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,67 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"bench_backtest": {
|
||||
"path": "benchmarks/artifacts/latest/bench_backtest_results.json",
|
||||
"size_bytes": 4022,
|
||||
"sha256": "acf27cd5d5077aff51194e31936aba2b9304a8a62d993b2ec496d6f347545316"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
ferro_ta backtesting engine speed benchmark.
|
||||
|
||||
Measures throughput for single-asset, multi-asset, and analytics functions
|
||||
across multiple bar sizes. Optional competitor comparison (vectorbt, backtrader)
|
||||
is guarded behind try/except.
|
||||
|
||||
Usage:
|
||||
python benchmarks/bench_backtest.py
|
||||
python benchmarks/bench_backtest.py --sizes 10000 100000
|
||||
python benchmarks/bench_backtest.py --skip-competitors --json benchmarks/artifacts/bench_backtest_results.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from ferro_ta._ferro_ta import (
|
||||
backtest_core,
|
||||
backtest_multi_asset_core,
|
||||
backtest_ohlcv_core,
|
||||
compute_performance_metrics,
|
||||
kelly_fraction,
|
||||
monte_carlo_bootstrap,
|
||||
walk_forward_indices,
|
||||
)
|
||||
|
||||
from ferro_ta.analysis.backtest import BacktestEngine
|
||||
|
||||
try:
|
||||
from benchmarks.metadata import benchmark_metadata
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
from metadata import benchmark_metadata # type: ignore[no-redef]
|
||||
|
||||
# Optional competitors -------------------------------------------------------
|
||||
try:
|
||||
import vectorbt as vbt # type: ignore[import]
|
||||
|
||||
VECTORBT_AVAILABLE = True
|
||||
except ImportError:
|
||||
VECTORBT_AVAILABLE = False
|
||||
vbt = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
import backtrader as bt # type: ignore[import]
|
||||
|
||||
BACKTRADER_AVAILABLE = True
|
||||
except ImportError:
|
||||
BACKTRADER_AVAILABLE = False
|
||||
bt = None # type: ignore[assignment]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
N_WARMUP = 1
|
||||
N_RUNS = 5
|
||||
DEFAULT_SIZES = [10_000, 100_000, 1_000_000]
|
||||
N_ASSETS = 50
|
||||
N_SIMS = 500
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timer helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _time_fn(
|
||||
fn, *args, n_warmup: int = N_WARMUP, n_runs: int = N_RUNS, **kwargs
|
||||
) -> float:
|
||||
for _ in range(n_warmup):
|
||||
fn(*args, **kwargs)
|
||||
times: list[float] = []
|
||||
for _ in range(n_runs):
|
||||
t0 = time.perf_counter()
|
||||
fn(*args, **kwargs)
|
||||
times.append(time.perf_counter() - t0)
|
||||
return float(np.median(times))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_ohlcv(n: int, seed: int = 0) -> tuple[np.ndarray, ...]:
|
||||
rng = np.random.default_rng(seed)
|
||||
close = np.cumprod(1 + rng.standard_normal(n) * 0.01) * 100.0
|
||||
high = close + rng.uniform(0.1, 1.5, n)
|
||||
low = close - rng.uniform(0.1, 1.5, n)
|
||||
open_ = close + rng.standard_normal(n) * 0.3
|
||||
return open_, high, low, close
|
||||
|
||||
|
||||
def _make_signals(n: int, seed: int = 1) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
raw = np.sign(rng.standard_normal(n))
|
||||
raw[raw == 0] = 1.0
|
||||
return raw.astype(np.float64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Benchmark functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def bench_backtest_core_single(n: int) -> dict[str, Any]:
|
||||
_, _, _, close = _make_ohlcv(n)
|
||||
signals = _make_signals(n)
|
||||
|
||||
t_ferro = _time_fn(backtest_core, close, signals)
|
||||
|
||||
row: dict[str, Any] = {
|
||||
"n_bars": n,
|
||||
"ferro_ta_ms": round(t_ferro * 1000, 4),
|
||||
"ferro_ta_mbars_s": round(n / t_ferro / 1e6, 4),
|
||||
}
|
||||
|
||||
if VECTORBT_AVAILABLE:
|
||||
import pandas as pd # noqa: PLC0415
|
||||
|
||||
close_s = pd.Series(close)
|
||||
sig_s = pd.Series(signals.astype(bool))
|
||||
|
||||
def _vbt():
|
||||
pf = vbt.Portfolio.from_signals(close_s, sig_s, ~sig_s, freq="1D")
|
||||
return pf.total_return()
|
||||
|
||||
t_vbt = _time_fn(_vbt)
|
||||
row["vectorbt_ms"] = round(t_vbt * 1000, 4)
|
||||
row["speedup_vs_vectorbt"] = round(t_vbt / t_ferro, 4)
|
||||
|
||||
return row
|
||||
|
||||
|
||||
def bench_backtest_ohlcv_core(n: int) -> dict[str, Any]:
|
||||
open_, high, low, close = _make_ohlcv(n)
|
||||
signals = _make_signals(n)
|
||||
|
||||
t_ferro = _time_fn(
|
||||
backtest_ohlcv_core,
|
||||
open_,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
signals,
|
||||
fill_mode="market_open",
|
||||
stop_loss_pct=0.02,
|
||||
take_profit_pct=0.04,
|
||||
)
|
||||
|
||||
return {
|
||||
"n_bars": n,
|
||||
"ferro_ta_ms": round(t_ferro * 1000, 4),
|
||||
"ferro_ta_mbars_s": round(n / t_ferro / 1e6, 4),
|
||||
}
|
||||
|
||||
|
||||
def bench_performance_metrics(n: int) -> dict[str, Any]:
|
||||
rng = np.random.default_rng(42)
|
||||
returns = rng.standard_normal(n) * 0.01
|
||||
equity = np.cumprod(1 + returns)
|
||||
|
||||
t_ferro = _time_fn(compute_performance_metrics, returns, equity)
|
||||
|
||||
def _numpy_sharpe():
|
||||
mean_r = np.mean(returns)
|
||||
std_r = np.std(returns, ddof=1)
|
||||
_ = mean_r / std_r * np.sqrt(252)
|
||||
rolling_max = np.maximum.accumulate(equity)
|
||||
drawdown = (equity - rolling_max) / rolling_max
|
||||
_ = float(drawdown.min())
|
||||
|
||||
t_numpy = _time_fn(_numpy_sharpe)
|
||||
|
||||
return {
|
||||
"n_bars": n,
|
||||
"ferro_ta_ms": round(t_ferro * 1000, 4),
|
||||
"numpy_partial_ms": round(t_numpy * 1000, 4),
|
||||
"speedup_vs_numpy": round(t_numpy / t_ferro, 4),
|
||||
"note": "numpy_partial only computes sharpe+max_dd (2/23 metrics)",
|
||||
}
|
||||
|
||||
|
||||
def bench_multi_asset(n: int, n_assets: int = N_ASSETS) -> dict[str, Any]:
|
||||
rng = np.random.default_rng(7)
|
||||
close_2d = np.ascontiguousarray(
|
||||
np.cumprod(1 + rng.standard_normal((n, n_assets)) * 0.01, axis=0) * 100.0
|
||||
)
|
||||
weights_2d = np.full((n, n_assets), 1.0 / n_assets)
|
||||
|
||||
t_parallel = _time_fn(
|
||||
backtest_multi_asset_core, close_2d, weights_2d, parallel=True
|
||||
)
|
||||
t_serial = _time_fn(backtest_multi_asset_core, close_2d, weights_2d, parallel=False)
|
||||
|
||||
def _numpy_loop():
|
||||
results = []
|
||||
for j in range(n_assets):
|
||||
col = np.ascontiguousarray(close_2d[:, j])
|
||||
sig = np.ones(n)
|
||||
_, _, sr, _ = backtest_core(col, sig)
|
||||
results.append(sr)
|
||||
return np.stack(results, axis=1)
|
||||
|
||||
t_loop = _time_fn(_numpy_loop)
|
||||
|
||||
return {
|
||||
"n_bars": n,
|
||||
"n_assets": n_assets,
|
||||
"parallel_ms": round(t_parallel * 1000, 4),
|
||||
"serial_ms": round(t_serial * 1000, 4),
|
||||
"loop_ms": round(t_loop * 1000, 4),
|
||||
"parallel_speedup_vs_loop": round(t_loop / t_parallel, 4),
|
||||
"parallel_speedup_vs_serial": round(t_serial / t_parallel, 4),
|
||||
}
|
||||
|
||||
|
||||
def bench_monte_carlo(n: int, n_sims: int = N_SIMS) -> dict[str, Any]:
|
||||
rng = np.random.default_rng(3)
|
||||
returns = rng.standard_normal(n) * 0.01
|
||||
|
||||
t_ferro = _time_fn(monte_carlo_bootstrap, returns, n_sims=n_sims, seed=42)
|
||||
|
||||
def _numpy_mc():
|
||||
out = np.empty((n_sims, n))
|
||||
for i in range(n_sims):
|
||||
idx = np.random.choice(len(returns), size=len(returns), replace=True)
|
||||
out[i] = np.cumprod(1 + returns[idx])
|
||||
return out
|
||||
|
||||
t_numpy = _time_fn(_numpy_mc)
|
||||
|
||||
return {
|
||||
"n_bars": n,
|
||||
"n_sims": n_sims,
|
||||
"ferro_ta_ms": round(t_ferro * 1000, 4),
|
||||
"numpy_loop_ms": round(t_numpy * 1000, 4),
|
||||
"speedup_vs_numpy": round(t_numpy / t_ferro, 4),
|
||||
}
|
||||
|
||||
|
||||
def bench_engine_pipeline(n: int) -> dict[str, Any]:
|
||||
_, high, low, open_ = _make_ohlcv(n)
|
||||
_, _, _, close = _make_ohlcv(n, seed=10)
|
||||
|
||||
engine = (
|
||||
BacktestEngine()
|
||||
.with_commission(0.001)
|
||||
.with_slippage(5.0)
|
||||
.with_ohlcv(high=high, low=low, open_=open_)
|
||||
.with_stop_loss(0.02)
|
||||
.with_take_profit(0.04)
|
||||
)
|
||||
|
||||
t_ferro = _time_fn(engine.run, close, "sma_crossover")
|
||||
|
||||
return {
|
||||
"n_bars": n,
|
||||
"ferro_ta_ms": round(t_ferro * 1000, 4),
|
||||
"description": "Full pipeline: signals + OHLCV fill + 23 metrics + trades + drawdown",
|
||||
}
|
||||
|
||||
|
||||
def bench_walk_forward_indices(n: int) -> dict[str, Any]:
|
||||
train = max(n // 5, 100)
|
||||
test = max(n // 20, 20)
|
||||
t = _time_fn(walk_forward_indices, n, train, test)
|
||||
return {
|
||||
"n_bars": n,
|
||||
"train_bars": train,
|
||||
"test_bars": test,
|
||||
"ferro_ta_us": round(t * 1_000_000, 4),
|
||||
}
|
||||
|
||||
|
||||
def bench_kelly_fraction() -> dict[str, Any]:
|
||||
win_rates = np.linspace(0.3, 0.7, 1000)
|
||||
avg_wins = np.linspace(0.01, 0.05, 1000)
|
||||
avg_losses = np.linspace(0.005, 0.03, 1000)
|
||||
|
||||
def _loop():
|
||||
for w, a, b in zip(win_rates, avg_wins, avg_losses):
|
||||
kelly_fraction(w, a, b)
|
||||
|
||||
t = _time_fn(_loop)
|
||||
return {"n_calls": 1000, "ferro_ta_us": round(t * 1_000_000, 4)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_all(
|
||||
sizes: list[int],
|
||||
skip_competitors: bool,
|
||||
n_assets: int,
|
||||
n_sims: int,
|
||||
) -> dict[str, Any]:
|
||||
results: dict[str, list[dict[str, Any]]] = {
|
||||
"backtest_core_single": [],
|
||||
"backtest_ohlcv_core": [],
|
||||
"performance_metrics": [],
|
||||
"multi_asset": [],
|
||||
"monte_carlo": [],
|
||||
"engine_full_pipeline": [],
|
||||
"walk_forward_indices": [],
|
||||
}
|
||||
|
||||
for n in sizes:
|
||||
print(f"\n--- {n:,} bars ---")
|
||||
|
||||
r = bench_backtest_core_single(n)
|
||||
results["backtest_core_single"].append(r)
|
||||
print(
|
||||
f" backtest_core_single: {r['ferro_ta_ms']:.2f} ms ({r['ferro_ta_mbars_s']:.2f} M bars/s)"
|
||||
)
|
||||
|
||||
r = bench_backtest_ohlcv_core(n)
|
||||
results["backtest_ohlcv_core"].append(r)
|
||||
print(
|
||||
f" backtest_ohlcv_core: {r['ferro_ta_ms']:.2f} ms ({r['ferro_ta_mbars_s']:.2f} M bars/s)"
|
||||
)
|
||||
|
||||
r = bench_performance_metrics(n)
|
||||
results["performance_metrics"].append(r)
|
||||
print(
|
||||
f" performance_metrics: {r['ferro_ta_ms']:.2f} ms (numpy partial: {r['numpy_partial_ms']:.2f} ms, {r['speedup_vs_numpy']:.2f}x)"
|
||||
)
|
||||
|
||||
r = bench_multi_asset(n, n_assets)
|
||||
results["multi_asset"].append(r)
|
||||
print(
|
||||
f" multi_asset ({n_assets}): parallel={r['parallel_ms']:.1f} ms serial={r['serial_ms']:.1f} ms loop={r['loop_ms']:.1f} ms ({r['parallel_speedup_vs_loop']:.2f}x vs loop)"
|
||||
)
|
||||
|
||||
r = bench_monte_carlo(n, n_sims)
|
||||
results["monte_carlo"].append(r)
|
||||
print(
|
||||
f" monte_carlo ({n_sims} sims): {r['ferro_ta_ms']:.2f} ms (numpy: {r['numpy_loop_ms']:.2f} ms, {r['speedup_vs_numpy']:.2f}x)"
|
||||
)
|
||||
|
||||
r = bench_engine_pipeline(n)
|
||||
results["engine_full_pipeline"].append(r)
|
||||
print(f" engine_full_pipeline: {r['ferro_ta_ms']:.2f} ms")
|
||||
|
||||
r = bench_walk_forward_indices(n)
|
||||
results["walk_forward_indices"].append(r)
|
||||
print(f" walk_forward_indices: {r['ferro_ta_us']:.1f} µs")
|
||||
|
||||
kelly_row = bench_kelly_fraction()
|
||||
results["kelly_fraction"] = [kelly_row]
|
||||
print(f"\n kelly_fraction (1k calls): {kelly_row['ferro_ta_us']:.1f} µs")
|
||||
|
||||
return {
|
||||
"metadata": benchmark_metadata("backtest"),
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Benchmark ferro-ta backtesting engine."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sizes",
|
||||
type=int,
|
||||
nargs="+",
|
||||
default=DEFAULT_SIZES,
|
||||
metavar="N",
|
||||
help="Bar counts to benchmark (default: 10000 100000 1000000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-competitors",
|
||||
action="store_true",
|
||||
help="Skip optional competitor benchmarks",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--assets",
|
||||
type=int,
|
||||
default=N_ASSETS,
|
||||
help="Number of assets for multi-asset benchmark",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sims",
|
||||
type=int,
|
||||
default=N_SIMS,
|
||||
help="Number of simulations for Monte Carlo benchmark",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json", dest="json_path", help="Write JSON results to this path"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(
|
||||
f"ferro-ta backtest benchmark | sizes={args.sizes} | assets={args.assets} | sims={args.sims}"
|
||||
)
|
||||
print("=" * 72)
|
||||
|
||||
payload = run_all(
|
||||
sizes=args.sizes,
|
||||
skip_competitors=args.skip_competitors,
|
||||
n_assets=args.assets,
|
||||
n_sims=args.sims,
|
||||
)
|
||||
|
||||
if args.json_path:
|
||||
json_path = Path(args.json_path)
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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())
|
||||
+212
-50
@@ -1,65 +1,227 @@
|
||||
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,154 @@
|
||||
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,193 @@
|
||||
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())
|
||||
+223
-113
@@ -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,17 @@ 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("Install with: pip install ta-lib (or conda install ta-lib) for comparison.\n")
|
||||
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 +331,148 @@ 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
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ Reads results.json and prints a markdown table: all indicators × all libraries.
|
||||
Unsupported (indicator, library) pairs show N/A. Supported pairs missing benchmark
|
||||
data show ERR (indicating the benchmark run was incomplete or failed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -21,9 +23,11 @@ if _root not in (Path(p).resolve() for p in sys.path):
|
||||
|
||||
from benchmarks.wrapper_registry import (
|
||||
INDICATOR_CATEGORIES,
|
||||
LIBRARY_NAMES as LIBS,
|
||||
is_supported,
|
||||
)
|
||||
from benchmarks.wrapper_registry import (
|
||||
LIBRARY_NAMES as LIBS,
|
||||
)
|
||||
|
||||
|
||||
def _all_indicators() -> list[str]:
|
||||
@@ -34,11 +38,17 @@ def _all_indicators() -> list[str]:
|
||||
def main():
|
||||
p = Path(__file__).parent / "results.json"
|
||||
if not p.exists():
|
||||
print("Run: pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v", file=sys.stderr)
|
||||
print(
|
||||
"Run: pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
raw = p.read_text().strip()
|
||||
if not raw:
|
||||
print("results.json is empty. Run the full benchmark suite first.", file=sys.stderr)
|
||||
print(
|
||||
"results.json is empty. Run the full benchmark suite first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
|
||||
@@ -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())
|
||||
@@ -88,7 +88,9 @@ def main() -> int:
|
||||
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not data.get("talib_available", False):
|
||||
print("ERROR: TA-Lib was not available; cannot enforce TA-Lib regression policy.")
|
||||
print(
|
||||
"ERROR: TA-Lib was not available; cannot enforce TA-Lib regression policy."
|
||||
)
|
||||
return 1
|
||||
|
||||
summary_by_size = {
|
||||
@@ -133,9 +135,7 @@ def main() -> int:
|
||||
)
|
||||
|
||||
if rows < args.min_rows:
|
||||
failures.append(
|
||||
f"size={size} rows {rows} < min_rows {args.min_rows}"
|
||||
)
|
||||
failures.append(f"size={size} rows {rows} < min_rows {args.min_rows}")
|
||||
if med < median_floor.get(size, float("-inf")):
|
||||
failures.append(
|
||||
f"size={size} median_speedup {med:.4f} < floor {median_floor[size]:.4f}"
|
||||
|
||||
@@ -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,284 @@
|
||||
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())
|
||||
@@ -16094,4 +16094,4 @@
|
||||
],
|
||||
"datetime": "2026-03-23T17:14:05.427766+00:00",
|
||||
"version": "5.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
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())
|
||||
+60
-53
@@ -5,65 +5,66 @@ For each indicator we compare ferro_ta output against every available reference
|
||||
Tolerances are based on known algorithmic differences (e.g. Wilder vs SMA seed).
|
||||
We only compare the overlapping (valid) suffix of each output array.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from benchmarks.data_generator import MEDIUM
|
||||
from benchmarks.wrapper_registry import (
|
||||
execute_indicator,
|
||||
INDICATOR_NAMES,
|
||||
INDICATOR_CATEGORIES,
|
||||
CUMULATIVE_INDICATORS,
|
||||
BINARY_INDICATORS,
|
||||
CUMULATIVE_INDICATORS,
|
||||
INDICATOR_CATEGORIES,
|
||||
INDICATOR_NAMES,
|
||||
available_libraries,
|
||||
execute_indicator,
|
||||
is_supported,
|
||||
)
|
||||
|
||||
# Reference = ferro_ta; compare against each library that has a non-empty result.
|
||||
REFERENCE_LIB = "ferro_ta"
|
||||
COMPARISON_LIBS = [l for l in available_libraries() if l != REFERENCE_LIB]
|
||||
COMPARISON_LIBS = [
|
||||
library for library in available_libraries() if library != REFERENCE_LIB
|
||||
]
|
||||
|
||||
# Per-indicator tolerances (rtol, atol)
|
||||
_TOLERANCES: dict[str, tuple[float, float]] = {
|
||||
"ATR": (1e-3, 0.05), # Wilder's smoothing seed differs
|
||||
"NATR": (1e-3, 0.10),
|
||||
"BBANDS": (1e-3, 0.20), # ddof=0 vs ddof=1
|
||||
"ATR": (1e-3, 0.05), # Wilder's smoothing seed differs
|
||||
"NATR": (1e-3, 0.10),
|
||||
"BBANDS": (1e-3, 0.20), # ddof=0 vs ddof=1
|
||||
"STDDEV": (1e-3, 0.20),
|
||||
"VAR": (1e-3, 0.50),
|
||||
"MACD": (1e-3, 1e-3), # double EMA seed
|
||||
"KAMA": (1e-3, 1e-3),
|
||||
"STOCH": (1e-3, 0.10), # smoothing method differences
|
||||
"SAR": (1e-3, 0.20),
|
||||
"ADOSC": (1e-3, 0.20),
|
||||
"ADX": (1e-3, 0.50), # Wilder's ADX
|
||||
"PLUS_DI":(1e-3, 0.50),
|
||||
"MINUS_DI":(1e-3, 0.50),
|
||||
"PPO": (1e-2, 1e-3),
|
||||
"CMO": (1e-3, 0.10),
|
||||
"TRIX": (1e-3, 1e-3),
|
||||
"CCI": (1e-3, 0.10),
|
||||
"VAR": (1e-3, 0.50),
|
||||
"MACD": (1e-3, 1.00), # seed differences across libraries
|
||||
"KAMA": (1e-3, 1e-3),
|
||||
"STOCH": (1e-3, 0.10), # smoothing method differences
|
||||
"SAR": (1e-3, 0.20),
|
||||
"ADOSC": (1e-3, 0.20),
|
||||
"ADX": (1e-3, 0.50), # Wilder's ADX
|
||||
"PLUS_DI": (1e-3, 0.50),
|
||||
"MINUS_DI": (1e-3, 0.50),
|
||||
"PPO": (1e-2, 1e-3),
|
||||
"CMO": (1e-3, 0.10),
|
||||
"TRIX": (1e-3, 0.05),
|
||||
"CCI": (1e-3, 0.10),
|
||||
"SUPERTREND": (1e-2, 0.50),
|
||||
"KELTNER_CHANNELS": (1e-2, 0.50),
|
||||
"DONCHIAN": (1e-4, 1e-4),
|
||||
"HT_DCPERIOD": (1e-2, 1.0),
|
||||
"VWAP": (1e-3, 0.10),
|
||||
"AROON": (1e-4, 1e-3),
|
||||
"HT_DCPERIOD": (1e-2, 2.0),
|
||||
"VWAP": (1e-3, 0.10),
|
||||
"AROON": (1e-4, 1e-3),
|
||||
"LINEARREG": (1e-4, 1e-4),
|
||||
"LINEARREG_SLOPE": (1e-4, 1e-4),
|
||||
"CORREL": (1e-4, 1e-3),
|
||||
"BETA": (1e-3, 1e-3),
|
||||
"TSF": (1e-4, 1e-4),
|
||||
"EMA": (1e-3, 0.30), # ta library uses different EMA seed
|
||||
"DEMA": (1e-3, 0.50),
|
||||
"TEMA": (1e-3, 0.50),
|
||||
"T3": (1e-3, 0.50),
|
||||
"HULL_MA":(1e-3, 0.10),
|
||||
"WMA": (1e-4, 1e-4),
|
||||
"TRIMA": (1e-4, 1e-4),
|
||||
"MACD": (1e-3, 1.00), # seed differences across libraries
|
||||
"TRIX": (1e-3, 0.05),
|
||||
"HT_DCPERIOD": (1e-2, 2.0),
|
||||
"BETA": (1e-3, 1e-3),
|
||||
"TSF": (1e-4, 1e-4),
|
||||
"EMA": (1e-3, 0.30), # ta library uses different EMA seed
|
||||
"DEMA": (1e-3, 0.50),
|
||||
"TEMA": (1e-3, 0.50),
|
||||
"T3": (1e-3, 0.50),
|
||||
"HULL_MA": (1e-3, 0.10),
|
||||
"WMA": (1e-4, 1e-4),
|
||||
"TRIMA": (1e-4, 1e-4),
|
||||
}
|
||||
|
||||
_DEFAULT_TOL = (1e-4, 1e-5)
|
||||
@@ -71,33 +72,33 @@ _DEFAULT_TOL = (1e-4, 1e-5)
|
||||
# Pairs that use correlation check (>=0.95) due to known algorithmic divergence
|
||||
# Format: (indicator, library) or just indicator (applies to all libs)
|
||||
_CORRELATION_PAIRS: set[tuple[str, str]] = {
|
||||
("PPO", "talib"), # different PPO formula normalization
|
||||
("PPO", "talib"), # different PPO formula normalization
|
||||
("PPO", "pandas_ta"),
|
||||
("PPO", "tulipy"),
|
||||
("STOCH", "ta"),
|
||||
("SUPERTREND", "pandas_ta"),
|
||||
("KELTNER_CHANNELS", "pandas_ta"),
|
||||
("KELTNER_CHANNELS", "ta"),
|
||||
("EMA", "finta"), # finta EMA uses different initialization
|
||||
("KAMA", "pandas_ta"), # pandas_ta KAMA has slightly different seed
|
||||
("RSI", "ta"), # ta uses SMA warmup vs Wilder
|
||||
("RSI", "finta"), # same
|
||||
("EMA", "finta"), # finta EMA uses different initialization
|
||||
("KAMA", "pandas_ta"), # pandas_ta KAMA has slightly different seed
|
||||
("RSI", "ta"), # ta uses SMA warmup vs Wilder
|
||||
("RSI", "finta"), # same
|
||||
}
|
||||
|
||||
# Pairs that are skipped because they are structurally incompatible
|
||||
_SKIP_PAIRS: set[tuple[str, str]] = {
|
||||
("BBANDS", "finta"), # finta normalizes band differently
|
||||
("ATR", "finta"), # finta ATR uses simple TR not Wilder
|
||||
("STDDEV", "finta"), # finta uses population std
|
||||
("TRIMA", "finta"), # finta TRIMA uses different formula
|
||||
("PPO", "finta"), # finta PPO scaling incompatible
|
||||
("STOCH", "finta"), # finta STOCH formula differs
|
||||
("VWAP", "pandas_ta"), # pandas_ta VWAP anchors to session start
|
||||
("BBANDS", "finta"), # finta normalizes band differently
|
||||
("ATR", "finta"), # finta ATR uses simple TR not Wilder
|
||||
("STDDEV", "finta"), # finta uses population std
|
||||
("TRIMA", "finta"), # finta TRIMA uses different formula
|
||||
("PPO", "finta"), # finta PPO scaling incompatible
|
||||
("STOCH", "finta"), # finta STOCH formula differs
|
||||
("VWAP", "pandas_ta"), # pandas_ta VWAP anchors to session start
|
||||
("HT_TRENDMODE", "talib"), # binary; Hilbert seed diverges
|
||||
("CMO", "talib"), # ferro_ta CMO smoothing variant corr < 0.90
|
||||
("CMO", "talib"), # ferro_ta CMO smoothing variant corr < 0.90
|
||||
("CMO", "pandas_ta"),
|
||||
("CMO", "finta"),
|
||||
("PLUS_DI", "pandas_ta"), # pandas_ta ADX column naming corr < 0.70
|
||||
("PLUS_DI", "pandas_ta"), # pandas_ta ADX column naming corr < 0.70
|
||||
}
|
||||
|
||||
MIN_OVERLAP = 30 # minimum points to make comparison meaningful
|
||||
@@ -114,12 +115,14 @@ def _compare(ref: np.ndarray, cmp: np.ndarray, indicator: str, library: str) ->
|
||||
c = cmp[-n:]
|
||||
if indicator in BINARY_INDICATORS or (indicator, library) in _CORRELATION_PAIRS:
|
||||
# Use correlation check for structurally different algorithms
|
||||
corr = np.corrcoef(r, c)[0, 1] if not indicator in BINARY_INDICATORS else None
|
||||
corr = np.corrcoef(r, c)[0, 1] if indicator not in BINARY_INDICATORS else None
|
||||
if indicator in BINARY_INDICATORS:
|
||||
agree = np.mean(r == c)
|
||||
assert agree >= 0.80, f"Binary agreement {agree:.1%} < 80%"
|
||||
else:
|
||||
assert corr >= 0.90, f"Correlation {corr:.4f} < 0.90 (structural divergence)"
|
||||
assert corr >= 0.90, (
|
||||
f"Correlation {corr:.4f} < 0.90 (structural divergence)"
|
||||
)
|
||||
elif indicator in CUMULATIVE_INDICATORS:
|
||||
dr, dc = np.diff(r), np.diff(c)
|
||||
if len(dr) < 5 or len(dc) < 5:
|
||||
@@ -136,6 +139,7 @@ def _compare(ref: np.ndarray, cmp: np.ndarray, indicator: str, library: str) ->
|
||||
|
||||
# ── dynamically generate one test per (indicator, library) pair ─────────────
|
||||
|
||||
|
||||
def pytest_generate_tests(metafunc):
|
||||
if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames:
|
||||
params = []
|
||||
@@ -172,6 +176,7 @@ class TestAccuracy:
|
||||
|
||||
# ── quick smoke tests that always run (no skip) ──────────────────────────────
|
||||
|
||||
|
||||
class TestSmoke:
|
||||
"""Sanity checks that ferro_ta returns non-empty finite arrays."""
|
||||
|
||||
@@ -182,7 +187,9 @@ class TestSmoke:
|
||||
|
||||
arr = execute_indicator("ferro_ta", indicator, MEDIUM)
|
||||
assert len(arr) > 0, f"ferro_ta {indicator} returned empty array"
|
||||
assert np.all(np.isfinite(arr)), f"ferro_ta {indicator} has non-finite values: {arr[~np.isfinite(arr)][:5]}"
|
||||
assert np.all(np.isfinite(arr)), (
|
||||
f"ferro_ta {indicator} has non-finite values: {arr[~np.isfinite(arr)][:5]}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("category,indicators", INDICATOR_CATEGORIES.items())
|
||||
def test_category_coverage(self, category, indicators):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+31
-18
@@ -6,31 +6,36 @@ Run: pytest benchmarks/test_speed.py --benchmark-only -v
|
||||
|
||||
Streaming benchmarks are in test_streaming_speed.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from benchmarks.data_generator import LARGE
|
||||
from benchmarks.wrapper_registry import (
|
||||
execute_indicator,
|
||||
INDICATOR_CATEGORIES,
|
||||
available_libraries,
|
||||
execute_indicator,
|
||||
is_supported,
|
||||
)
|
||||
|
||||
BENCH_DATA = LARGE # 100k bars for main benchmarks
|
||||
BENCH_LIBS = available_libraries()
|
||||
BENCH_DATA = LARGE # 100k bars for main benchmarks
|
||||
BENCH_LIBS = available_libraries()
|
||||
|
||||
|
||||
def _make_bench(indicator: str, library: str):
|
||||
"""Return a benchmark function that runs indicator on library (uses BENCH_DATA)."""
|
||||
|
||||
def _fn():
|
||||
execute_indicator(library, indicator, BENCH_DATA)
|
||||
|
||||
_fn.__name__ = f"{library}_{indicator}"
|
||||
return _fn
|
||||
|
||||
|
||||
# ── Parametrize over all (indicator, library) combinations ───────────────────
|
||||
|
||||
|
||||
def pytest_generate_tests(metafunc):
|
||||
if "indicator" in metafunc.fixturenames and "library" in metafunc.fixturenames:
|
||||
params = []
|
||||
@@ -53,20 +58,24 @@ class TestSpeed:
|
||||
|
||||
# ── Standalone head-to-head for the most important indicators ─────────────────
|
||||
|
||||
@pytest.mark.parametrize("indicator,libs", [
|
||||
("SMA", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("EMA", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("RSI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("MACD", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("BBANDS",["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("ATR", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("CCI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("WILLR", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("OBV", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("ADX", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("MFI", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
("STOCH", ["ferro_ta","talib","tulipy","pandas_ta","ta","finta"]),
|
||||
])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"indicator,libs",
|
||||
[
|
||||
("SMA", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("EMA", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("RSI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("MACD", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("BBANDS", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("ATR", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("CCI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("WILLR", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("OBV", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("ADX", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("MFI", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
("STOCH", ["ferro_ta", "talib", "tulipy", "pandas_ta", "ta", "finta"]),
|
||||
],
|
||||
)
|
||||
def test_head_to_head(benchmark, indicator, libs):
|
||||
"""Benchmark ferro_ta vs all peers — for README table generation."""
|
||||
if not is_supported("ferro_ta", indicator):
|
||||
@@ -77,7 +86,11 @@ def test_head_to_head(benchmark, indicator, libs):
|
||||
|
||||
# ── Large dataset benchmarks (100k bars) ─────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("indicator", ["SMA","EMA","RSI","MACD","ATR","BBANDS","OBV","CCI","ADX","MFI"])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"indicator",
|
||||
["SMA", "EMA", "RSI", "MACD", "ATR", "BBANDS", "OBV", "CCI", "ADX", "MFI"],
|
||||
)
|
||||
def test_large_dataset(benchmark, indicator):
|
||||
"""Scaling benchmark at 100k bars for ferro_ta."""
|
||||
if not is_supported("ferro_ta", indicator):
|
||||
|
||||
+2521
-616
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -1,5 +1,5 @@
|
||||
{% set name = "ferro-ta" %}
|
||||
{% set version = "1.0.0" %}
|
||||
{% set version = "1.1.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,6 +1,6 @@
|
||||
[package]
|
||||
name = "ferro_ta_core"
|
||||
version = "1.0.1"
|
||||
version = "1.1.4"
|
||||
edition = "2021"
|
||||
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
|
||||
license = "MIT"
|
||||
@@ -17,6 +17,8 @@ crate-type = ["lib"]
|
||||
|
||||
[dependencies]
|
||||
wide = { version = "1.1.1", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
@@ -28,3 +30,4 @@ harness = false
|
||||
[features]
|
||||
wide = ["dep:wide"]
|
||||
simd = ["wide"]
|
||||
serde = ["dep:serde", "dep:serde_json"]
|
||||
|
||||
@@ -7,13 +7,13 @@ 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
|
||||
- non-Python bindings (WASM, FFI)
|
||||
|
||||
## Installation
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ferro_ta_core = "1.0.1"
|
||||
ferro_ta_core = "1.1.4"
|
||||
```
|
||||
|
||||
## Design
|
||||
@@ -25,12 +25,31 @@ ferro_ta_core = "1.0.1"
|
||||
|
||||
## Modules
|
||||
|
||||
- `overlap` - moving averages, MACD, Bollinger Bands
|
||||
- `momentum` - RSI, MOM
|
||||
- `volatility` - ATR, TRANGE
|
||||
- `volume` - OBV
|
||||
- `statistic` - STDDEV
|
||||
- `math` - rolling SUM/MAX/MIN helpers
|
||||
| Module | Functions | Highlights |
|
||||
|--------|-----------|------------|
|
||||
| `overlap` | 20 | SMA, EMA, WMA, DEMA, TEMA, TRIMA, KAMA, T3, BBANDS, MACD, MACDFIX, MACDEXT, SAR, SAREXT, MAMA, MIDPOINT, MIDPRICE, MA, MAVP, Hull MA |
|
||||
| `momentum` | 26 | RSI, MOM, STOCH, STOCHF, ADX, ADXR, DX, +DI, -DI, +DM, -DM, ROC, WILLR, AROON, CCI, BOP, STOCHRSI, APO, PPO, CMO, TRIX, ULTOSC |
|
||||
| `volatility` | 3 | ATR, NATR, TRANGE |
|
||||
| `volume` | 4 | OBV, MFI, AD, ADOSC |
|
||||
| `pattern` | 61 | All TA-Lib candlestick patterns (CDL2CROWS through CDLXSIDEGAP3METHODS) |
|
||||
| `statistic` | 9 | STDDEV, VAR, LINEARREG, LINEARREG_SLOPE/INTERCEPT/ANGLE, TSF, BETA, CORREL |
|
||||
| `math` | 24 | Rolling SUM/MAX/MIN/MAXINDEX/MININDEX, element-wise ADD/SUB/MULT/DIV, 15 transforms (trig, exp, log, sqrt, ceil, floor) |
|
||||
| `price_transform` | 4 | AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE |
|
||||
| `cycle` | 7 | Hilbert Transform: TRENDLINE, DCPERIOD, DCPHASE, PHASOR, SINE, TRENDMODE |
|
||||
| `extended` | 10 | VWAP, VWMA, Supertrend, Donchian, Keltner, Ichimoku, Pivot Points, Hull MA, Chandelier Exit, Choppiness Index |
|
||||
| `streaming` | 9 | Stateful bar-by-bar: SMA, EMA, RSI, ATR, BBands, MACD, Stoch, VWAP, Supertrend |
|
||||
| `batch` | 8 | Vectorized multi-column: batch_sma/ema/rsi/atr/stoch/adx, run_close/hlc_indicators |
|
||||
| `backtest` | 19 | Signal generators, close-only and OHLCV engines, walk-forward, Monte Carlo, performance metrics |
|
||||
| `options` | 18 | Black-Scholes/Black-76 pricing, Greeks, implied volatility, IV rank/percentile/zscore, smile metrics, chain analytics |
|
||||
| `futures` | 14 | Basis, annualized basis, carry, roll (weighted/back-adjusted/ratio), curve analysis, synthetic forward/spot |
|
||||
| `portfolio` | 10 | Beta, correlation matrix, drawdown, relative strength, spread, ratio, z-score, portfolio volatility |
|
||||
| `signals` | 4 | Rank values, compose rank, top/bottom N indices |
|
||||
| `alerts` | 3 | Threshold crossings, cross detection, alert bar collection |
|
||||
| `regime` | 4 | ADX regime, combined regime, CUSUM breaks, variance breaks |
|
||||
| `aggregation` | 3 | Tick bars, volume bars, time bars from trade data |
|
||||
| `resampling` | 2 | Volume bars, OHLCV aggregation by label |
|
||||
| `chunked` | 4 | Trim overlap, stitch chunks, make chunk ranges, forward fill NaN |
|
||||
| `crypto` | 3 | Funding cumulative PnL, continuous bar labels, session boundaries |
|
||||
|
||||
## Example
|
||||
|
||||
@@ -49,18 +68,7 @@ fn main() {
|
||||
|
||||
## 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
|
||||
The published Python package (`ferro-ta` on PyPI) wraps this crate with PyO3 bindings and adds NumPy conversion, pandas/polars wrappers, and higher-level Python tooling. The WASM package (`ferro-ta-wasm` on npm) also wraps this crate with full feature parity.
|
||||
|
||||
If you only need Rust indicator functions, use `ferro_ta_core` directly.
|
||||
|
||||
|
||||
@@ -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,340 @@
|
||||
//! Tick / Trade Aggregation Pipeline — pure Rust, no PyO3.
|
||||
//!
|
||||
//! Aggregates raw tick/trade data into OHLCV bars:
|
||||
//! - **tick bars** — fixed number of ticks per bar
|
||||
//! - **volume bars** — fixed volume threshold per bar
|
||||
//! - **time bars** — label-based grouping (labels from Python timestamps)
|
||||
|
||||
/// OHLCV 5-tuple return type alias.
|
||||
type Ohlcv5 = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>);
|
||||
|
||||
/// OHLCV 5-tuple plus labels return type alias.
|
||||
type Ohlcv5AndLabels = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<i64>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// aggregate_tick_bars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate tick/trade data into tick bars (every N ticks become one bar).
|
||||
///
|
||||
/// Returns `(open, high, low, close, volume)` where volume = sum of sizes.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `ticks_per_bar == 0`, arrays are empty, or lengths differ.
|
||||
pub fn aggregate_tick_bars(price: &[f64], size: &[f64], ticks_per_bar: usize) -> Ohlcv5 {
|
||||
assert!(ticks_per_bar >= 1, "ticks_per_bar must be >= 1");
|
||||
let n = price.len();
|
||||
assert!(
|
||||
n > 0 && size.len() == n,
|
||||
"price and size must be non-empty and equal length"
|
||||
);
|
||||
|
||||
let n_bars = n.div_ceil(ticks_per_bar);
|
||||
let mut out_open = Vec::with_capacity(n_bars);
|
||||
let mut out_high = Vec::with_capacity(n_bars);
|
||||
let mut out_low = Vec::with_capacity(n_bars);
|
||||
let mut out_close = Vec::with_capacity(n_bars);
|
||||
let mut out_vol = Vec::with_capacity(n_bars);
|
||||
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let end = (i + ticks_per_bar).min(n);
|
||||
let bar_p = &price[i..end];
|
||||
let bar_s = &size[i..end];
|
||||
let bar_open = bar_p[0];
|
||||
let bar_high = bar_p.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let bar_low = bar_p.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
let bar_close = *bar_p.last().expect("slice cannot be empty");
|
||||
let bar_vol: f64 = bar_s.iter().sum();
|
||||
out_open.push(bar_open);
|
||||
out_high.push(bar_high);
|
||||
out_low.push(bar_low);
|
||||
out_close.push(bar_close);
|
||||
out_vol.push(bar_vol);
|
||||
i = end;
|
||||
}
|
||||
|
||||
(out_open, out_high, out_low, out_close, out_vol)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// aggregate_volume_bars_ticks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate tick data into volume bars (fixed volume threshold).
|
||||
///
|
||||
/// Accumulates ticks until cumulative size >= `volume_threshold`, then emits
|
||||
/// a bar. Any remaining partial bar is also emitted.
|
||||
///
|
||||
/// Returns `(open, high, low, close, volume)`.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `volume_threshold <= 0`, arrays are empty, or lengths differ.
|
||||
pub fn aggregate_volume_bars_ticks(price: &[f64], size: &[f64], volume_threshold: f64) -> Ohlcv5 {
|
||||
assert!(volume_threshold > 0.0, "volume_threshold must be > 0");
|
||||
let n = price.len();
|
||||
assert!(
|
||||
n > 0 && size.len() == n,
|
||||
"price and size must be non-empty and equal length"
|
||||
);
|
||||
|
||||
let mut out_open: Vec<f64> = Vec::new();
|
||||
let mut out_high: Vec<f64> = Vec::new();
|
||||
let mut out_low: Vec<f64> = Vec::new();
|
||||
let mut out_close: Vec<f64> = Vec::new();
|
||||
let mut out_vol: Vec<f64> = Vec::new();
|
||||
|
||||
let mut bar_open = price[0];
|
||||
let mut bar_high = price[0];
|
||||
let mut bar_low = price[0];
|
||||
let mut bar_close = price[0];
|
||||
let mut bar_vol = size[0];
|
||||
|
||||
for i in 1..n {
|
||||
bar_high = bar_high.max(price[i]);
|
||||
bar_low = bar_low.min(price[i]);
|
||||
bar_close = price[i];
|
||||
bar_vol += size[i];
|
||||
|
||||
if bar_vol >= volume_threshold {
|
||||
out_open.push(bar_open);
|
||||
out_high.push(bar_high);
|
||||
out_low.push(bar_low);
|
||||
out_close.push(bar_close);
|
||||
out_vol.push(bar_vol);
|
||||
if i + 1 < n {
|
||||
bar_open = price[i + 1];
|
||||
bar_high = price[i + 1];
|
||||
bar_low = price[i + 1];
|
||||
bar_close = price[i + 1];
|
||||
bar_vol = size[i + 1];
|
||||
} else {
|
||||
bar_vol = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Push remaining partial bar
|
||||
if bar_vol > 0.0 {
|
||||
out_open.push(bar_open);
|
||||
out_high.push(bar_high);
|
||||
out_low.push(bar_low);
|
||||
out_close.push(bar_close);
|
||||
out_vol.push(bar_vol);
|
||||
}
|
||||
|
||||
(out_open, out_high, out_low, out_close, out_vol)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// aggregate_time_bars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate tick data into time bars using pre-computed integer bucket labels.
|
||||
///
|
||||
/// Each tick is assigned a `label` (e.g. unix_ts // period_secs). Ticks with
|
||||
/// the same label are accumulated into one bar. Labels must be non-decreasing.
|
||||
///
|
||||
/// Returns `(open, high, low, close, volume, unique_labels)`.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if arrays are empty or have unequal lengths.
|
||||
pub fn aggregate_time_bars(price: &[f64], size: &[f64], labels: &[i64]) -> Ohlcv5AndLabels {
|
||||
let n = price.len();
|
||||
assert!(
|
||||
n > 0 && size.len() == n && labels.len() == n,
|
||||
"price, size, and labels must be non-empty and equal length"
|
||||
);
|
||||
|
||||
let mut out_open: Vec<f64> = Vec::new();
|
||||
let mut out_high: Vec<f64> = Vec::new();
|
||||
let mut out_low: Vec<f64> = Vec::new();
|
||||
let mut out_close: Vec<f64> = Vec::new();
|
||||
let mut out_vol: Vec<f64> = Vec::new();
|
||||
let mut out_labels: Vec<i64> = Vec::new();
|
||||
|
||||
let mut cur_label = labels[0];
|
||||
let mut bar_open = price[0];
|
||||
let mut bar_high = price[0];
|
||||
let mut bar_low = price[0];
|
||||
let mut bar_close = price[0];
|
||||
let mut bar_vol = size[0];
|
||||
|
||||
for i in 1..n {
|
||||
if labels[i] != cur_label {
|
||||
out_open.push(bar_open);
|
||||
out_high.push(bar_high);
|
||||
out_low.push(bar_low);
|
||||
out_close.push(bar_close);
|
||||
out_vol.push(bar_vol);
|
||||
out_labels.push(cur_label);
|
||||
cur_label = labels[i];
|
||||
bar_open = price[i];
|
||||
bar_high = price[i];
|
||||
bar_low = price[i];
|
||||
bar_close = price[i];
|
||||
bar_vol = size[i];
|
||||
} else {
|
||||
bar_high = bar_high.max(price[i]);
|
||||
bar_low = bar_low.min(price[i]);
|
||||
bar_close = price[i];
|
||||
bar_vol += size[i];
|
||||
}
|
||||
}
|
||||
out_open.push(bar_open);
|
||||
out_high.push(bar_high);
|
||||
out_low.push(bar_low);
|
||||
out_close.push(bar_close);
|
||||
out_vol.push(bar_vol);
|
||||
out_labels.push(cur_label);
|
||||
|
||||
(out_open, out_high, out_low, out_close, out_vol, out_labels)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- aggregate_tick_bars -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_tick_bars_exact_division() {
|
||||
let price = [10.0, 11.0, 12.0, 13.0, 14.0, 15.0];
|
||||
let size = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
|
||||
let (o, h, l, c, v) = aggregate_tick_bars(&price, &size, 3);
|
||||
assert_eq!(o.len(), 2);
|
||||
// Bar 0: ticks 0..3
|
||||
assert!((o[0] - 10.0).abs() < 1e-10);
|
||||
assert!((h[0] - 12.0).abs() < 1e-10);
|
||||
assert!((l[0] - 10.0).abs() < 1e-10);
|
||||
assert!((c[0] - 12.0).abs() < 1e-10);
|
||||
assert!((v[0] - 6.0).abs() < 1e-10);
|
||||
// Bar 1: ticks 3..6
|
||||
assert!((o[1] - 13.0).abs() < 1e-10);
|
||||
assert!((h[1] - 15.0).abs() < 1e-10);
|
||||
assert!((l[1] - 13.0).abs() < 1e-10);
|
||||
assert!((c[1] - 15.0).abs() < 1e-10);
|
||||
assert!((v[1] - 15.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tick_bars_partial_last_bar() {
|
||||
let price = [10.0, 11.0, 12.0, 13.0, 14.0];
|
||||
let size = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let (o, _h, _l, c, v) = aggregate_tick_bars(&price, &size, 3);
|
||||
assert_eq!(o.len(), 2);
|
||||
// Partial bar: ticks 3..5
|
||||
assert!((o[1] - 13.0).abs() < 1e-10);
|
||||
assert!((c[1] - 14.0).abs() < 1e-10);
|
||||
assert!((v[1] - 9.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tick_bars_single_tick() {
|
||||
let (o, h, l, c, v) = aggregate_tick_bars(&[42.0], &[100.0], 5);
|
||||
assert_eq!(o.len(), 1);
|
||||
assert!((o[0] - 42.0).abs() < 1e-10);
|
||||
assert!((h[0] - 42.0).abs() < 1e-10);
|
||||
assert!((l[0] - 42.0).abs() < 1e-10);
|
||||
assert!((c[0] - 42.0).abs() < 1e-10);
|
||||
assert!((v[0] - 100.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "ticks_per_bar must be >= 1")]
|
||||
fn test_tick_bars_zero_ticks() {
|
||||
aggregate_tick_bars(&[1.0], &[1.0], 0);
|
||||
}
|
||||
|
||||
// -- aggregate_volume_bars_ticks -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_volume_bars_ticks_basic() {
|
||||
let price = [10.0, 11.0, 12.0, 13.0, 14.0];
|
||||
let size = [30.0, 40.0, 50.0, 20.0, 60.0];
|
||||
// threshold=70: bar0 = ticks 0+1 (vol=70), bar1 = tick2 (vol=50) + tick3 (vol=70),
|
||||
// then tick4 as partial
|
||||
let (o, h, l, c, v) = aggregate_volume_bars_ticks(&price, &size, 70.0);
|
||||
// First bar: 30+40=70 >= 70
|
||||
assert!((o[0] - 10.0).abs() < 1e-10);
|
||||
assert!((c[0] - 11.0).abs() < 1e-10);
|
||||
assert!((v[0] - 70.0).abs() < 1e-10);
|
||||
assert!((h[0] - 11.0).abs() < 1e-10);
|
||||
assert!((l[0] - 10.0).abs() < 1e-10);
|
||||
assert!(v.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_bars_ticks_single() {
|
||||
let (o, _h, _l, _c, v) = aggregate_volume_bars_ticks(&[5.0], &[10.0], 100.0);
|
||||
assert_eq!(o.len(), 1);
|
||||
assert!((v[0] - 10.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "volume_threshold must be > 0")]
|
||||
fn test_volume_bars_ticks_zero_threshold() {
|
||||
aggregate_volume_bars_ticks(&[1.0], &[1.0], 0.0);
|
||||
}
|
||||
|
||||
// -- aggregate_time_bars -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_time_bars_basic() {
|
||||
let price = [10.0, 11.0, 12.0, 13.0, 14.0];
|
||||
let size = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let labels: [i64; 5] = [0, 0, 1, 1, 1];
|
||||
let (o, h, l, c, v, out_lbl) = aggregate_time_bars(&price, &size, &labels);
|
||||
assert_eq!(o.len(), 2);
|
||||
assert_eq!(out_lbl, vec![0, 1]);
|
||||
// Group 0: ticks 0,1
|
||||
assert!((o[0] - 10.0).abs() < 1e-10);
|
||||
assert!((h[0] - 11.0).abs() < 1e-10);
|
||||
assert!((l[0] - 10.0).abs() < 1e-10);
|
||||
assert!((c[0] - 11.0).abs() < 1e-10);
|
||||
assert!((v[0] - 3.0).abs() < 1e-10);
|
||||
// Group 1: ticks 2,3,4
|
||||
assert!((o[1] - 12.0).abs() < 1e-10);
|
||||
assert!((h[1] - 14.0).abs() < 1e-10);
|
||||
assert!((l[1] - 12.0).abs() < 1e-10);
|
||||
assert!((c[1] - 14.0).abs() < 1e-10);
|
||||
assert!((v[1] - 12.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_bars_all_same_label() {
|
||||
let price = [5.0, 6.0, 4.0];
|
||||
let size = [10.0, 20.0, 30.0];
|
||||
let labels: [i64; 3] = [42, 42, 42];
|
||||
let (o, h, l, c, v, out_lbl) = aggregate_time_bars(&price, &size, &labels);
|
||||
assert_eq!(o.len(), 1);
|
||||
assert_eq!(out_lbl, vec![42]);
|
||||
assert!((o[0] - 5.0).abs() < 1e-10);
|
||||
assert!((h[0] - 6.0).abs() < 1e-10);
|
||||
assert!((l[0] - 4.0).abs() < 1e-10);
|
||||
assert!((c[0] - 4.0).abs() < 1e-10);
|
||||
assert!((v[0] - 60.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_time_bars_each_tick_own_label() {
|
||||
let price = [10.0, 20.0, 30.0];
|
||||
let size = [1.0, 2.0, 3.0];
|
||||
let labels: [i64; 3] = [0, 1, 2];
|
||||
let (o, _h, _l, _c, v, out_lbl) = aggregate_time_bars(&price, &size, &labels);
|
||||
assert_eq!(o.len(), 3);
|
||||
assert_eq!(out_lbl, vec![0, 1, 2]);
|
||||
assert!((v[0] - 1.0).abs() < 1e-10);
|
||||
assert!((v[1] - 2.0).abs() < 1e-10);
|
||||
assert!((v[2] - 3.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "price, size, and labels must be non-empty and equal length")]
|
||||
fn test_time_bars_empty() {
|
||||
aggregate_time_bars(&[], &[], &[]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Alerts — condition evaluation helpers.
|
||||
//!
|
||||
//! - `check_threshold` — fires when a series crosses above/below a level
|
||||
//! - `check_cross` — fires when *fast* crosses above or below *slow*
|
||||
//! - `collect_alert_bars` — returns indices of bars where a mask is non-zero
|
||||
|
||||
/// Fire an alert when `series` crosses a threshold level.
|
||||
///
|
||||
/// `direction`: `1` = cross above, `-1` = cross below.
|
||||
///
|
||||
/// Returns a `Vec<i8>` with `1` at crossing bars, `0` elsewhere.
|
||||
/// Element 0 is always 0.
|
||||
pub fn check_threshold(series: &[f64], level: f64, direction: i32) -> Vec<i8> {
|
||||
let n = series.len();
|
||||
let mut out = vec![0i8; n];
|
||||
if n < 2 {
|
||||
return out;
|
||||
}
|
||||
for i in 1..n {
|
||||
let prev = series[i - 1];
|
||||
let curr = series[i];
|
||||
if prev.is_nan() || curr.is_nan() {
|
||||
continue;
|
||||
}
|
||||
if (direction == 1 && prev <= level && curr > level)
|
||||
|| (direction == -1 && prev >= level && curr < level)
|
||||
{
|
||||
out[i] = 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Detect cross-over / cross-under events between two series.
|
||||
///
|
||||
/// Returns `Vec<i8>`: `1` = bullish cross (fast above slow), `-1` = bearish, `0` = none.
|
||||
/// Element 0 is always 0.
|
||||
pub fn check_cross(fast: &[f64], slow: &[f64]) -> Vec<i8> {
|
||||
let n = fast.len();
|
||||
let mut out = vec![0i8; n];
|
||||
if n < 2 {
|
||||
return out;
|
||||
}
|
||||
for i in 1..n {
|
||||
let fp = fast[i - 1];
|
||||
let fc = fast[i];
|
||||
let sp = slow[i - 1];
|
||||
let sc = slow[i];
|
||||
if fp.is_nan() || fc.is_nan() || sp.is_nan() || sc.is_nan() {
|
||||
continue;
|
||||
}
|
||||
if fp <= sp && fc > sc {
|
||||
out[i] = 1;
|
||||
} else if fp >= sp && fc < sc {
|
||||
out[i] = -1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Collect bar indices where `mask` is non-zero.
|
||||
pub fn collect_alert_bars(mask: &[i8]) -> Vec<i64> {
|
||||
mask.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &v)| v != 0)
|
||||
.map(|(i, _)| i as i64)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_check_threshold_cross_above() {
|
||||
let series = vec![10.0, 20.0, 30.0, 40.0, 50.0];
|
||||
let result = check_threshold(&series, 25.0, 1);
|
||||
assert_eq!(result, vec![0, 0, 1, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_threshold_cross_below() {
|
||||
let series = vec![50.0, 40.0, 30.0, 20.0, 10.0];
|
||||
let result = check_threshold(&series, 25.0, -1);
|
||||
assert_eq!(result, vec![0, 0, 0, 1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_cross_bullish() {
|
||||
let fast = vec![1.0, 2.0, 5.0];
|
||||
let slow = vec![3.0, 3.0, 3.0];
|
||||
let result = check_cross(&fast, &slow);
|
||||
assert_eq!(result, vec![0, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_cross_bearish() {
|
||||
let fast = vec![5.0, 4.0, 1.0];
|
||||
let slow = vec![3.0, 3.0, 3.0];
|
||||
let result = check_cross(&fast, &slow);
|
||||
assert_eq!(result, vec![0, 0, -1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_alert_bars() {
|
||||
let mask = vec![0i8, 1, 0, -1, 0, 1];
|
||||
let result = collect_alert_bars(&mask);
|
||||
assert_eq!(result, vec![1, 3, 5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty() {
|
||||
assert_eq!(check_threshold(&[], 0.0, 1), Vec::<i8>::new());
|
||||
assert_eq!(check_cross(&[], &[]), Vec::<i8>::new());
|
||||
assert_eq!(collect_alert_bars(&[]), Vec::<i64>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nan_handling() {
|
||||
let series = vec![10.0, f64::NAN, 30.0, 40.0];
|
||||
let result = check_threshold(&series, 25.0, 1);
|
||||
// NaN bars are skipped
|
||||
assert_eq!(result[1], 0);
|
||||
assert_eq!(result[2], 0); // prev is NaN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Performance attribution and trade analysis — pure Rust, no PyO3.
|
||||
//!
|
||||
//! Functions
|
||||
//! ---------
|
||||
//! - `trade_stats` — win rate, avg win/loss, profit factor, avg hold
|
||||
//! - `monthly_contribution` — group bar returns by month index and sum
|
||||
//! - `signal_attribution` — group bar returns by signal label and sum
|
||||
//! - `extract_trades` — extract trade pnl and hold durations from positions
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// trade_stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute trade-level statistics from trade PnL and hold durations.
|
||||
///
|
||||
/// Returns `(win_rate, avg_win, avg_loss, profit_factor, avg_hold_bars)`.
|
||||
///
|
||||
/// - **win_rate** : fraction of trades with PnL > 0
|
||||
/// - **avg_win** : mean PnL of winning trades (0 if none)
|
||||
/// - **avg_loss** : mean PnL of losing trades (negative; 0 if none)
|
||||
/// - **profit_factor** : gross profit / |gross loss| (inf if no losses)
|
||||
/// - **avg_hold_bars** : mean hold duration across all trades
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `pnl` is empty or `pnl.len() != hold_bars.len()`.
|
||||
pub fn trade_stats(pnl: &[f64], hold_bars: &[f64]) -> (f64, f64, f64, f64, f64) {
|
||||
let n = pnl.len();
|
||||
assert!(n > 0, "pnl must be non-empty");
|
||||
assert_eq!(
|
||||
n,
|
||||
hold_bars.len(),
|
||||
"pnl and hold_bars must have equal length"
|
||||
);
|
||||
|
||||
let mut wins: Vec<f64> = Vec::new();
|
||||
let mut losses: Vec<f64> = Vec::new();
|
||||
for &v in pnl.iter() {
|
||||
if v > 0.0 {
|
||||
wins.push(v);
|
||||
} else if v < 0.0 {
|
||||
losses.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
let win_rate = wins.len() as f64 / n as f64;
|
||||
let avg_win = if wins.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
wins.iter().sum::<f64>() / wins.len() as f64
|
||||
};
|
||||
let avg_loss = if losses.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
losses.iter().sum::<f64>() / losses.len() as f64
|
||||
};
|
||||
|
||||
let gross_profit: f64 = wins.iter().sum();
|
||||
let gross_loss: f64 = losses.iter().map(|v| v.abs()).sum();
|
||||
let profit_factor = if gross_loss == 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
gross_profit / gross_loss
|
||||
};
|
||||
|
||||
let avg_hold = hold_bars.iter().sum::<f64>() / n as f64;
|
||||
|
||||
(win_rate, avg_win, avg_loss, profit_factor, avg_hold)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// monthly_contribution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Group per-bar returns by month index and sum each month's contribution.
|
||||
///
|
||||
/// Returns `(months, contributions)` where `months` is sorted unique month
|
||||
/// indices and `contributions` is the corresponding total return per month.
|
||||
/// NaN returns are skipped.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `bar_returns.len() != month_index.len()`.
|
||||
pub fn monthly_contribution(bar_returns: &[f64], month_index: &[i64]) -> (Vec<i64>, Vec<f64>) {
|
||||
let n = bar_returns.len();
|
||||
assert_eq!(
|
||||
n,
|
||||
month_index.len(),
|
||||
"bar_returns and month_index must have equal length"
|
||||
);
|
||||
|
||||
let mut map: HashMap<i64, f64> = HashMap::new();
|
||||
for i in 0..n {
|
||||
if !bar_returns[i].is_nan() {
|
||||
*map.entry(month_index[i]).or_insert(0.0) += bar_returns[i];
|
||||
}
|
||||
}
|
||||
|
||||
let mut months: Vec<i64> = map.keys().copied().collect();
|
||||
months.sort_unstable();
|
||||
let contributions: Vec<f64> = months.iter().map(|m| map[m]).collect();
|
||||
|
||||
(months, contributions)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// signal_attribution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Attribute per-bar returns to each signal label.
|
||||
///
|
||||
/// Returns `(labels, contributions)` where `labels` is sorted unique signal
|
||||
/// labels and `contributions` is the corresponding total return per label.
|
||||
/// NaN returns are skipped.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `bar_returns.len() != signal_labels.len()`.
|
||||
pub fn signal_attribution(bar_returns: &[f64], signal_labels: &[i64]) -> (Vec<i64>, Vec<f64>) {
|
||||
let n = bar_returns.len();
|
||||
assert_eq!(
|
||||
n,
|
||||
signal_labels.len(),
|
||||
"bar_returns and signal_labels must have equal length"
|
||||
);
|
||||
|
||||
let mut map: HashMap<i64, f64> = HashMap::new();
|
||||
for i in 0..n {
|
||||
if !bar_returns[i].is_nan() {
|
||||
*map.entry(signal_labels[i]).or_insert(0.0) += bar_returns[i];
|
||||
}
|
||||
}
|
||||
|
||||
let mut labels: Vec<i64> = map.keys().copied().collect();
|
||||
labels.sort_unstable();
|
||||
let contributions: Vec<f64> = labels.iter().map(|l| map[l]).collect();
|
||||
|
||||
(labels, contributions)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extract_trades
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract trade-level PnL and hold durations from positions and strategy returns.
|
||||
///
|
||||
/// A trade is a maximal contiguous run of non-zero position values with the
|
||||
/// same sign/magnitude. Returns `(pnl, hold_durations)`.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `positions.len() != strategy_returns.len()`.
|
||||
pub fn extract_trades(positions: &[f64], strategy_returns: &[f64]) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = positions.len();
|
||||
assert_eq!(
|
||||
n,
|
||||
strategy_returns.len(),
|
||||
"positions and strategy_returns must have equal length"
|
||||
);
|
||||
|
||||
let mut pnl = Vec::<f64>::new();
|
||||
let mut hold = Vec::<f64>::new();
|
||||
|
||||
let mut i = 0usize;
|
||||
while i < n {
|
||||
if positions[i] == 0.0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i + 1;
|
||||
while j < n && positions[j] == positions[i] {
|
||||
j += 1;
|
||||
}
|
||||
let mut trade_pnl = 0.0_f64;
|
||||
for v in strategy_returns.iter().take(j).skip(i) {
|
||||
trade_pnl += *v;
|
||||
}
|
||||
pnl.push(trade_pnl);
|
||||
hold.push((j - i) as f64);
|
||||
i = j;
|
||||
}
|
||||
|
||||
(pnl, hold)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- trade_stats ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_trade_stats_basic() {
|
||||
let pnl = [100.0, -50.0, 200.0, -30.0, 150.0];
|
||||
let hold = [5.0, 3.0, 7.0, 2.0, 6.0];
|
||||
let (wr, aw, al, pf, ah) = trade_stats(&pnl, &hold);
|
||||
|
||||
// 3 wins out of 5
|
||||
assert!((wr - 0.6).abs() < 1e-10);
|
||||
// avg win = (100+200+150)/3
|
||||
assert!((aw - 150.0).abs() < 1e-10);
|
||||
// avg loss = (-50 + -30)/2 = -40
|
||||
assert!((al - (-40.0)).abs() < 1e-10);
|
||||
// profit_factor = 450 / 80
|
||||
assert!((pf - 5.625).abs() < 1e-10);
|
||||
// avg hold = (5+3+7+2+6)/5 = 4.6
|
||||
assert!((ah - 4.6).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trade_stats_all_wins() {
|
||||
let pnl = [10.0, 20.0];
|
||||
let hold = [1.0, 2.0];
|
||||
let (wr, _aw, al, pf, _ah) = trade_stats(&pnl, &hold);
|
||||
assert!((wr - 1.0).abs() < 1e-10);
|
||||
assert!((al - 0.0).abs() < 1e-10);
|
||||
assert!(pf.is_infinite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trade_stats_all_losses() {
|
||||
let pnl = [-10.0, -20.0];
|
||||
let hold = [1.0, 2.0];
|
||||
let (wr, aw, _al, pf, _ah) = trade_stats(&pnl, &hold);
|
||||
assert!((wr - 0.0).abs() < 1e-10);
|
||||
assert!((aw - 0.0).abs() < 1e-10);
|
||||
assert!((pf - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "pnl must be non-empty")]
|
||||
fn test_trade_stats_empty() {
|
||||
trade_stats(&[], &[]);
|
||||
}
|
||||
|
||||
// -- monthly_contribution ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_monthly_contribution_basic() {
|
||||
let returns = [0.01, 0.02, -0.01, 0.03, -0.02];
|
||||
let months = [0, 0, 1, 1, 2];
|
||||
let (m, c) = monthly_contribution(&returns, &months);
|
||||
assert_eq!(m, vec![0, 1, 2]);
|
||||
assert!((c[0] - 0.03).abs() < 1e-10);
|
||||
assert!((c[1] - 0.02).abs() < 1e-10);
|
||||
assert!((c[2] - (-0.02)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_monthly_contribution_nan_skipped() {
|
||||
let returns = [0.01, f64::NAN, 0.03];
|
||||
let months = [0, 0, 1];
|
||||
let (m, c) = monthly_contribution(&returns, &months);
|
||||
assert_eq!(m, vec![0, 1]);
|
||||
assert!((c[0] - 0.01).abs() < 1e-10);
|
||||
assert!((c[1] - 0.03).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_monthly_contribution_empty() {
|
||||
let (m, c) = monthly_contribution(&[], &[]);
|
||||
assert!(m.is_empty());
|
||||
assert!(c.is_empty());
|
||||
}
|
||||
|
||||
// -- signal_attribution --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_signal_attribution_basic() {
|
||||
let returns = [0.05, -0.02, 0.03, 0.01];
|
||||
let labels = [1, -1, 2, 1];
|
||||
let (l, c) = signal_attribution(&returns, &labels);
|
||||
assert_eq!(l, vec![-1, 1, 2]);
|
||||
assert!((c[0] - (-0.02)).abs() < 1e-10);
|
||||
assert!((c[1] - 0.06).abs() < 1e-10); // 0.05 + 0.01
|
||||
assert!((c[2] - 0.03).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signal_attribution_nan_skipped() {
|
||||
let returns = [0.05, f64::NAN];
|
||||
let labels = [1, 2];
|
||||
let (l, c) = signal_attribution(&returns, &labels);
|
||||
assert_eq!(l, vec![1]);
|
||||
assert!((c[0] - 0.05).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// -- extract_trades ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_extract_trades_basic() {
|
||||
// positions: flat, long, long, flat, short, short
|
||||
let positions = [0.0, 1.0, 1.0, 0.0, -1.0, -1.0];
|
||||
let strat_ret = [0.0, 0.01, 0.02, 0.0, -0.01, 0.03];
|
||||
let (pnl, hold) = extract_trades(&positions, &strat_ret);
|
||||
assert_eq!(pnl.len(), 2);
|
||||
assert_eq!(hold.len(), 2);
|
||||
// First trade: bars 1..3 => 0.01 + 0.02 = 0.03
|
||||
assert!((pnl[0] - 0.03).abs() < 1e-10);
|
||||
assert!((hold[0] - 2.0).abs() < 1e-10);
|
||||
// Second trade: bars 4..6 => -0.01 + 0.03 = 0.02
|
||||
assert!((pnl[1] - 0.02).abs() < 1e-10);
|
||||
assert!((hold[1] - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_trades_all_flat() {
|
||||
let positions = [0.0, 0.0, 0.0];
|
||||
let strat_ret = [0.01, 0.02, 0.03];
|
||||
let (pnl, hold) = extract_trades(&positions, &strat_ret);
|
||||
assert!(pnl.is_empty());
|
||||
assert!(hold.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_trades_empty() {
|
||||
let (pnl, hold) = extract_trades(&[], &[]);
|
||||
assert!(pnl.is_empty());
|
||||
assert!(hold.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_trades_single_bar_trade() {
|
||||
let positions = [0.0, 1.0, 0.0];
|
||||
let strat_ret = [0.0, 0.05, 0.0];
|
||||
let (pnl, hold) = extract_trades(&positions, &strat_ret);
|
||||
assert_eq!(pnl.len(), 1);
|
||||
assert!((pnl[0] - 0.05).abs() < 1e-10);
|
||||
assert!((hold[0] - 1.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,641 @@
|
||||
//! Pure-Rust batch operations — apply indicators across multiple series
|
||||
//! (columns) sequentially. The PyO3 wrapper can add Rayon parallelism on top.
|
||||
//!
|
||||
//! Input convention: `data[j]` is column *j* (one time-series). All columns
|
||||
//! must have the same length.
|
||||
|
||||
use crate::{momentum, overlap, statistic, volatility};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Validate that every column in `data` has the same length. Returns `Ok(n)`
|
||||
/// where `n` is the common length, or `Err` with a message.
|
||||
fn validate_columns(data: &[Vec<f64>]) -> Result<usize, String> {
|
||||
if data.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let n = data[0].len();
|
||||
for (idx, col) in data.iter().enumerate() {
|
||||
if col.len() != n {
|
||||
return Err(format!(
|
||||
"column 0 has length {n}, but column {idx} has length {}",
|
||||
col.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn validate_hlc_columns(
|
||||
high: &[Vec<f64>],
|
||||
low: &[Vec<f64>],
|
||||
close: &[Vec<f64>],
|
||||
) -> Result<(usize, usize), String> {
|
||||
let n_series = high.len();
|
||||
if low.len() != n_series || close.len() != n_series {
|
||||
return Err(format!(
|
||||
"high has {} columns, low has {}, close has {} — must be equal",
|
||||
n_series,
|
||||
low.len(),
|
||||
close.len()
|
||||
));
|
||||
}
|
||||
if n_series == 0 {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
let n = high[0].len();
|
||||
for (idx, (h, (l, c))) in high.iter().zip(low.iter().zip(close.iter())).enumerate() {
|
||||
if h.len() != n || l.len() != n || c.len() != n {
|
||||
return Err(format!(
|
||||
"column {idx}: high len={}, low len={}, close len={} — must all be {n}",
|
||||
h.len(),
|
||||
l.len(),
|
||||
c.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok((n, n_series))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rolling linear regression (self-contained so core has no PyO3 dep)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn linreg(window: &[f64]) -> (f64, f64) {
|
||||
let n = window.len() as f64;
|
||||
let sum_x: f64 = (0..window.len()).map(|i| i as f64).sum();
|
||||
let sum_y: f64 = window.iter().sum();
|
||||
let sum_xy: f64 = window.iter().enumerate().map(|(i, &y)| i as f64 * y).sum();
|
||||
let sum_x2: f64 = (0..window.len()).map(|i| (i as f64).powi(2)).sum();
|
||||
let denom = n * sum_x2 - sum_x * sum_x;
|
||||
let slope = if denom != 0.0 {
|
||||
(n * sum_xy - sum_x * sum_y) / denom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let intercept = (sum_y - slope * sum_x) / n;
|
||||
(slope, intercept)
|
||||
}
|
||||
|
||||
fn rolling_linreg_apply<F>(prices: &[f64], timeperiod: usize, mut map: F) -> Vec<f64>
|
||||
where
|
||||
F: FnMut(f64, f64) -> f64,
|
||||
{
|
||||
let n = prices.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
|
||||
if prices.iter().any(|value| !value.is_finite()) {
|
||||
for end in (timeperiod - 1)..n {
|
||||
let window = &prices[(end + 1 - timeperiod)..=end];
|
||||
let (slope, intercept) = linreg(window);
|
||||
result[end] = map(slope, intercept);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let period = timeperiod as f64;
|
||||
let last_x = (timeperiod - 1) as f64;
|
||||
let sum_x = last_x * period / 2.0;
|
||||
let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0;
|
||||
let denom = period * sum_x2 - sum_x * sum_x;
|
||||
|
||||
let mut sum_y = prices[..timeperiod].iter().sum::<f64>();
|
||||
let mut sum_xy = prices[..timeperiod]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, &value)| idx as f64 * value)
|
||||
.sum::<f64>();
|
||||
|
||||
for end in (timeperiod - 1)..n {
|
||||
let slope = if denom != 0.0 {
|
||||
(period * sum_xy - sum_x * sum_y) / denom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let intercept = (sum_y - slope * sum_x) / period;
|
||||
result[end] = map(slope, intercept);
|
||||
|
||||
if end + 1 < n {
|
||||
let outgoing = prices[end + 1 - timeperiod];
|
||||
let incoming = prices[end + 1];
|
||||
let prev_sum_y = sum_y;
|
||||
|
||||
sum_y = prev_sum_y - outgoing + incoming;
|
||||
sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CCI / WILLR helpers (no external dep)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
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) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Use simple sliding-window max/min
|
||||
for end in (timeperiod - 1)..n {
|
||||
let start = end + 1 - timeperiod;
|
||||
let mut highest = f64::NEG_INFINITY;
|
||||
let mut lowest = f64::INFINITY;
|
||||
for i in start..=end {
|
||||
if high[i] > highest {
|
||||
highest = high[i];
|
||||
}
|
||||
if low[i] < lowest {
|
||||
lowest = low[i];
|
||||
}
|
||||
}
|
||||
let range = highest - lowest;
|
||||
result[end] = if range != 0.0 {
|
||||
-100.0 * (highest - close[end]) / range
|
||||
} else {
|
||||
-50.0
|
||||
};
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch_sma
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply SMA to each column. Returns one output column per input column.
|
||||
pub fn batch_sma(data: &[Vec<f64>], timeperiod: usize) -> Result<Vec<Vec<f64>>, String> {
|
||||
if timeperiod == 0 {
|
||||
return Err("timeperiod must be >= 1".into());
|
||||
}
|
||||
validate_columns(data)?;
|
||||
Ok(data
|
||||
.iter()
|
||||
.map(|col| overlap::sma(col, timeperiod))
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch_ema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply EMA to each column.
|
||||
pub fn batch_ema(data: &[Vec<f64>], timeperiod: usize) -> Result<Vec<Vec<f64>>, String> {
|
||||
if timeperiod == 0 {
|
||||
return Err("timeperiod must be >= 1".into());
|
||||
}
|
||||
validate_columns(data)?;
|
||||
Ok(data
|
||||
.iter()
|
||||
.map(|col| overlap::ema(col, timeperiod))
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch_rsi
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply RSI to each column.
|
||||
pub fn batch_rsi(data: &[Vec<f64>], timeperiod: usize) -> Result<Vec<Vec<f64>>, String> {
|
||||
if timeperiod == 0 {
|
||||
return Err("timeperiod must be >= 1".into());
|
||||
}
|
||||
validate_columns(data)?;
|
||||
Ok(data
|
||||
.iter()
|
||||
.map(|col| momentum::rsi(col, timeperiod))
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch_atr
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply ATR to each set of (high, low, close) columns.
|
||||
pub fn batch_atr(
|
||||
high: &[Vec<f64>],
|
||||
low: &[Vec<f64>],
|
||||
close: &[Vec<f64>],
|
||||
timeperiod: usize,
|
||||
) -> Result<Vec<Vec<f64>>, String> {
|
||||
if timeperiod == 0 {
|
||||
return Err("timeperiod must be >= 1".into());
|
||||
}
|
||||
validate_hlc_columns(high, low, close)?;
|
||||
Ok((0..high.len())
|
||||
.map(|i| volatility::atr(&high[i], &low[i], &close[i], timeperiod))
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch_stoch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply Stochastic to each set of (high, low, close) columns.
|
||||
/// Returns `(slowk_columns, slowd_columns)`.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn batch_stoch(
|
||||
high: &[Vec<f64>],
|
||||
low: &[Vec<f64>],
|
||||
close: &[Vec<f64>],
|
||||
fastk_period: usize,
|
||||
slowk_period: usize,
|
||||
slowd_period: usize,
|
||||
) -> Result<(Vec<Vec<f64>>, Vec<Vec<f64>>), String> {
|
||||
validate_hlc_columns(high, low, close)?;
|
||||
let mut all_k = Vec::with_capacity(high.len());
|
||||
let mut all_d = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let (k, d) = momentum::stoch(
|
||||
&high[i],
|
||||
&low[i],
|
||||
&close[i],
|
||||
fastk_period,
|
||||
slowk_period,
|
||||
slowd_period,
|
||||
);
|
||||
all_k.push(k);
|
||||
all_d.push(d);
|
||||
}
|
||||
Ok((all_k, all_d))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// batch_adx
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply ADX to each set of (high, low, close) columns.
|
||||
pub fn batch_adx(
|
||||
high: &[Vec<f64>],
|
||||
low: &[Vec<f64>],
|
||||
close: &[Vec<f64>],
|
||||
timeperiod: usize,
|
||||
) -> Result<Vec<Vec<f64>>, String> {
|
||||
if timeperiod == 0 {
|
||||
return Err("timeperiod must be >= 1".into());
|
||||
}
|
||||
validate_hlc_columns(high, low, close)?;
|
||||
Ok((0..high.len())
|
||||
.map(|i| momentum::adx(&high[i], &low[i], &close[i], timeperiod))
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// run_close_indicators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn validate_indicator_requests(names: &[String], timeperiods: &[usize]) -> Result<(), String> {
|
||||
if names.len() != timeperiods.len() {
|
||||
return Err(format!(
|
||||
"names length ({}) must equal timeperiods length ({})",
|
||||
names.len(),
|
||||
timeperiods.len()
|
||||
));
|
||||
}
|
||||
for (name, &tp) in names.iter().zip(timeperiods.iter()) {
|
||||
if tp == 0 {
|
||||
return Err(format!("{name}: timeperiod must be >= 1"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_close_indicator(
|
||||
name: &str,
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
) -> Result<Vec<f64>, String> {
|
||||
match name {
|
||||
"SMA" => Ok(overlap::sma(close, timeperiod)),
|
||||
"EMA" => Ok(overlap::ema(close, timeperiod)),
|
||||
"RSI" => Ok(momentum::rsi(close, timeperiod)),
|
||||
"STDDEV" => Ok(statistic::stddev(close, timeperiod, 1.0)),
|
||||
"VAR" => Ok(statistic::stddev(close, timeperiod, 1.0)
|
||||
.into_iter()
|
||||
.map(|v| if v.is_nan() { v } else { v * v })
|
||||
.collect()),
|
||||
"LINEARREG" => {
|
||||
let last_x = (timeperiod - 1) as f64;
|
||||
Ok(rolling_linreg_apply(
|
||||
close,
|
||||
timeperiod,
|
||||
|slope, intercept| intercept + slope * last_x,
|
||||
))
|
||||
}
|
||||
"LINEARREG_SLOPE" => Ok(rolling_linreg_apply(close, timeperiod, |slope, _| slope)),
|
||||
"LINEARREG_INTERCEPT" => Ok(rolling_linreg_apply(close, timeperiod, |_, intercept| {
|
||||
intercept
|
||||
})),
|
||||
"LINEARREG_ANGLE" => Ok(rolling_linreg_apply(close, timeperiod, |slope, _| {
|
||||
slope.atan() * 180.0 / std::f64::consts::PI
|
||||
})),
|
||||
"TSF" => {
|
||||
let forecast_x = timeperiod as f64;
|
||||
Ok(rolling_linreg_apply(
|
||||
close,
|
||||
timeperiod,
|
||||
|slope, intercept| intercept + slope * forecast_x,
|
||||
))
|
||||
}
|
||||
_ => Err(format!(
|
||||
"unsupported close indicator for grouped execution: {name}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run multiple close-only indicators on the same series.
|
||||
/// Returns `Vec<Result<Vec<f64>, String>>` — one result per (name, timeperiod) pair.
|
||||
pub fn run_close_indicators(
|
||||
close: &[f64],
|
||||
names: &[String],
|
||||
timeperiods: &[usize],
|
||||
) -> Result<Vec<Vec<f64>>, String> {
|
||||
validate_indicator_requests(names, timeperiods)?;
|
||||
let mut results = Vec::with_capacity(names.len());
|
||||
for (name, &tp) in names.iter().zip(timeperiods.iter()) {
|
||||
results.push(compute_close_indicator(name, close, tp)?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// run_hlc_indicators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn compute_hlc_indicator(
|
||||
name: &str,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
) -> Result<Vec<f64>, String> {
|
||||
match name {
|
||||
"ATR" => Ok(volatility::atr(high, low, close, timeperiod)),
|
||||
"NATR" => {
|
||||
let atr_vals = volatility::atr(high, low, close, timeperiod);
|
||||
Ok(atr_vals
|
||||
.into_iter()
|
||||
.zip(close.iter())
|
||||
.map(|(a, &c)| {
|
||||
if a.is_nan() || c == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
(a / c) * 100.0
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
"ADX" => Ok(momentum::adx(high, low, close, timeperiod)),
|
||||
"ADXR" => Ok(momentum::adxr(high, low, close, timeperiod)),
|
||||
"CCI" => Ok(compute_cci(high, low, close, timeperiod)),
|
||||
"WILLR" => Ok(compute_willr(high, low, close, timeperiod)),
|
||||
_ => Err(format!(
|
||||
"unsupported HLC indicator for grouped execution: {name}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run multiple HLC indicators on the same series.
|
||||
pub fn run_hlc_indicators(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
names: &[String],
|
||||
timeperiods: &[usize],
|
||||
) -> Result<Vec<Vec<f64>>, String> {
|
||||
validate_indicator_requests(names, timeperiods)?;
|
||||
if high.len() != low.len() || high.len() != close.len() {
|
||||
return Err("high, low, and close must have equal length".into());
|
||||
}
|
||||
let mut results = Vec::with_capacity(names.len());
|
||||
for (name, &tp) in names.iter().zip(timeperiods.iter()) {
|
||||
results.push(compute_hlc_indicator(name, high, low, close, tp)?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close_data() -> Vec<f64> {
|
||||
vec![
|
||||
44.34, 44.09, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61,
|
||||
46.28, 46.28, 46.00, 46.03, 46.41, 46.22, 45.64,
|
||||
]
|
||||
}
|
||||
|
||||
fn hlc_data() -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let close = close_data();
|
||||
let high: Vec<f64> = close.iter().map(|c| c + 0.5).collect();
|
||||
let low: Vec<f64> = close.iter().map(|c| c - 0.5).collect();
|
||||
(high, low, close)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_sma_basic() {
|
||||
let col1 = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let col2 = vec![10.0, 20.0, 30.0, 40.0, 50.0];
|
||||
let data = vec![col1, col2];
|
||||
let result = batch_sma(&data, 3).unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result[0][0].is_nan());
|
||||
assert!(result[0][1].is_nan());
|
||||
assert!((result[0][2] - 2.0).abs() < 1e-10);
|
||||
assert!((result[1][2] - 20.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_sma_zero_period() {
|
||||
let data = vec![vec![1.0, 2.0]];
|
||||
assert!(batch_sma(&data, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_ema_basic() {
|
||||
let data = vec![vec![1.0, 2.0, 3.0, 4.0, 5.0]];
|
||||
let result = batch_ema(&data, 3).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0][0].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_rsi_basic() {
|
||||
let data = vec![close_data()];
|
||||
let result = batch_rsi(&data, 14).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
// First 14 values should be NaN
|
||||
for i in 0..14 {
|
||||
assert!(result[0][i].is_nan(), "index {i} should be NaN");
|
||||
}
|
||||
// Value at index 14 should be a valid RSI
|
||||
let rsi_val = result[0][14];
|
||||
assert!(!rsi_val.is_nan());
|
||||
assert!(rsi_val >= 0.0 && rsi_val <= 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_atr_basic() {
|
||||
let (h, l, c) = hlc_data();
|
||||
let high = vec![h];
|
||||
let low = vec![l];
|
||||
let close = vec![c];
|
||||
let result = batch_atr(&high, &low, &close, 14).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_stoch_basic() {
|
||||
let (h, l, c) = hlc_data();
|
||||
let high = vec![h];
|
||||
let low = vec![l];
|
||||
let close = vec![c];
|
||||
let (k, d) = batch_stoch(&high, &low, &close, 5, 3, 3).unwrap();
|
||||
assert_eq!(k.len(), 1);
|
||||
assert_eq!(d.len(), 1);
|
||||
assert_eq!(k[0].len(), d[0].len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_adx_basic() {
|
||||
let (h, l, c) = hlc_data();
|
||||
let high = vec![h];
|
||||
let low = vec![l];
|
||||
let close = vec![c];
|
||||
let result = batch_adx(&high, &low, &close, 14).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_close_indicators_basic() {
|
||||
let close = close_data();
|
||||
let names = vec!["SMA".to_string(), "EMA".to_string()];
|
||||
let timeperiods = vec![5, 5];
|
||||
let result = run_close_indicators(&close, &names, &timeperiods).unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].len(), close.len());
|
||||
assert_eq!(result[1].len(), close.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_close_indicators_mismatched_lengths() {
|
||||
let close = close_data();
|
||||
let names = vec!["SMA".to_string()];
|
||||
let timeperiods = vec![5, 10]; // different length
|
||||
assert!(run_close_indicators(&close, &names, &timeperiods).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_close_indicators_linreg_variants() {
|
||||
let close = close_data();
|
||||
let names = vec![
|
||||
"LINEARREG".to_string(),
|
||||
"LINEARREG_SLOPE".to_string(),
|
||||
"LINEARREG_INTERCEPT".to_string(),
|
||||
"LINEARREG_ANGLE".to_string(),
|
||||
"TSF".to_string(),
|
||||
];
|
||||
let timeperiods = vec![5, 5, 5, 5, 5];
|
||||
let result = run_close_indicators(&close, &names, &timeperiods).unwrap();
|
||||
assert_eq!(result.len(), 5);
|
||||
// First 4 values should be NaN for period=5
|
||||
for series in &result {
|
||||
for i in 0..4 {
|
||||
assert!(series[i].is_nan());
|
||||
}
|
||||
assert!(!series[4].is_nan());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_hlc_indicators_basic() {
|
||||
let (h, l, c) = hlc_data();
|
||||
let names = vec!["ATR".to_string(), "CCI".to_string()];
|
||||
let timeperiods = vec![14, 14];
|
||||
let result = run_hlc_indicators(&h, &l, &c, &names, &timeperiods).unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_hlc_indicators_unsupported() {
|
||||
let (h, l, c) = hlc_data();
|
||||
let names = vec!["UNKNOWN".to_string()];
|
||||
let timeperiods = vec![14];
|
||||
assert!(run_hlc_indicators(&h, &l, &c, &names, &timeperiods).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_hlc_mismatched_columns() {
|
||||
let high = vec![vec![1.0, 2.0]];
|
||||
let low = vec![vec![1.0, 2.0], vec![3.0, 4.0]]; // 2 cols vs 1
|
||||
let close = vec![vec![1.0, 2.0]];
|
||||
assert!(batch_atr(&high, &low, &close, 5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_data() {
|
||||
let data: Vec<Vec<f64>> = vec![];
|
||||
let result = batch_sma(&data, 3).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_multiple_columns() {
|
||||
let data = vec![
|
||||
vec![1.0, 2.0, 3.0, 4.0, 5.0],
|
||||
vec![5.0, 4.0, 3.0, 2.0, 1.0],
|
||||
vec![2.0, 4.0, 6.0, 8.0, 10.0],
|
||||
];
|
||||
let result = batch_sma(&data, 3).unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
// col 0: sma(3) at index 2 = (1+2+3)/3 = 2.0
|
||||
assert!((result[0][2] - 2.0).abs() < 1e-10);
|
||||
// col 1: sma(3) at index 2 = (5+4+3)/3 = 4.0
|
||||
assert!((result[1][2] - 4.0).abs() < 1e-10);
|
||||
// col 2: sma(3) at index 2 = (2+4+6)/3 = 4.0
|
||||
assert!((result[2][2] - 4.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Chunked / out-of-core execution helpers.
|
||||
//!
|
||||
//! - `trim_overlap` — remove the first N elements from a slice
|
||||
//! - `stitch_chunks` — concatenate trimmed chunk results
|
||||
//! - `make_chunk_ranges` — compute (start, end) index pairs for chunked processing
|
||||
//! - `forward_fill_nan` — forward-fill NaN values
|
||||
|
||||
/// Remove the first `overlap` elements from a slice.
|
||||
pub fn trim_overlap(chunk_out: &[f64], overlap: usize) -> Vec<f64> {
|
||||
if overlap > chunk_out.len() {
|
||||
return vec![];
|
||||
}
|
||||
chunk_out[overlap..].to_vec()
|
||||
}
|
||||
|
||||
/// Concatenate a list of slices into a single Vec.
|
||||
pub fn stitch_chunks(chunks: &[&[f64]]) -> Vec<f64> {
|
||||
let mut out = Vec::new();
|
||||
for &chunk in chunks {
|
||||
out.extend_from_slice(chunk);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute (start, end) index pairs for chunked processing.
|
||||
///
|
||||
/// Returns a flat Vec of pairs: [start0, end0, start1, end1, ...].
|
||||
/// `chunk_size` is the desired output bars per chunk, `overlap` is the warm-up prefix.
|
||||
pub fn make_chunk_ranges(n: usize, chunk_size: usize, overlap: usize) -> Vec<i64> {
|
||||
if chunk_size == 0 || n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let mut ranges: Vec<i64> = Vec::new();
|
||||
let mut start: usize = 0;
|
||||
loop {
|
||||
let end = (start + chunk_size + overlap).min(n);
|
||||
ranges.push(start as i64);
|
||||
ranges.push(end as i64);
|
||||
if end >= n {
|
||||
break;
|
||||
}
|
||||
start = end.saturating_sub(overlap);
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
/// Forward-fill NaN values in a 1-D array.
|
||||
/// Leading NaN values are preserved until the first non-NaN value appears.
|
||||
pub fn forward_fill_nan(values: &[f64]) -> Vec<f64> {
|
||||
let mut out = Vec::with_capacity(values.len());
|
||||
let mut last = f64::NAN;
|
||||
for &value in values {
|
||||
if value.is_nan() {
|
||||
out.push(last);
|
||||
} else {
|
||||
last = value;
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_trim_overlap() {
|
||||
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let result = trim_overlap(&data, 2);
|
||||
assert_eq!(result, vec![3.0, 4.0, 5.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trim_overlap_zero() {
|
||||
let data = vec![1.0, 2.0, 3.0];
|
||||
assert_eq!(trim_overlap(&data, 0), data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trim_overlap_exceeds() {
|
||||
let data = vec![1.0, 2.0];
|
||||
assert!(trim_overlap(&data, 5).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stitch_chunks() {
|
||||
let a = vec![1.0, 2.0];
|
||||
let b = vec![3.0, 4.0, 5.0];
|
||||
let chunks: Vec<&[f64]> = vec![&a, &b];
|
||||
let result = stitch_chunks(&chunks);
|
||||
assert_eq!(result, vec![1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_chunk_ranges() {
|
||||
let ranges = make_chunk_ranges(10, 4, 2);
|
||||
// Expected: [0,6], [4,10]
|
||||
assert_eq!(ranges.len() % 2, 0);
|
||||
assert!(ranges.len() >= 4);
|
||||
assert_eq!(ranges[0], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_fill_nan() {
|
||||
let data = vec![f64::NAN, 1.0, f64::NAN, f64::NAN, 2.0, f64::NAN];
|
||||
let result = forward_fill_nan(&data);
|
||||
assert!(result[0].is_nan()); // leading NaN preserved
|
||||
assert!((result[1] - 1.0).abs() < 1e-10);
|
||||
assert!((result[2] - 1.0).abs() < 1e-10); // filled
|
||||
assert!((result[3] - 1.0).abs() < 1e-10); // filled
|
||||
assert!((result[4] - 2.0).abs() < 1e-10);
|
||||
assert!((result[5] - 2.0).abs() < 1e-10); // filled
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty() {
|
||||
assert!(trim_overlap(&[], 0).is_empty());
|
||||
assert!(stitch_chunks(&[]).is_empty());
|
||||
assert!(make_chunk_ranges(0, 4, 2).is_empty());
|
||||
assert!(forward_fill_nan(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Commission, tax, and fee model for Indian and global markets.
|
||||
//!
|
||||
//! All `_rate` fields are fractions (0.001 = 0.1%).
|
||||
//! All per-unit fields (`flat_per_order`, `per_lot`) are in base currency units (e.g., INR).
|
||||
//! The model is self-contained: pass `trade_value`, `num_lots`, `is_buy` to get total cost.
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Advanced commission and tax model.
|
||||
///
|
||||
/// # Fields (all public for direct construction)
|
||||
/// - **Brokerage**: `flat_per_order`, `rate_of_value`, `per_lot`, `max_brokerage`
|
||||
/// - **STT**: `stt_rate`, `stt_on_buy`, `stt_on_sell`
|
||||
/// - **Levies**: `exchange_charges_rate`, `regulatory_charges_rate`, `gst_rate`, `stamp_duty_rate`
|
||||
/// - **Sizing**: `lot_size`
|
||||
///
|
||||
/// # Indian market notes
|
||||
/// - STT (Securities Transaction Tax) is applied on turnover (buy/sell legs vary by segment).
|
||||
/// - Exchange charges and regulatory body charges are on turnover.
|
||||
/// - GST (18%) applies on brokerage + exchange charges + regulatory body charges (not STT/stamp).
|
||||
/// - Stamp duty is on buy-side value only.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||
pub struct CommissionModel {
|
||||
// --- Brokerage ---------------------------------------------------------
|
||||
/// Fixed fee per order (e.g., ₹20 flat fee per order). 0.0 = none.
|
||||
pub flat_per_order: f64,
|
||||
/// Proportional brokerage as fraction of `trade_value` (e.g., 0.001 = 0.1%). 0.0 = none.
|
||||
pub rate_of_value: f64,
|
||||
/// Fixed fee per lot (e.g., ₹2 per lot). 0.0 = none.
|
||||
pub per_lot: f64,
|
||||
/// Brokerage cap in currency units. 0.0 = no cap.
|
||||
/// Effective brokerage = min(flat + rate × value + per_lot × lots, max_brokerage).
|
||||
pub max_brokerage: f64,
|
||||
/// Bid-ask spread model in basis points. Half-spread is paid on each leg (entry and exit),
|
||||
/// so total roundtrip cost = spread_bps in bps. 0.0 = no spread cost.
|
||||
pub spread_bps: f64,
|
||||
|
||||
// --- Securities Transaction Tax (STT) ----------------------------------
|
||||
/// STT rate as fraction of trade value. 0.0 = no STT.
|
||||
pub stt_rate: f64,
|
||||
/// Apply STT on the buy leg.
|
||||
pub stt_on_buy: bool,
|
||||
/// Apply STT on the sell leg.
|
||||
pub stt_on_sell: bool,
|
||||
|
||||
// --- Exchange & Regulatory Levies --------------------------------------
|
||||
/// Exchange transaction charges rate (fraction of trade value).
|
||||
pub exchange_charges_rate: f64,
|
||||
/// Regulatory body turnover charges rate (fraction of trade value). Typically ~0.000001.
|
||||
pub regulatory_charges_rate: f64,
|
||||
/// Indirect tax (GST) rate applied on (brokerage + exchange_charges + regulatory_charges).
|
||||
/// Typically 0.18 in India.
|
||||
pub gst_rate: f64,
|
||||
/// Stamp duty rate on buy side only (fraction of trade value).
|
||||
pub stamp_duty_rate: f64,
|
||||
|
||||
// --- Instrument Sizing ------------------------------------------------
|
||||
/// Lot size for the instrument.
|
||||
/// Equities: 1.0. Index futures/options: contract lot size (e.g., 25, 50, 75).
|
||||
/// Used for per_lot cost: cost += per_lot × ceil(quantity / lot_size).
|
||||
pub lot_size: f64,
|
||||
|
||||
// --- Short Selling ----------------------------------------------------
|
||||
/// Annualised short borrow rate as a fraction (e.g. 0.03 = 3% p.a.).
|
||||
/// Applied per bar to short positions. 0.0 = no borrow cost.
|
||||
pub short_borrow_rate_annual: f64,
|
||||
}
|
||||
|
||||
impl Default for CommissionModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
flat_per_order: 0.0,
|
||||
rate_of_value: 0.0,
|
||||
per_lot: 0.0,
|
||||
max_brokerage: 0.0,
|
||||
spread_bps: 0.0,
|
||||
stt_rate: 0.0,
|
||||
stt_on_buy: false,
|
||||
stt_on_sell: false,
|
||||
exchange_charges_rate: 0.0,
|
||||
regulatory_charges_rate: 0.0,
|
||||
gst_rate: 0.0,
|
||||
stamp_duty_rate: 0.0,
|
||||
lot_size: 1.0,
|
||||
short_borrow_rate_annual: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommissionModel {
|
||||
// ------------------------------------------------------------------
|
||||
// Core computation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Compute total transaction cost in **absolute currency units**.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `trade_value`: price × quantity in base currency
|
||||
/// - `num_lots`: number of lots transacted
|
||||
/// - `is_buy`: true for buy (entry) leg, false for sell (exit) leg
|
||||
pub fn total_cost(&self, trade_value: f64, num_lots: f64, is_buy: bool) -> f64 {
|
||||
// Brokerage (optionally capped)
|
||||
let raw_brokerage =
|
||||
self.flat_per_order + self.rate_of_value * trade_value + self.per_lot * num_lots;
|
||||
let brokerage = if self.max_brokerage > 0.0 {
|
||||
raw_brokerage.min(self.max_brokerage)
|
||||
} else {
|
||||
raw_brokerage
|
||||
};
|
||||
|
||||
// STT
|
||||
let stt = if (is_buy && self.stt_on_buy) || (!is_buy && self.stt_on_sell) {
|
||||
self.stt_rate * trade_value
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let exchange = self.exchange_charges_rate * trade_value;
|
||||
let regulatory = self.regulatory_charges_rate * trade_value;
|
||||
|
||||
// GST on brokerage + exchange + regulatory (NOT on STT or stamp duty)
|
||||
let gst = self.gst_rate * (brokerage + exchange + regulatory);
|
||||
|
||||
// Stamp duty only on buy side
|
||||
let stamp = if is_buy {
|
||||
self.stamp_duty_rate * trade_value
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Bid-ask spread: half-spread paid on each leg
|
||||
let spread_cost = self.spread_bps / 2.0 / 10_000.0 * trade_value;
|
||||
|
||||
brokerage + stt + exchange + regulatory + gst + stamp + spread_cost
|
||||
}
|
||||
|
||||
/// Borrow cost per bar for a short position.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `trade_value`: abs(price × quantity)
|
||||
/// - `periods_per_year`: 252 for daily, 52 for weekly, etc.
|
||||
pub fn short_borrow_cost(&self, trade_value: f64, periods_per_year: f64) -> f64 {
|
||||
if self.short_borrow_rate_annual <= 0.0 || periods_per_year <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.short_borrow_rate_annual / periods_per_year * trade_value
|
||||
}
|
||||
|
||||
/// Compute cost as a **fraction of `initial_capital`** for use in normalised equity loops.
|
||||
///
|
||||
/// Returns 0.0 if `initial_capital` ≤ 0.
|
||||
pub fn cost_fraction(
|
||||
&self,
|
||||
trade_value: f64,
|
||||
num_lots: f64,
|
||||
is_buy: bool,
|
||||
initial_capital: f64,
|
||||
) -> f64 {
|
||||
if initial_capital <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.total_cost(trade_value, num_lots, is_buy) / initial_capital
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Built-in Presets
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Zero commission — useful for clean research/comparison runs.
|
||||
pub fn zero() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Indian equity **delivery** (long-term hold).
|
||||
///
|
||||
/// Brokerage: 0.1% (capped at ₹20), STT 0.1% both sides,
|
||||
/// exchange charges, regulatory body charges, 18% GST, stamp duty.
|
||||
pub fn equity_delivery_india() -> Self {
|
||||
Self {
|
||||
flat_per_order: 0.0,
|
||||
rate_of_value: 0.001, // 0.1%
|
||||
per_lot: 0.0,
|
||||
max_brokerage: 20.0, // ₹20 cap
|
||||
spread_bps: 0.0,
|
||||
stt_rate: 0.001, // 0.1%
|
||||
stt_on_buy: true,
|
||||
stt_on_sell: true,
|
||||
exchange_charges_rate: 0.0000297,
|
||||
regulatory_charges_rate: 0.000001,
|
||||
gst_rate: 0.18,
|
||||
stamp_duty_rate: 0.00015,
|
||||
lot_size: 1.0,
|
||||
short_borrow_rate_annual: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indian equity **intraday** (same-day square-off).
|
||||
///
|
||||
/// Brokerage: 0.03% (capped at ₹20), STT 0.025% sell side only,
|
||||
/// exchange charges, regulatory body charges, 18% GST, stamp duty on buy.
|
||||
pub fn equity_intraday_india() -> Self {
|
||||
Self {
|
||||
flat_per_order: 0.0,
|
||||
rate_of_value: 0.0003, // 0.03%
|
||||
per_lot: 0.0,
|
||||
max_brokerage: 20.0,
|
||||
spread_bps: 0.0,
|
||||
stt_rate: 0.00025, // 0.025%
|
||||
stt_on_buy: false,
|
||||
stt_on_sell: true,
|
||||
exchange_charges_rate: 0.0000297,
|
||||
regulatory_charges_rate: 0.000001,
|
||||
gst_rate: 0.18,
|
||||
stamp_duty_rate: 0.000003,
|
||||
lot_size: 1.0,
|
||||
short_borrow_rate_annual: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indian **index futures** (indicative rates per current regulations).
|
||||
///
|
||||
/// Flat ₹20 per order, STT 0.05% sell side only, exchange charges,
|
||||
/// regulatory body charges, 18% GST, stamp duty on buy.
|
||||
/// `lot_size` defaults to 25 — update as needed for the specific contract.
|
||||
pub fn futures_india() -> Self {
|
||||
Self {
|
||||
flat_per_order: 20.0,
|
||||
rate_of_value: 0.0,
|
||||
per_lot: 0.0,
|
||||
max_brokerage: 0.0,
|
||||
spread_bps: 0.0,
|
||||
stt_rate: 0.0005, // 0.05%
|
||||
stt_on_buy: false,
|
||||
stt_on_sell: true,
|
||||
exchange_charges_rate: 0.0000019,
|
||||
regulatory_charges_rate: 0.000001,
|
||||
gst_rate: 0.18,
|
||||
stamp_duty_rate: 0.00002,
|
||||
lot_size: 25.0,
|
||||
short_borrow_rate_annual: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indian **index options** (indicative rates per current regulations).
|
||||
///
|
||||
/// Flat ₹20 per order, STT 0.15% on premium sell side only, exchange charges,
|
||||
/// regulatory body charges, 18% GST, stamp duty on buy.
|
||||
/// `lot_size` defaults to 25 — update as needed for the specific contract.
|
||||
pub fn options_india() -> Self {
|
||||
Self {
|
||||
flat_per_order: 20.0,
|
||||
rate_of_value: 0.0,
|
||||
per_lot: 0.0,
|
||||
max_brokerage: 0.0,
|
||||
spread_bps: 0.0,
|
||||
stt_rate: 0.0015, // 0.15% on premium
|
||||
stt_on_buy: false,
|
||||
stt_on_sell: true,
|
||||
exchange_charges_rate: 0.0000053,
|
||||
regulatory_charges_rate: 0.000001,
|
||||
gst_rate: 0.18,
|
||||
stamp_duty_rate: 0.000003,
|
||||
lot_size: 25.0,
|
||||
short_borrow_rate_annual: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple proportional model — e.g., `proportional(0.001)` = 0.1% both sides.
|
||||
///
|
||||
/// No taxes, no levies — suitable for non-Indian markets or simplified modelling.
|
||||
pub fn proportional(rate: f64) -> Self {
|
||||
Self {
|
||||
rate_of_value: rate,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// JSON serialization (requires "serde" feature)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Serialize to a pretty-printed JSON string.
|
||||
#[cfg(feature = "serde")]
|
||||
pub fn to_json(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string_pretty(self)
|
||||
}
|
||||
|
||||
/// Deserialize from a JSON string.
|
||||
#[cfg(feature = "serde")]
|
||||
pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
|
||||
serde_json::from_str(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Crypto and 24/7 market helpers.
|
||||
//!
|
||||
//! - `funding_cumulative_pnl` — cumulative PnL from periodic funding rate payments
|
||||
//! - `continuous_bar_labels` — assign sequential integer labels based on fixed period size
|
||||
//! - `mark_session_boundaries` — return indices where a new UTC day begins
|
||||
|
||||
/// Compute the cumulative PnL from funding rate payments.
|
||||
///
|
||||
/// `position_size` and `funding_rate` must have the same length.
|
||||
/// PnL at period i = -position_size[i] * funding_rate[i] (longs pay when rate > 0).
|
||||
pub fn funding_cumulative_pnl(position_size: &[f64], funding_rate: &[f64]) -> Vec<f64> {
|
||||
let n = position_size.len();
|
||||
let mut out = vec![0.0_f64; n];
|
||||
let mut cumulative = 0.0_f64;
|
||||
for i in 0..n {
|
||||
cumulative += -position_size[i] * funding_rate[i];
|
||||
out[i] = cumulative;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Assign a sequential integer label per bar based on a fixed-size period.
|
||||
///
|
||||
/// Bars 0..(period_bars-1) get label 0, bars period_bars..(2*period_bars-1) get label 1, etc.
|
||||
/// `period_bars` must be >= 1.
|
||||
pub fn continuous_bar_labels(n_bars: usize, period_bars: usize) -> Vec<i64> {
|
||||
(0..n_bars).map(|i| (i / period_bars) as i64).collect()
|
||||
}
|
||||
|
||||
/// Return bar indices where a new UTC day begins (based on nanosecond timestamps).
|
||||
///
|
||||
/// Bar 0 is always included as the first boundary.
|
||||
pub fn mark_session_boundaries(timestamps_ns: &[i64]) -> Vec<i64> {
|
||||
let n = timestamps_ns.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
const NS_PER_DAY: i64 = 86_400_000_000_000;
|
||||
let mut out = vec![0i64]; // bar 0 is always a boundary
|
||||
let mut prev_day = timestamps_ns[0].div_euclid(NS_PER_DAY);
|
||||
for (i, &t) in timestamps_ns.iter().enumerate().skip(1) {
|
||||
let day = t.div_euclid(NS_PER_DAY);
|
||||
if day != prev_day {
|
||||
out.push(i as i64);
|
||||
prev_day = day;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_funding_cumulative_pnl() {
|
||||
let pos = vec![100.0, 100.0, -50.0];
|
||||
let rate = vec![0.001, -0.002, 0.001];
|
||||
let result = funding_cumulative_pnl(&pos, &rate);
|
||||
assert!((result[0] - (-0.1)).abs() < 1e-10);
|
||||
assert!((result[1] - 0.1).abs() < 1e-10); // -0.1 + 0.2 = 0.1
|
||||
assert!((result[2] - 0.15).abs() < 1e-10); // 0.1 + 0.05 = 0.15
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_continuous_bar_labels() {
|
||||
let labels = continuous_bar_labels(7, 3);
|
||||
assert_eq!(labels, vec![0, 0, 0, 1, 1, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_session_boundaries() {
|
||||
let ns_per_day: i64 = 86_400_000_000_000;
|
||||
let ts = vec![
|
||||
0, // day 0
|
||||
ns_per_day / 2, // day 0
|
||||
ns_per_day, // day 1
|
||||
ns_per_day + ns_per_day / 2, // day 1
|
||||
ns_per_day * 2, // day 2
|
||||
];
|
||||
let result = mark_session_boundaries(&ts);
|
||||
assert_eq!(result, vec![0, 2, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty() {
|
||||
assert!(funding_cumulative_pnl(&[], &[]).is_empty());
|
||||
assert!(continuous_bar_labels(0, 1).is_empty());
|
||||
assert!(mark_session_boundaries(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Currency metadata and Indian number formatting.
|
||||
|
||||
/// Immutable currency descriptor.
|
||||
///
|
||||
/// Carries the currency code, symbol, decimal places, and whether to use
|
||||
/// Indian lakh/crore grouping (1,23,45,678.00) instead of standard
|
||||
/// Western grouping (1,234,567.89).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Currency {
|
||||
/// IETF currency code, e.g. "INR", "USD".
|
||||
pub code: &'static str,
|
||||
/// Display symbol, e.g. "₹", "$".
|
||||
pub symbol: &'static str,
|
||||
/// Number of decimal places for formatting.
|
||||
pub decimal_places: u8,
|
||||
/// Use Indian lakh/crore digit grouping (true only for INR).
|
||||
pub lakh_grouping: bool,
|
||||
}
|
||||
|
||||
impl Currency {
|
||||
pub const INR: Currency = Currency {
|
||||
code: "INR",
|
||||
symbol: "₹",
|
||||
decimal_places: 2,
|
||||
lakh_grouping: true,
|
||||
};
|
||||
pub const USD: Currency = Currency {
|
||||
code: "USD",
|
||||
symbol: "$",
|
||||
decimal_places: 2,
|
||||
lakh_grouping: false,
|
||||
};
|
||||
pub const EUR: Currency = Currency {
|
||||
code: "EUR",
|
||||
symbol: "€",
|
||||
decimal_places: 2,
|
||||
lakh_grouping: false,
|
||||
};
|
||||
pub const GBP: Currency = Currency {
|
||||
code: "GBP",
|
||||
symbol: "£",
|
||||
decimal_places: 2,
|
||||
lakh_grouping: false,
|
||||
};
|
||||
pub const JPY: Currency = Currency {
|
||||
code: "JPY",
|
||||
symbol: "¥",
|
||||
decimal_places: 0,
|
||||
lakh_grouping: false,
|
||||
};
|
||||
pub const USDT: Currency = Currency {
|
||||
code: "USDT",
|
||||
symbol: "₮",
|
||||
decimal_places: 2,
|
||||
lakh_grouping: false,
|
||||
};
|
||||
|
||||
/// Look up a currency by IETF code (case-insensitive).
|
||||
/// Returns `None` if the code is not recognised.
|
||||
pub fn from_code(code: &str) -> Option<&'static Currency> {
|
||||
match code.to_ascii_uppercase().as_str() {
|
||||
"INR" => Some(&Currency::INR),
|
||||
"USD" => Some(&Currency::USD),
|
||||
"EUR" => Some(&Currency::EUR),
|
||||
"GBP" => Some(&Currency::GBP),
|
||||
"JPY" => Some(&Currency::JPY),
|
||||
"USDT" => Some(&Currency::USDT),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format `amount` according to this currency's style.
|
||||
///
|
||||
/// - INR uses Indian lakh/crore grouping: `₹1,23,45,678.00`
|
||||
/// - Others use standard Western grouping: `$1,234,567.89`
|
||||
pub fn format(&self, amount: f64) -> String {
|
||||
let neg = amount < 0.0;
|
||||
let abs = amount.abs();
|
||||
let integer_part = abs.floor() as u64;
|
||||
let frac_part = abs - abs.floor();
|
||||
|
||||
let grouped = if self.lakh_grouping {
|
||||
format_lakh(integer_part)
|
||||
} else {
|
||||
format_standard(integer_part)
|
||||
};
|
||||
|
||||
let dp = self.decimal_places as usize;
|
||||
let decimal_str = if dp > 0 {
|
||||
let frac = (frac_part * 10f64.powi(dp as i32)).round() as u64;
|
||||
format!(".{:0>width$}", frac, width = dp)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let sign = if neg { "-" } else { "" };
|
||||
format!("{}{}{}{}", sign, self.symbol, grouped, decimal_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Indian lakh/crore grouping: last 3 digits, then groups of 2 from the right.
|
||||
/// e.g. 12345678 → "1,23,45,678"
|
||||
fn format_lakh(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
if s.len() <= 3 {
|
||||
return s;
|
||||
}
|
||||
let (rest, last3) = s.split_at(s.len() - 3);
|
||||
let mut out = String::new();
|
||||
let chars: Vec<char> = rest.chars().collect();
|
||||
let first_len = chars.len() % 2;
|
||||
if first_len > 0 {
|
||||
out.push_str(&chars[..first_len].iter().collect::<String>());
|
||||
}
|
||||
let mut i = first_len;
|
||||
while i < chars.len() {
|
||||
if !out.is_empty() {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&chars[i..i + 2].iter().collect::<String>());
|
||||
i += 2;
|
||||
}
|
||||
if !out.is_empty() {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(last3);
|
||||
out
|
||||
}
|
||||
|
||||
/// Standard Western grouping: groups of 3 digits from the right.
|
||||
/// e.g. 1234567 → "1,234,567"
|
||||
fn format_standard(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let mut out = String::new();
|
||||
for (i, c) in s.chars().rev().enumerate() {
|
||||
if i > 0 && i % 3 == 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.chars().rev().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inr_format() {
|
||||
assert_eq!(Currency::INR.format(123456.78), "₹1,23,456.78");
|
||||
assert_eq!(Currency::INR.format(10000000.0), "₹1,00,00,000.00");
|
||||
assert_eq!(Currency::INR.format(100.0), "₹100.00");
|
||||
assert_eq!(Currency::INR.format(-5000.0), "-₹5,000.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usd_format() {
|
||||
assert_eq!(Currency::USD.format(1234567.89), "$1,234,567.89");
|
||||
assert_eq!(Currency::USD.format(0.5), "$0.50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jpy_format() {
|
||||
assert_eq!(Currency::JPY.format(1000000.0), "¥1,000,000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_code() {
|
||||
assert_eq!(Currency::from_code("inr"), Some(&Currency::INR));
|
||||
assert_eq!(Currency::from_code("USD"), Some(&Currency::USD));
|
||||
assert_eq!(Currency::from_code("UNKNOWN"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Cycle indicators — Hilbert Transform-based cycle analysis (Ehlers).
|
||||
//!
|
||||
//! Based on John Ehlers' Discrete Hilbert Transform as implemented in TA-Lib.
|
||||
//! Reference: "Cybernetic Analysis for Stocks and Futures" by J.F. Ehlers
|
||||
//!
|
||||
//! All HT functions share a 63-bar lookback period.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Number of leading bars that are set to NaN / zero.
|
||||
pub const HT_LOOKBACK: usize = 63;
|
||||
|
||||
/// Shared output from the core Hilbert Transform computation.
|
||||
pub struct HtCore {
|
||||
pub trendline: Vec<f64>,
|
||||
pub dc_period: Vec<f64>,
|
||||
pub dc_phase: Vec<f64>,
|
||||
pub inphase: Vec<f64>,
|
||||
pub quadrature: Vec<f64>,
|
||||
pub trend_mode: Vec<i32>,
|
||||
}
|
||||
|
||||
/// Run the full Hilbert Transform pipeline on a slice of close prices.
|
||||
pub fn compute_ht_core(prices: &[f64]) -> HtCore {
|
||||
let n = prices.len();
|
||||
|
||||
let mut trendline = vec![f64::NAN; n];
|
||||
let mut dc_period = vec![f64::NAN; n];
|
||||
let mut dc_phase = vec![f64::NAN; n];
|
||||
let mut inphase = vec![f64::NAN; n];
|
||||
let mut quadrature = vec![f64::NAN; n];
|
||||
let mut trend_mode = vec![0i32; n];
|
||||
|
||||
if n <= HT_LOOKBACK {
|
||||
return HtCore {
|
||||
trendline,
|
||||
dc_period,
|
||||
dc_phase,
|
||||
inphase,
|
||||
quadrature,
|
||||
trend_mode,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 1: Smooth the price series (4-bar weighted average)
|
||||
let mut smooth = vec![0.0f64; n];
|
||||
for i in 0..n {
|
||||
smooth[i] = if i >= 3 {
|
||||
(4.0 * prices[i] + 3.0 * prices[i - 1] + 2.0 * prices[i - 2] + prices[i - 3]) / 10.0
|
||||
} else {
|
||||
prices[i]
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Full Hilbert Transform pipeline
|
||||
let mut detrender = vec![0.0f64; n];
|
||||
let mut q1 = vec![0.0f64; n];
|
||||
let mut i1 = vec![0.0f64; n];
|
||||
let mut ji = vec![0.0f64; n];
|
||||
let mut jq = vec![0.0f64; n];
|
||||
let mut i2 = vec![0.0f64; n];
|
||||
let mut q2 = vec![0.0f64; n];
|
||||
let mut re = vec![0.0f64; n];
|
||||
let mut im = vec![0.0f64; n];
|
||||
let mut period = vec![0.0f64; n];
|
||||
let mut smooth_period = vec![0.0f64; n];
|
||||
let mut phase = vec![0.0f64; n];
|
||||
|
||||
for i in 6..n {
|
||||
let prev_period = period[i - 1];
|
||||
// Alpha coefficient for HT filters depends on the current period estimate
|
||||
let alpha = 0.075 * prev_period + 0.54;
|
||||
|
||||
// Discrete Hilbert Transform of smooth price (detrender)
|
||||
detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2]
|
||||
- 0.5769 * smooth[i - 4]
|
||||
- 0.0962 * smooth[i - 6])
|
||||
* alpha;
|
||||
|
||||
// Q1: HT of detrender
|
||||
if i >= 12 {
|
||||
q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2]
|
||||
- 0.5769 * detrender[i - 4]
|
||||
- 0.0962 * detrender[i - 6])
|
||||
* alpha;
|
||||
}
|
||||
|
||||
// I1: delayed detrender
|
||||
if i >= 9 {
|
||||
i1[i] = detrender[i - 3];
|
||||
}
|
||||
|
||||
// jI: HT of I1
|
||||
if i >= 15 {
|
||||
ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6])
|
||||
* alpha;
|
||||
}
|
||||
|
||||
// jQ: HT of Q1
|
||||
if i >= 18 {
|
||||
jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6])
|
||||
* alpha;
|
||||
}
|
||||
|
||||
// Phase components
|
||||
let i2_raw = i1[i] - jq[i];
|
||||
let q2_raw = q1[i] + ji[i];
|
||||
|
||||
// EMA smoothing of I2 and Q2
|
||||
let i2_prev = i2[i - 1];
|
||||
let q2_prev = q2[i - 1];
|
||||
i2[i] = 0.2 * i2_raw + 0.8 * i2_prev;
|
||||
q2[i] = 0.2 * q2_raw + 0.8 * q2_prev;
|
||||
|
||||
// Cross-product for period estimation
|
||||
let re_raw = i2[i] * i2_prev + q2[i] * q2_prev;
|
||||
let im_raw = i2[i] * q2_prev - q2[i] * i2_prev;
|
||||
|
||||
// EMA smoothing of Re and Im
|
||||
re[i] = 0.2 * re_raw + 0.8 * re[i - 1];
|
||||
im[i] = 0.2 * im_raw + 0.8 * im[i - 1];
|
||||
|
||||
// Compute period from cross-product of consecutive phasors.
|
||||
let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 {
|
||||
2.0 * PI / (im[i] / re[i]).atan()
|
||||
} else {
|
||||
prev_period
|
||||
};
|
||||
|
||||
// Clamp period relative to previous
|
||||
if prev_period > 0.0 {
|
||||
if p > 1.5 * prev_period {
|
||||
p = 1.5 * prev_period;
|
||||
}
|
||||
if p < 0.67 * prev_period {
|
||||
p = 0.67 * prev_period;
|
||||
}
|
||||
}
|
||||
// Hard clamp to [6, 50] bars
|
||||
p = p.clamp(6.0, 50.0);
|
||||
|
||||
// EMA smooth the period
|
||||
period[i] = 0.2 * p + 0.8 * prev_period;
|
||||
|
||||
// Smooth the smoothed period once more
|
||||
smooth_period[i] = 0.33 * period[i] + 0.67 * smooth_period[i - 1];
|
||||
|
||||
// Phase from I1 and Q1
|
||||
phase[i] = if i1[i] != 0.0 {
|
||||
q1[i].atan2(i1[i]) * 180.0 / PI
|
||||
} else if q1[i] > 0.0 {
|
||||
90.0
|
||||
} else if q1[i] < 0.0 {
|
||||
-90.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Write outputs once past lookback
|
||||
if i >= HT_LOOKBACK {
|
||||
dc_period[i] = smooth_period[i];
|
||||
dc_phase[i] = phase[i];
|
||||
inphase[i] = i1[i];
|
||||
quadrature[i] = q1[i];
|
||||
|
||||
// Trend mode: cycle when SmoothPeriod >= 20, trend when < 20
|
||||
trend_mode[i] = if smooth_period[i] < 20.0 { 1 } else { 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// Trendline: average over the current dominant cycle period
|
||||
for i in HT_LOOKBACK..n {
|
||||
let sp = smooth_period[i];
|
||||
let dc = (sp.round() as usize).max(1).min(i + 1);
|
||||
let sum: f64 = (0..dc).map(|j| smooth[i - j]).sum();
|
||||
trendline[i] = sum / dc as f64;
|
||||
}
|
||||
|
||||
HtCore {
|
||||
trendline,
|
||||
dc_period,
|
||||
dc_phase,
|
||||
inphase,
|
||||
quadrature,
|
||||
trend_mode,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public indicator functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Hilbert Transform Instantaneous Trendline (Ehlers).
|
||||
/// Smooths price over the dominant cycle period.
|
||||
pub fn ht_trendline(close: &[f64]) -> Vec<f64> {
|
||||
compute_ht_core(close).trendline
|
||||
}
|
||||
|
||||
/// Hilbert Transform Dominant Cycle Period in bars.
|
||||
pub fn ht_dcperiod(close: &[f64]) -> Vec<f64> {
|
||||
compute_ht_core(close).dc_period
|
||||
}
|
||||
|
||||
/// Hilbert Transform Dominant Cycle Phase in degrees.
|
||||
pub fn ht_dcphase(close: &[f64]) -> Vec<f64> {
|
||||
compute_ht_core(close).dc_phase
|
||||
}
|
||||
|
||||
/// Hilbert Transform Phasor components. Returns `(inphase, quadrature)`.
|
||||
pub fn ht_phasor(close: &[f64]) -> (Vec<f64>, Vec<f64>) {
|
||||
let core = compute_ht_core(close);
|
||||
(core.inphase, core.quadrature)
|
||||
}
|
||||
|
||||
/// Hilbert Transform SineWave. Returns `(sine, leadsine)` where leadsine
|
||||
/// leads sine by 45 degrees.
|
||||
pub fn ht_sine(close: &[f64]) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let core = compute_ht_core(close);
|
||||
|
||||
let mut sine = vec![f64::NAN; n];
|
||||
let mut lead_sine = vec![f64::NAN; n];
|
||||
|
||||
for i in HT_LOOKBACK..n {
|
||||
if !core.dc_phase[i].is_nan() {
|
||||
let phase_rad = core.dc_phase[i] * PI / 180.0;
|
||||
sine[i] = phase_rad.sin();
|
||||
lead_sine[i] = (phase_rad + PI / 4.0).sin(); // 45-degree lead
|
||||
}
|
||||
}
|
||||
|
||||
(sine, lead_sine)
|
||||
}
|
||||
|
||||
/// Hilbert Transform Trend vs Cycle Mode: 1 = trending, 0 = cycling.
|
||||
pub fn ht_trendmode(close: &[f64]) -> Vec<i32> {
|
||||
compute_ht_core(close).trend_mode
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Generate a simple sine wave for testing cycle detection.
|
||||
fn sine_wave(n: usize, period: f64) -> Vec<f64> {
|
||||
(0..n)
|
||||
.map(|i| 100.0 + 10.0 * (2.0 * PI * i as f64 / period).sin())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Flat price series for baseline testing.
|
||||
fn flat_prices(n: usize) -> Vec<f64> {
|
||||
vec![100.0; n]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ht_trendline_length_and_lookback() {
|
||||
let close = sine_wave(200, 20.0);
|
||||
let result = ht_trendline(&close);
|
||||
assert_eq!(result.len(), close.len());
|
||||
// First HT_LOOKBACK values must be NaN
|
||||
for v in &result[..HT_LOOKBACK] {
|
||||
assert!(v.is_nan(), "expected NaN in lookback region");
|
||||
}
|
||||
// Values after lookback must be finite
|
||||
for v in &result[HT_LOOKBACK..] {
|
||||
assert!(v.is_finite(), "expected finite value after lookback");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ht_dcperiod_length_and_lookback() {
|
||||
let close = sine_wave(200, 20.0);
|
||||
let result = ht_dcperiod(&close);
|
||||
assert_eq!(result.len(), close.len());
|
||||
for v in &result[..HT_LOOKBACK] {
|
||||
assert!(v.is_nan());
|
||||
}
|
||||
// After lookback, period should be positive and finite
|
||||
for v in &result[HT_LOOKBACK..] {
|
||||
assert!(v.is_finite());
|
||||
assert!(*v >= 6.0 && *v <= 50.0, "period {} out of [6,50]", v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ht_dcphase_length_and_lookback() {
|
||||
let close = sine_wave(200, 20.0);
|
||||
let result = ht_dcphase(&close);
|
||||
assert_eq!(result.len(), close.len());
|
||||
for v in &result[..HT_LOOKBACK] {
|
||||
assert!(v.is_nan());
|
||||
}
|
||||
for v in &result[HT_LOOKBACK..] {
|
||||
assert!(v.is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ht_phasor_dual_output() {
|
||||
let close = sine_wave(200, 20.0);
|
||||
let (inp, quad) = ht_phasor(&close);
|
||||
assert_eq!(inp.len(), close.len());
|
||||
assert_eq!(quad.len(), close.len());
|
||||
for v in &inp[..HT_LOOKBACK] {
|
||||
assert!(v.is_nan());
|
||||
}
|
||||
for v in &quad[..HT_LOOKBACK] {
|
||||
assert!(v.is_nan());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ht_sine_dual_output() {
|
||||
let close = sine_wave(200, 20.0);
|
||||
let (s, ls) = ht_sine(&close);
|
||||
assert_eq!(s.len(), close.len());
|
||||
assert_eq!(ls.len(), close.len());
|
||||
for v in &s[..HT_LOOKBACK] {
|
||||
assert!(v.is_nan());
|
||||
}
|
||||
// Sine values should be in [-1, 1]
|
||||
for v in &s[HT_LOOKBACK..] {
|
||||
assert!(v.is_finite());
|
||||
assert!(*v >= -1.0 && *v <= 1.0, "sine {} out of [-1,1]", v);
|
||||
}
|
||||
for v in &ls[HT_LOOKBACK..] {
|
||||
assert!(v.is_finite());
|
||||
assert!(*v >= -1.0 && *v <= 1.0, "leadsine {} out of [-1,1]", v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ht_trendmode_values() {
|
||||
let close = sine_wave(200, 20.0);
|
||||
let result = ht_trendmode(&close);
|
||||
assert_eq!(result.len(), close.len());
|
||||
// All values must be 0 or 1
|
||||
for v in &result {
|
||||
assert!(*v == 0 || *v == 1, "trend_mode {} not 0 or 1", v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_input_all_nan() {
|
||||
let close = vec![100.0; HT_LOOKBACK]; // exactly HT_LOOKBACK, not enough
|
||||
let tl = ht_trendline(&close);
|
||||
assert!(tl.iter().all(|v| v.is_nan()));
|
||||
let dp = ht_dcperiod(&close);
|
||||
assert!(dp.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flat_prices_trendline_equals_price() {
|
||||
let close = flat_prices(200);
|
||||
let tl = ht_trendline(&close);
|
||||
// For a flat price, trendline after lookback should be very close to the price
|
||||
for v in &tl[HT_LOOKBACK..] {
|
||||
assert!(
|
||||
(v - 100.0).abs() < 1e-6,
|
||||
"trendline {} diverged from flat price",
|
||||
v
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,962 @@
|
||||
//! Extended indicators — pure Rust implementations (no PyO3, no numpy).
|
||||
//!
|
||||
//! These indicators are not part of TA-Lib and provide additional technical
|
||||
//! analysis capabilities. All functions operate on `&[f64]` slices and return
|
||||
//! `Vec<f64>` (or tuples thereof).
|
||||
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use crate::math;
|
||||
use crate::overlap;
|
||||
// Note: we use a local compute_atr helper (seeds from bar 0) rather than
|
||||
// crate::volatility::atr (which seeds from bar 1, TA-Lib style).
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute ATR array using Wilder smoothing (same algorithm as in the PyO3
|
||||
/// extended module — seeds from bar 0, not bar 1 like TA-Lib's `volatility::atr`).
|
||||
fn compute_atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if n <= timeperiod {
|
||||
return result;
|
||||
}
|
||||
// Seed: SMA of first `timeperiod` true range values
|
||||
let mut seed_sum = high[0] - low[0]; // first TR has no prev_close
|
||||
for i in 1..timeperiod {
|
||||
let hl = high[i] - low[i];
|
||||
let hc = (high[i] - close[i - 1]).abs();
|
||||
let lc = (low[i] - close[i - 1]).abs();
|
||||
seed_sum += hl.max(hc).max(lc);
|
||||
}
|
||||
let mut atr = seed_sum / timeperiod as f64;
|
||||
result[timeperiod - 1] = atr;
|
||||
let pf = (timeperiod - 1) as f64;
|
||||
for i in timeperiod..n {
|
||||
let hl = high[i] - low[i];
|
||||
let hc = (high[i] - close[i - 1]).abs();
|
||||
let lc = (low[i] - close[i - 1]).abs();
|
||||
let tr = hl.max(hc).max(lc);
|
||||
atr = (atr * pf + tr) / timeperiod as f64;
|
||||
result[i] = atr;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VWAP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Volume Weighted Average Price (cumulative or rolling).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high`, `low`, `close`, `volume` — equal-length price/volume slices.
|
||||
/// * `timeperiod` — 0 for cumulative VWAP from bar 0; >= 1 for a rolling window.
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Vec<f64>` of VWAP values. For rolling mode the first `timeperiod - 1`
|
||||
/// entries are `NaN`.
|
||||
pub fn vwap(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
timeperiod: usize,
|
||||
) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
|
||||
if timeperiod == 0 {
|
||||
let mut cum_tpv = 0.0_f64;
|
||||
let mut cum_vol = 0.0_f64;
|
||||
for i in 0..n {
|
||||
let tp = (high[i] + low[i] + close[i]) / 3.0;
|
||||
cum_tpv += tp * volume[i];
|
||||
cum_vol += volume[i];
|
||||
result[i] = if cum_vol != 0.0 {
|
||||
cum_tpv / cum_vol
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Pre-compute cumulative sums for O(n) rolling window
|
||||
let mut cum_tpv_arr = vec![0.0_f64; n];
|
||||
let mut cum_vol_arr = vec![0.0_f64; n];
|
||||
for i in 0..n {
|
||||
let tp = (high[i] + low[i] + close[i]) / 3.0;
|
||||
let tpv = tp * volume[i];
|
||||
cum_tpv_arr[i] = tpv + if i > 0 { cum_tpv_arr[i - 1] } else { 0.0 };
|
||||
cum_vol_arr[i] = volume[i] + if i > 0 { cum_vol_arr[i - 1] } else { 0.0 };
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
let prev_tpv = if i >= timeperiod {
|
||||
cum_tpv_arr[i - timeperiod]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let prev_vol = if i >= timeperiod {
|
||||
cum_vol_arr[i - timeperiod]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let w_tpv = cum_tpv_arr[i] - prev_tpv;
|
||||
let w_vol = cum_vol_arr[i] - prev_vol;
|
||||
result[i] = if w_vol != 0.0 {
|
||||
w_tpv / w_vol
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VWMA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Volume Weighted Moving Average.
|
||||
///
|
||||
/// `VWMA = sum(close * volume, n) / sum(volume, n)`
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `close` — price series.
|
||||
/// * `volume` — volume series (same length as `close`).
|
||||
/// * `timeperiod` — rolling window size (>= 1).
|
||||
///
|
||||
/// # Returns
|
||||
/// A `Vec<f64>` with `NaN` for the first `timeperiod - 1` entries.
|
||||
pub fn vwma(close: &[f64], volume: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
|
||||
let mut cum_cv = vec![0.0_f64; n];
|
||||
let mut cum_v = vec![0.0_f64; n];
|
||||
for i in 0..n {
|
||||
cum_cv[i] = close[i] * volume[i] + if i > 0 { cum_cv[i - 1] } else { 0.0 };
|
||||
cum_v[i] = volume[i] + if i > 0 { cum_v[i - 1] } else { 0.0 };
|
||||
}
|
||||
|
||||
for i in (timeperiod - 1)..n {
|
||||
let prev_cv = if i >= timeperiod {
|
||||
cum_cv[i - timeperiod]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let prev_v = if i >= timeperiod {
|
||||
cum_v[i - timeperiod]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let w_cv = cum_cv[i] - prev_cv;
|
||||
let w_v = cum_v[i] - prev_v;
|
||||
result[i] = if w_v != 0.0 { w_cv / w_v } else { f64::NAN };
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SUPERTREND
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// ATR-based Supertrend indicator.
|
||||
///
|
||||
/// # Returns
|
||||
/// `(supertrend_line, direction)` where direction values are:
|
||||
/// * `1` = uptrend
|
||||
/// * `-1` = downtrend
|
||||
/// * `0` = warmup (first `timeperiod` bars)
|
||||
pub fn supertrend(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
multiplier: f64,
|
||||
) -> (Vec<f64>, Vec<i8>) {
|
||||
let n = high.len();
|
||||
let mut supertrend_out = vec![f64::NAN; n];
|
||||
let mut direction = vec![0_i8; n];
|
||||
|
||||
if timeperiod < 1 || n <= timeperiod {
|
||||
return (supertrend_out, direction);
|
||||
}
|
||||
|
||||
let atr = compute_atr(high, low, close, timeperiod);
|
||||
|
||||
let mut upper_band = vec![f64::NAN; n];
|
||||
let mut lower_band = vec![f64::NAN; n];
|
||||
|
||||
let first_valid = timeperiod - 1;
|
||||
if first_valid >= n || atr[first_valid].is_nan() {
|
||||
return (supertrend_out, direction);
|
||||
}
|
||||
|
||||
// Initialize band state at first valid ATR bar (compute basic bands inline)
|
||||
{
|
||||
let hl2 = (high[first_valid] + low[first_valid]) / 2.0;
|
||||
upper_band[first_valid] = hl2 + multiplier * atr[first_valid];
|
||||
lower_band[first_valid] = hl2 - multiplier * atr[first_valid];
|
||||
}
|
||||
|
||||
for i in (first_valid + 1)..n {
|
||||
if atr[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute basic bands as scalars — no Vec allocation needed
|
||||
let hl2 = (high[i] + low[i]) / 2.0;
|
||||
let upper_basic = hl2 + multiplier * atr[i];
|
||||
let lower_basic = hl2 - multiplier * atr[i];
|
||||
|
||||
// Adjust lower band
|
||||
lower_band[i] = if lower_basic > lower_band[i - 1] || close[i - 1] < lower_band[i - 1] {
|
||||
lower_basic
|
||||
} else {
|
||||
lower_band[i - 1]
|
||||
};
|
||||
|
||||
// Adjust upper band
|
||||
upper_band[i] = if upper_basic < upper_band[i - 1] || close[i - 1] > upper_band[i - 1] {
|
||||
upper_basic
|
||||
} else {
|
||||
upper_band[i - 1]
|
||||
};
|
||||
|
||||
// Direction and output only from index timeperiod (warmup = 0, NaN)
|
||||
if i >= timeperiod {
|
||||
let prev_dir = direction[i - 1];
|
||||
direction[i] = if prev_dir == 0 || prev_dir == -1 {
|
||||
if close[i] > upper_band[i] {
|
||||
1
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
} else if close[i] < lower_band[i] {
|
||||
-1
|
||||
} else {
|
||||
1
|
||||
};
|
||||
supertrend_out[i] = if direction[i] == 1 {
|
||||
lower_band[i]
|
||||
} else {
|
||||
upper_band[i]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
(supertrend_out, direction)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DONCHIAN
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Donchian Channels — rolling highest high / lowest low.
|
||||
///
|
||||
/// # Returns
|
||||
/// `(upper, middle, lower)` arrays.
|
||||
pub fn donchian(high: &[f64], low: &[f64], timeperiod: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let n = high.len();
|
||||
let mut upper = vec![f64::NAN; n];
|
||||
let mut lower = vec![f64::NAN; n];
|
||||
let mut middle = vec![f64::NAN; n];
|
||||
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return (upper, middle, lower);
|
||||
}
|
||||
|
||||
let hh = math::sliding_max(high, timeperiod);
|
||||
let ll = math::sliding_min(low, timeperiod);
|
||||
|
||||
for i in 0..n {
|
||||
if !hh[i].is_nan() {
|
||||
upper[i] = hh[i];
|
||||
lower[i] = ll[i];
|
||||
middle[i] = (upper[i] + lower[i]) / 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
(upper, middle, lower)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CHOPPINESS_INDEX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Choppiness Index — measures market choppiness vs trending.
|
||||
///
|
||||
/// Values near 100 indicate a choppy market; near 0 indicates trending.
|
||||
/// The first `timeperiod` values are `NaN`.
|
||||
pub fn choppiness_index(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n <= timeperiod {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ATR(1) = True Range per bar
|
||||
let mut tr = vec![0.0_f64; n];
|
||||
tr[0] = high[0] - low[0];
|
||||
for i in 1..n {
|
||||
let hl = high[i] - low[i];
|
||||
let hc = (high[i] - close[i - 1]).abs();
|
||||
let lc = (low[i] - close[i - 1]).abs();
|
||||
tr[i] = hl.max(hc).max(lc);
|
||||
}
|
||||
|
||||
// Cumulative TR for rolling sum
|
||||
let mut cum_tr = vec![0.0_f64; n];
|
||||
cum_tr[0] = tr[0];
|
||||
for i in 1..n {
|
||||
cum_tr[i] = cum_tr[i - 1] + tr[i];
|
||||
}
|
||||
|
||||
let log_n = (timeperiod as f64).log10();
|
||||
|
||||
let hh = math::sliding_max(high, timeperiod);
|
||||
let ll = math::sliding_min(low, timeperiod);
|
||||
|
||||
for i in (timeperiod)..n {
|
||||
let prev_cum = cum_tr[i - timeperiod];
|
||||
let sum_tr = cum_tr[i] - prev_cum;
|
||||
let hl_range = hh[i] - ll[i];
|
||||
if hl_range > 0.0 && log_n > 0.0 {
|
||||
result[i] = 100.0 * (sum_tr / hl_range).log10() / log_n;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KELTNER_CHANNELS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Keltner Channels — EMA +/- (multiplier x ATR).
|
||||
///
|
||||
/// # Returns
|
||||
/// `(upper, middle, lower)` arrays.
|
||||
pub fn keltner_channels(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
atr_period: usize,
|
||||
multiplier: f64,
|
||||
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let n = high.len();
|
||||
if timeperiod < 1 || atr_period < 1 || n < timeperiod || n < atr_period {
|
||||
let nan = vec![f64::NAN; n];
|
||||
return (nan.clone(), nan.clone(), nan);
|
||||
}
|
||||
|
||||
let middle = overlap::ema(close, timeperiod);
|
||||
let atr = compute_atr(high, low, close, atr_period);
|
||||
|
||||
let mut upper = vec![f64::NAN; n];
|
||||
let mut lower = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
if !middle[i].is_nan() && !atr[i].is_nan() {
|
||||
let band = multiplier * atr[i];
|
||||
upper[i] = middle[i] + band;
|
||||
lower[i] = middle[i] - band;
|
||||
}
|
||||
}
|
||||
|
||||
(upper, middle, lower)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HULL_MA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Hull Moving Average (HMA).
|
||||
///
|
||||
/// `HMA(n) = WMA(2 * WMA(n/2) - WMA(n), sqrt(n))`
|
||||
pub fn hull_ma(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return vec![f64::NAN; n];
|
||||
}
|
||||
|
||||
let half = (timeperiod / 2).max(1);
|
||||
let sqrt_p = ((timeperiod as f64).sqrt().round() as usize).max(1);
|
||||
|
||||
let wma_full = overlap::wma(close, timeperiod);
|
||||
let wma_half = overlap::wma(close, half);
|
||||
|
||||
// raw = 2 * wma_half - wma_full
|
||||
let mut raw = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
if !wma_full[i].is_nan() && !wma_half[i].is_nan() {
|
||||
raw[i] = 2.0 * wma_half[i] - wma_full[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Find first valid index in raw
|
||||
let first_valid = raw.iter().position(|x| !x.is_nan()).unwrap_or(n);
|
||||
let mut hull = vec![f64::NAN; n];
|
||||
if first_valid < n {
|
||||
let raw_valid = &raw[first_valid..];
|
||||
let hma_slice = overlap::wma(raw_valid, sqrt_p);
|
||||
for (k, &v) in hma_slice.iter().enumerate() {
|
||||
hull[first_valid + k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
hull
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CHANDELIER_EXIT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Chandelier Exit — ATR-based trailing stop levels.
|
||||
///
|
||||
/// # Returns
|
||||
/// `(long_exit, short_exit)` arrays.
|
||||
pub fn chandelier_exit(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
multiplier: f64,
|
||||
) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = high.len();
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return (vec![f64::NAN; n], vec![f64::NAN; n]);
|
||||
}
|
||||
|
||||
let atr = compute_atr(high, low, close, timeperiod);
|
||||
|
||||
let highest_high = math::sliding_max(high, timeperiod);
|
||||
let lowest_low = math::sliding_min(low, timeperiod);
|
||||
|
||||
let mut long_exit = vec![f64::NAN; n];
|
||||
let mut short_exit = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
if !highest_high[i].is_nan() && !atr[i].is_nan() {
|
||||
long_exit[i] = highest_high[i] - multiplier * atr[i];
|
||||
short_exit[i] = lowest_low[i] + multiplier * atr[i];
|
||||
}
|
||||
}
|
||||
|
||||
(long_exit, short_exit)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ICHIMOKU
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ichimoku Cloud (Ichimoku Kinko Hyo).
|
||||
///
|
||||
/// # Returns
|
||||
/// `(tenkan, kijun, senkou_a, senkou_b, chikou)` arrays.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn ichimoku(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
tenkan_period: usize,
|
||||
kijun_period: usize,
|
||||
senkou_b_period: usize,
|
||||
displacement: usize,
|
||||
) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let n = high.len();
|
||||
let nan = || vec![f64::NAN; n];
|
||||
|
||||
if tenkan_period < 1 || kijun_period < 1 || senkou_b_period < 1 {
|
||||
return (nan(), nan(), nan(), nan(), nan());
|
||||
}
|
||||
|
||||
// Helper: rolling (H+L)/2 via shared sliding_max / sliding_min
|
||||
let midpoint_rolling = |period: usize| -> Vec<f64> {
|
||||
let hh = math::sliding_max(high, period);
|
||||
let ll = math::sliding_min(low, period);
|
||||
let mut result = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
if !hh[i].is_nan() {
|
||||
result[i] = (hh[i] + ll[i]) / 2.0;
|
||||
}
|
||||
}
|
||||
result
|
||||
};
|
||||
|
||||
let tenkan = midpoint_rolling(tenkan_period);
|
||||
let kijun = midpoint_rolling(kijun_period);
|
||||
let raw_b = midpoint_rolling(senkou_b_period);
|
||||
|
||||
// Senkou A: (tenkan + kijun) / 2 shifted back `displacement` bars
|
||||
let mut senkou_a = vec![f64::NAN; n];
|
||||
if n > displacement {
|
||||
for i in displacement..n {
|
||||
if !tenkan[i].is_nan() && !kijun[i].is_nan() {
|
||||
senkou_a[i - displacement] = (tenkan[i] + kijun[i]) / 2.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Senkou B: raw_b shifted back `displacement` bars
|
||||
let mut senkou_b = vec![f64::NAN; n];
|
||||
if n > displacement {
|
||||
senkou_b[..n - displacement].copy_from_slice(&raw_b[displacement..]);
|
||||
}
|
||||
|
||||
// Chikou: close shifted forward `displacement` bars
|
||||
let mut chikou = vec![f64::NAN; n];
|
||||
if n > displacement {
|
||||
chikou[displacement..].copy_from_slice(&close[..n - displacement]);
|
||||
}
|
||||
|
||||
(tenkan, kijun, senkou_a, senkou_b, chikou)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PIVOT_POINTS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pivot Points — support / resistance levels computed from the previous bar.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `method` — `"classic"`, `"fibonacci"`, or `"camarilla"`. Returns all-NaN
|
||||
/// vectors for unknown methods.
|
||||
///
|
||||
/// # Returns
|
||||
/// `(pivot, r1, s1, r2, s2)` arrays. Index 0 is always `NaN` (no previous bar).
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn pivot_points(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
method: &str,
|
||||
) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let n = high.len();
|
||||
let mut pivot = vec![f64::NAN; n];
|
||||
let mut r1 = vec![f64::NAN; n];
|
||||
let mut s1 = vec![f64::NAN; n];
|
||||
let mut r2 = vec![f64::NAN; n];
|
||||
let mut s2 = vec![f64::NAN; n];
|
||||
|
||||
let method_lower = method.to_lowercase();
|
||||
if !matches!(method_lower.as_str(), "classic" | "fibonacci" | "camarilla") {
|
||||
// Unknown method — return all NaN
|
||||
return (pivot, r1, s1, r2, s2);
|
||||
}
|
||||
|
||||
for i in 1..n {
|
||||
let ph = high[i - 1];
|
||||
let pl = low[i - 1];
|
||||
let pc = close[i - 1];
|
||||
let hl = ph - pl;
|
||||
let p = (ph + pl + pc) / 3.0;
|
||||
pivot[i] = p;
|
||||
match method_lower.as_str() {
|
||||
"classic" => {
|
||||
r1[i] = 2.0 * p - pl;
|
||||
s1[i] = 2.0 * p - ph;
|
||||
r2[i] = p + hl;
|
||||
s2[i] = p - hl;
|
||||
}
|
||||
"fibonacci" => {
|
||||
r1[i] = p + 0.382 * hl;
|
||||
s1[i] = p - 0.382 * hl;
|
||||
r2[i] = p + 0.618 * hl;
|
||||
s2[i] = p - 0.618 * hl;
|
||||
}
|
||||
"camarilla" => {
|
||||
r1[i] = pc + 1.1 * hl / 12.0;
|
||||
s1[i] = pc - 1.1 * hl / 12.0;
|
||||
r2[i] = pc + 1.1 * hl / 6.0;
|
||||
s2[i] = pc - 1.1 * hl / 6.0;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
(pivot, r1, s1, r2, s2)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Shared test data: 10-bar OHLCV
|
||||
fn sample_ohlcv() -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let high = vec![11.0, 12.0, 13.0, 14.0, 15.0, 14.5, 15.5, 16.0, 15.0, 14.0];
|
||||
let low = vec![9.0, 10.0, 11.0, 12.0, 13.0, 12.5, 13.5, 14.0, 13.0, 12.0];
|
||||
let close = vec![10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 14.5, 15.0, 14.0, 13.0];
|
||||
let volume = vec![
|
||||
100.0, 150.0, 200.0, 250.0, 300.0, 200.0, 350.0, 400.0, 180.0, 220.0,
|
||||
];
|
||||
(high, low, close, volume)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// VWAP tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn vwap_cumulative_basic() {
|
||||
let (h, l, c, v) = sample_ohlcv();
|
||||
let result = vwap(&h, &l, &c, &v, 0);
|
||||
assert_eq!(result.len(), h.len());
|
||||
// First bar: tp = (11+9+10)/3 = 10.0, tpv = 1000.0, vol = 100.0 => 10.0
|
||||
assert!((result[0] - 10.0).abs() < 1e-10);
|
||||
// All values should be non-NaN for cumulative
|
||||
for val in &result {
|
||||
assert!(!val.is_nan());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwap_empty_input() {
|
||||
let result = vwap(&[], &[], &[], &[], 0);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwap_rolling_basic() {
|
||||
let (h, l, c, v) = sample_ohlcv();
|
||||
let result = vwap(&h, &l, &c, &v, 3);
|
||||
assert_eq!(result.len(), h.len());
|
||||
// First 2 values should be NaN
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
// From index 2 onward should be valid
|
||||
assert!(!result[2].is_nan());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// VWMA tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn vwma_basic() {
|
||||
let (_, _, c, v) = sample_ohlcv();
|
||||
let result = vwma(&c, &v, 3);
|
||||
assert_eq!(result.len(), c.len());
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
// Index 2: sum(c*v, 0..3) / sum(v, 0..3) = (1000+1650+2400)/(100+150+200) = 5050/450
|
||||
let expected = (10.0 * 100.0 + 11.0 * 150.0 + 12.0 * 200.0) / (100.0 + 150.0 + 200.0);
|
||||
assert!((result[2] - expected).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwma_empty_input() {
|
||||
let result = vwma(&[], &[], 3);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwma_period_larger_than_data() {
|
||||
let result = vwma(&[1.0, 2.0], &[100.0, 200.0], 5);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert!(result.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// SUPERTREND tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn supertrend_basic() {
|
||||
let (h, l, c, _) = sample_ohlcv();
|
||||
let (st, dir) = supertrend(&h, &l, &c, 3, 2.0);
|
||||
assert_eq!(st.len(), h.len());
|
||||
assert_eq!(dir.len(), h.len());
|
||||
// First 3 bars should be warmup (direction = 0, st = NaN)
|
||||
for i in 0..3 {
|
||||
assert_eq!(dir[i], 0);
|
||||
assert!(st[i].is_nan());
|
||||
}
|
||||
// From bar 3 onward, direction should be 1 or -1
|
||||
for i in 3..h.len() {
|
||||
assert!(dir[i] == 1 || dir[i] == -1);
|
||||
assert!(!st[i].is_nan());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supertrend_empty_input() {
|
||||
let (st, dir) = supertrend(&[], &[], &[], 3, 2.0);
|
||||
assert!(st.is_empty());
|
||||
assert!(dir.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supertrend_insufficient_data() {
|
||||
let (st, dir) = supertrend(&[1.0, 2.0], &[0.5, 1.5], &[1.5, 1.8], 5, 2.0);
|
||||
assert!(st.iter().all(|v| v.is_nan()));
|
||||
assert!(dir.iter().all(|&d| d == 0));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// DONCHIAN tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn donchian_basic() {
|
||||
let (h, l, _, _) = sample_ohlcv();
|
||||
let (upper, middle, lower) = donchian(&h, &l, 3);
|
||||
assert_eq!(upper.len(), h.len());
|
||||
// First 2 are NaN
|
||||
assert!(upper[0].is_nan());
|
||||
assert!(upper[1].is_nan());
|
||||
// Index 2: max(11,12,13)=13, min(9,10,11)=9
|
||||
assert!((upper[2] - 13.0).abs() < 1e-10);
|
||||
assert!((lower[2] - 9.0).abs() < 1e-10);
|
||||
assert!((middle[2] - 11.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn donchian_empty_input() {
|
||||
let (u, m, l) = donchian(&[], &[], 3);
|
||||
assert!(u.is_empty());
|
||||
assert!(m.is_empty());
|
||||
assert!(l.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn donchian_period_1() {
|
||||
let h = vec![5.0, 3.0, 7.0];
|
||||
let l = vec![2.0, 1.0, 4.0];
|
||||
let (upper, middle, lower) = donchian(&h, &l, 1);
|
||||
// Every bar is its own window
|
||||
assert!((upper[0] - 5.0).abs() < 1e-10);
|
||||
assert!((lower[0] - 2.0).abs() < 1e-10);
|
||||
assert!((middle[0] - 3.5).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CHOPPINESS_INDEX tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn choppiness_index_basic() {
|
||||
let (h, l, c, _) = sample_ohlcv();
|
||||
let result = choppiness_index(&h, &l, &c, 3);
|
||||
assert_eq!(result.len(), h.len());
|
||||
// First 3 values should be NaN (timeperiod=3, i+1 > 3 starts at i=3)
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
assert!(result[2].is_nan());
|
||||
// Index 3 should have a valid value (i+1=4 > 3)
|
||||
assert!(!result[3].is_nan());
|
||||
// CI should be between 0 and 100
|
||||
for val in result.iter().filter(|v| !v.is_nan()) {
|
||||
assert!(*val >= 0.0 && *val <= 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choppiness_index_empty_input() {
|
||||
let result = choppiness_index(&[], &[], &[], 3);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// KELTNER_CHANNELS tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn keltner_channels_basic() {
|
||||
let (h, l, c, _) = sample_ohlcv();
|
||||
let (upper, middle, lower) = keltner_channels(&h, &l, &c, 3, 3, 1.5);
|
||||
assert_eq!(upper.len(), h.len());
|
||||
// Where both EMA and ATR are valid, upper > middle > lower
|
||||
for i in 0..h.len() {
|
||||
if !upper[i].is_nan() && !lower[i].is_nan() {
|
||||
assert!(upper[i] > middle[i]);
|
||||
assert!(lower[i] < middle[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keltner_channels_empty_input() {
|
||||
let (u, m, l) = keltner_channels(&[], &[], &[], 3, 3, 1.5);
|
||||
assert!(u.is_empty());
|
||||
assert!(m.is_empty());
|
||||
assert!(l.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HULL_MA tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn hull_ma_basic() {
|
||||
let prices: Vec<f64> = (1..=20).map(|i| i as f64).collect();
|
||||
let result = hull_ma(&prices, 4);
|
||||
assert_eq!(result.len(), prices.len());
|
||||
// Should have some NaN warmup, then valid values
|
||||
let valid_count = result.iter().filter(|v| !v.is_nan()).count();
|
||||
assert!(valid_count > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hull_ma_empty_input() {
|
||||
let result = hull_ma(&[], 4);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hull_ma_period_larger_than_data() {
|
||||
let result = hull_ma(&[1.0, 2.0], 10);
|
||||
assert!(result.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CHANDELIER_EXIT tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn chandelier_exit_basic() {
|
||||
let (h, l, c, _) = sample_ohlcv();
|
||||
let (long_exit, short_exit) = chandelier_exit(&h, &l, &c, 3, 2.0);
|
||||
assert_eq!(long_exit.len(), h.len());
|
||||
assert_eq!(short_exit.len(), h.len());
|
||||
// Where valid, long_exit should be below highest high
|
||||
for i in 0..h.len() {
|
||||
if !long_exit[i].is_nan() {
|
||||
// long_exit = highest_high - multiplier * atr, should be < max high
|
||||
assert!(long_exit[i] < 20.0); // sanity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chandelier_exit_empty_input() {
|
||||
let (le, se) = chandelier_exit(&[], &[], &[], 3, 2.0);
|
||||
assert!(le.is_empty());
|
||||
assert!(se.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// ICHIMOKU tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ichimoku_basic() {
|
||||
// Use a larger dataset for ichimoku
|
||||
let n = 60;
|
||||
let high: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 + 1.0).collect();
|
||||
let low: Vec<f64> = (0..n).map(|i| 100.0 + i as f64 - 1.0).collect();
|
||||
let close: Vec<f64> = (0..n).map(|i| 100.0 + i as f64).collect();
|
||||
|
||||
let (tenkan, kijun, senkou_a, senkou_b, chikou) =
|
||||
ichimoku(&high, &low, &close, 9, 26, 52, 26);
|
||||
|
||||
assert_eq!(tenkan.len(), n);
|
||||
assert_eq!(kijun.len(), n);
|
||||
assert_eq!(senkou_a.len(), n);
|
||||
assert_eq!(senkou_b.len(), n);
|
||||
assert_eq!(chikou.len(), n);
|
||||
|
||||
// Tenkan: period 9, first valid at index 8
|
||||
assert!(tenkan[7].is_nan());
|
||||
assert!(!tenkan[8].is_nan());
|
||||
|
||||
// Kijun: period 26, first valid at index 25
|
||||
assert!(kijun[24].is_nan());
|
||||
assert!(!kijun[25].is_nan());
|
||||
|
||||
// Chikou: close shifted forward by 26 bars
|
||||
assert!(chikou[25].is_nan());
|
||||
assert!(!chikou[26].is_nan());
|
||||
assert!((chikou[26] - close[0]).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ichimoku_empty_input() {
|
||||
let (t, k, sa, sb, ch) = ichimoku(&[], &[], &[], 9, 26, 52, 26);
|
||||
assert!(t.is_empty());
|
||||
assert!(k.is_empty());
|
||||
assert!(sa.is_empty());
|
||||
assert!(sb.is_empty());
|
||||
assert!(ch.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PIVOT_POINTS tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pivot_points_classic() {
|
||||
let h = vec![10.0, 12.0, 11.0];
|
||||
let l = vec![8.0, 9.0, 8.5];
|
||||
let c = vec![9.0, 11.0, 10.0];
|
||||
let (pivot, r1, s1, r2, s2) = pivot_points(&h, &l, &c, "classic");
|
||||
assert_eq!(pivot.len(), 3);
|
||||
// Index 0 is NaN
|
||||
assert!(pivot[0].is_nan());
|
||||
// Index 1: prev bar H=10, L=8, C=9 => P=(10+8+9)/3=9.0
|
||||
assert!((pivot[1] - 9.0).abs() < 1e-10);
|
||||
// R1 = 2*P - L = 18 - 8 = 10
|
||||
assert!((r1[1] - 10.0).abs() < 1e-10);
|
||||
// S1 = 2*P - H = 18 - 10 = 8
|
||||
assert!((s1[1] - 8.0).abs() < 1e-10);
|
||||
// R2 = P + (H-L) = 9 + 2 = 11
|
||||
assert!((r2[1] - 11.0).abs() < 1e-10);
|
||||
// S2 = P - (H-L) = 9 - 2 = 7
|
||||
assert!((s2[1] - 7.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_points_fibonacci() {
|
||||
let h = vec![10.0, 12.0];
|
||||
let l = vec![8.0, 9.0];
|
||||
let c = vec![9.0, 11.0];
|
||||
let (pivot, r1, s1, _, _) = pivot_points(&h, &l, &c, "fibonacci");
|
||||
// Index 1: P = (10+8+9)/3 = 9.0, HL = 2
|
||||
assert!((pivot[1] - 9.0).abs() < 1e-10);
|
||||
assert!((r1[1] - (9.0 + 0.382 * 2.0)).abs() < 1e-10);
|
||||
assert!((s1[1] - (9.0 - 0.382 * 2.0)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_points_camarilla() {
|
||||
let h = vec![10.0, 12.0];
|
||||
let l = vec![8.0, 9.0];
|
||||
let c = vec![9.0, 11.0];
|
||||
let (pivot, r1, s1, _, _) = pivot_points(&h, &l, &c, "camarilla");
|
||||
assert!((pivot[1] - 9.0).abs() < 1e-10);
|
||||
// R1 = C + 1.1 * HL / 12 = 9 + 1.1*2/12
|
||||
assert!((r1[1] - (9.0 + 1.1 * 2.0 / 12.0)).abs() < 1e-10);
|
||||
assert!((s1[1] - (9.0 - 1.1 * 2.0 / 12.0)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_points_unknown_method() {
|
||||
let h = vec![10.0, 12.0];
|
||||
let l = vec![8.0, 9.0];
|
||||
let c = vec![9.0, 11.0];
|
||||
let (pivot, r1, s1, r2, s2) = pivot_points(&h, &l, &c, "unknown");
|
||||
assert!(pivot.iter().all(|v| v.is_nan()));
|
||||
assert!(r1.iter().all(|v| v.is_nan()));
|
||||
assert!(s1.iter().all(|v| v.is_nan()));
|
||||
assert!(r2.iter().all(|v| v.is_nan()));
|
||||
assert!(s2.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_points_empty_input() {
|
||||
let (p, r1, s1, r2, s2) = pivot_points(&[], &[], &[], "classic");
|
||||
assert!(p.is_empty());
|
||||
assert!(r1.is_empty());
|
||||
assert!(s1.is_empty());
|
||||
assert!(r2.is_empty());
|
||||
assert!(s2.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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,9 +26,30 @@ assert!((sma[2] - 2.0).abs() < 1e-10);
|
||||
```
|
||||
*/
|
||||
|
||||
pub mod aggregation;
|
||||
pub mod alerts;
|
||||
pub mod attribution;
|
||||
pub mod backtest;
|
||||
pub mod batch;
|
||||
pub mod chunked;
|
||||
pub mod commission;
|
||||
pub mod crypto;
|
||||
pub mod currency;
|
||||
pub mod cycle;
|
||||
pub mod extended;
|
||||
pub mod futures;
|
||||
pub mod math;
|
||||
pub mod math_ops;
|
||||
pub mod momentum;
|
||||
pub mod options;
|
||||
pub mod overlap;
|
||||
pub mod pattern;
|
||||
pub mod portfolio;
|
||||
pub mod price_transform;
|
||||
pub mod regime;
|
||||
pub mod resampling;
|
||||
pub mod signals;
|
||||
pub mod statistic;
|
||||
pub mod streaming;
|
||||
pub mod volatility;
|
||||
pub mod volume;
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Rolling sum over `timeperiod` bars.
|
||||
/// Compute the rolling sum over `timeperiod` bars.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of length `n`. The first `timeperiod - 1` values
|
||||
/// are `NaN`. Uses an incremental algorithm (add new, subtract old) for O(n).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `real` - Input series.
|
||||
/// * `timeperiod` - Rolling window size (must be >= 1).
|
||||
pub fn sum(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
@@ -18,20 +25,38 @@ pub fn sum(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Rolling maximum over `timeperiod` bars — O(n) via monotonic deque.
|
||||
/// Compute the rolling maximum over `timeperiod` bars.
|
||||
///
|
||||
/// Delegates to [`sliding_max`] for O(n) performance via a monotonic deque.
|
||||
/// The first `timeperiod - 1` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `real` - Input series.
|
||||
/// * `timeperiod` - Rolling window size (must be >= 1).
|
||||
pub fn max(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
sliding_max(real, timeperiod)
|
||||
}
|
||||
|
||||
/// Rolling minimum over `timeperiod` bars — O(n) via monotonic deque.
|
||||
/// Compute the rolling minimum over `timeperiod` bars.
|
||||
///
|
||||
/// Delegates to [`sliding_min`] for O(n) performance via a monotonic deque.
|
||||
/// The first `timeperiod - 1` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `real` - Input series.
|
||||
/// * `timeperiod` - Rolling window size (must be >= 1).
|
||||
pub fn min(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
sliding_min(real, timeperiod)
|
||||
}
|
||||
|
||||
/// Sliding maximum over `timeperiod` bars — O(n) via monotonic deque.
|
||||
/// Compute the sliding maximum over `timeperiod` bars in O(n) time.
|
||||
///
|
||||
/// Equivalent to `max` but uses a monotonic deque for O(n) total time.
|
||||
/// Leading `timeperiod - 1` values are NaN.
|
||||
/// Uses a monotonic decreasing deque so each element is pushed/popped at
|
||||
/// most once. The first `timeperiod - 1` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `real` - Input series.
|
||||
/// * `timeperiod` - Rolling window size (must be >= 1).
|
||||
pub fn sliding_max(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
@@ -56,10 +81,14 @@ pub fn sliding_max(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Sliding minimum over `timeperiod` bars — O(n) via monotonic deque.
|
||||
/// Compute the sliding minimum over `timeperiod` bars in O(n) time.
|
||||
///
|
||||
/// Equivalent to `min` but uses a monotonic deque for O(n) total time.
|
||||
/// Leading `timeperiod - 1` values are NaN.
|
||||
/// Uses a monotonic increasing deque so each element is pushed/popped at
|
||||
/// most once. The first `timeperiod - 1` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `real` - Input series.
|
||||
/// * `timeperiod` - Rolling window size (must be >= 1).
|
||||
pub fn sliding_min(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
@@ -84,6 +113,61 @@ pub fn sliding_min(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Element-wise arithmetic operators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Element-wise addition of two arrays.
|
||||
pub fn add(a: &[f64], b: &[f64]) -> Vec<f64> {
|
||||
a.iter().zip(b.iter()).map(|(&x, &y)| x + y).collect()
|
||||
}
|
||||
|
||||
/// Element-wise subtraction of two arrays.
|
||||
pub fn sub(a: &[f64], b: &[f64]) -> Vec<f64> {
|
||||
a.iter().zip(b.iter()).map(|(&x, &y)| x - y).collect()
|
||||
}
|
||||
|
||||
/// Element-wise multiplication of two arrays.
|
||||
pub fn mult(a: &[f64], b: &[f64]) -> Vec<f64> {
|
||||
a.iter().zip(b.iter()).map(|(&x, &y)| x * y).collect()
|
||||
}
|
||||
|
||||
/// Element-wise division of two arrays (NaN where b=0).
|
||||
pub fn div(a: &[f64], b: &[f64]) -> Vec<f64> {
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(&x, &y)| if y != 0.0 { x / y } else { f64::NAN })
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Element-wise math transforms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
macro_rules! unary_transform {
|
||||
($name:ident, $method:ident) => {
|
||||
pub fn $name(real: &[f64]) -> Vec<f64> {
|
||||
real.iter().map(|&x| x.$method()).collect()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
unary_transform!(math_acos, acos);
|
||||
unary_transform!(math_asin, asin);
|
||||
unary_transform!(math_atan, atan);
|
||||
unary_transform!(math_ceil, ceil);
|
||||
unary_transform!(math_cos, cos);
|
||||
unary_transform!(math_cosh, cosh);
|
||||
unary_transform!(math_exp, exp);
|
||||
unary_transform!(math_floor, floor);
|
||||
unary_transform!(math_ln, ln);
|
||||
unary_transform!(math_log10, log10);
|
||||
unary_transform!(math_sin, sin);
|
||||
unary_transform!(math_sinh, sinh);
|
||||
unary_transform!(math_sqrt, sqrt);
|
||||
unary_transform!(math_tan, tan);
|
||||
unary_transform!(math_tanh, tanh);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Rolling math operators — O(n) sliding window implementations.
|
||||
//!
|
||||
//! - `rolling_sum` — rolling sum over `timeperiod` bars (prefix-sum based)
|
||||
//! - `rolling_max` — rolling maximum (O(n) monotonic deque)
|
||||
//! - `rolling_min` — rolling minimum (O(n) monotonic deque)
|
||||
//! - `rolling_maxindex` — index of rolling maximum
|
||||
//! - `rolling_minindex` — index of rolling minimum
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Rolling sum over `timeperiod` bars using a prefix-sum array.
|
||||
/// Leading `timeperiod - 1` values are NaN.
|
||||
pub fn rolling_sum(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let mut cs = vec![0.0f64; n + 1];
|
||||
for i in 0..n {
|
||||
cs[i + 1] = cs[i] + real[i];
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
result[i] = cs[i + 1] - cs[i + 1 - timeperiod];
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Rolling maximum over `timeperiod` bars (O(n) monotonic deque).
|
||||
/// Delegates to `math::sliding_max`.
|
||||
pub fn rolling_max(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
crate::math::sliding_max(real, timeperiod)
|
||||
}
|
||||
|
||||
/// Rolling minimum over `timeperiod` bars (O(n) monotonic deque).
|
||||
/// Delegates to `math::sliding_min`.
|
||||
pub fn rolling_min(real: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
crate::math::sliding_min(real, timeperiod)
|
||||
}
|
||||
|
||||
/// Index of rolling maximum over `timeperiod` bars.
|
||||
/// Returns 0-based index. During warmup the value is `-1`.
|
||||
pub fn rolling_maxindex(real: &[f64], timeperiod: usize) -> Vec<i64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![-1i64; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let mut dq: VecDeque<usize> = VecDeque::new();
|
||||
for i in 0..n {
|
||||
while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) {
|
||||
dq.pop_front();
|
||||
}
|
||||
while dq.back().map(|&j| real[j] <= real[i]).unwrap_or(false) {
|
||||
dq.pop_back();
|
||||
}
|
||||
dq.push_back(i);
|
||||
if i + 1 >= timeperiod {
|
||||
result[i] = *dq.front().unwrap() as i64;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Index of rolling minimum over `timeperiod` bars.
|
||||
/// Returns 0-based index. During warmup the value is `-1`.
|
||||
pub fn rolling_minindex(real: &[f64], timeperiod: usize) -> Vec<i64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![-1i64; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let mut dq: VecDeque<usize> = VecDeque::new();
|
||||
for i in 0..n {
|
||||
while dq.front().map(|&j| j + timeperiod <= i).unwrap_or(false) {
|
||||
dq.pop_front();
|
||||
}
|
||||
while dq.back().map(|&j| real[j] >= real[i]).unwrap_or(false) {
|
||||
dq.pop_back();
|
||||
}
|
||||
dq.push_back(i);
|
||||
if i + 1 >= timeperiod {
|
||||
result[i] = *dq.front().unwrap() as i64;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rolling_sum() {
|
||||
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let result = rolling_sum(&data, 3);
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
assert!((result[2] - 6.0).abs() < 1e-10); // 1+2+3
|
||||
assert!((result[3] - 9.0).abs() < 1e-10); // 2+3+4
|
||||
assert!((result[4] - 12.0).abs() < 1e-10); // 3+4+5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_max() {
|
||||
let data = vec![1.0, 3.0, 2.0, 5.0, 4.0];
|
||||
let result = rolling_max(&data, 3);
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
assert!((result[2] - 3.0).abs() < 1e-10);
|
||||
assert!((result[3] - 5.0).abs() < 1e-10);
|
||||
assert!((result[4] - 5.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_min() {
|
||||
let data = vec![5.0, 3.0, 4.0, 1.0, 2.0];
|
||||
let result = rolling_min(&data, 3);
|
||||
assert!(result[0].is_nan());
|
||||
assert!(result[1].is_nan());
|
||||
assert!((result[2] - 3.0).abs() < 1e-10);
|
||||
assert!((result[3] - 1.0).abs() < 1e-10);
|
||||
assert!((result[4] - 1.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_maxindex() {
|
||||
let data = vec![1.0, 3.0, 2.0, 5.0, 4.0];
|
||||
let result = rolling_maxindex(&data, 3);
|
||||
assert_eq!(result[0], -1);
|
||||
assert_eq!(result[1], -1);
|
||||
assert_eq!(result[2], 1); // max(1,3,2) at index 1
|
||||
assert_eq!(result[3], 3); // max(3,2,5) at index 3
|
||||
assert_eq!(result[4], 3); // max(2,5,4) at index 3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_minindex() {
|
||||
let data = vec![5.0, 3.0, 4.0, 1.0, 2.0];
|
||||
let result = rolling_minindex(&data, 3);
|
||||
assert_eq!(result[0], -1);
|
||||
assert_eq!(result[1], -1);
|
||||
assert_eq!(result[2], 1); // min(5,3,4) at index 1
|
||||
assert_eq!(result[3], 3); // min(3,4,1) at index 3
|
||||
assert_eq!(result[4], 3); // min(4,1,2) at index 3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_short_input() {
|
||||
let data = vec![1.0, 2.0];
|
||||
let result = rolling_sum(&data, 5);
|
||||
assert!(result.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
//! Momentum indicators.
|
||||
|
||||
use crate::math::{sliding_max, sliding_min};
|
||||
|
||||
/// Relative Strength Index — TA-Lib compatible Wilder smoothing.
|
||||
/// Compute the Relative Strength Index (RSI).
|
||||
///
|
||||
/// Seeds avg_gain/avg_loss with SMA of first `timeperiod` changes.
|
||||
/// Uses branchless gain/loss split: `gain = diff.max(0.0)`, `loss = (-diff).max(0.0)`.
|
||||
/// Returns values in the range `[0, 100]`. Uses Wilder's smoothing method
|
||||
/// (TA-Lib compatible), seeding avg_gain/avg_loss with the SMA of the first
|
||||
/// `timeperiod` price changes. The first `timeperiod` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `close` - Price series.
|
||||
/// * `timeperiod` - Lookback period (typically 14).
|
||||
pub fn rsi(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
@@ -46,7 +49,14 @@ pub fn rsi(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Momentum — `close[i] - close[i - timeperiod]`.
|
||||
/// Compute the Momentum indicator: `close[i] - close[i - timeperiod]`.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of length `n`. The first `timeperiod` values are `NaN`.
|
||||
/// Positive values indicate upward price movement over the lookback window.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `close` - Price series.
|
||||
/// * `timeperiod` - Number of bars to look back (must be >= 1).
|
||||
pub fn mom(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
@@ -59,14 +69,21 @@ pub fn mom(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Stochastic Oscillator — TA-Lib compatible.
|
||||
/// Compute the Stochastic Oscillator (TA-Lib compatible).
|
||||
///
|
||||
/// Returns `(slowk, slowd)`.
|
||||
/// - Fast %K[i] = 100 * (close[i] - min(low, fastk_period)) / (max(high, fastk_period) - min(low, fastk_period))
|
||||
/// - Slow %K = SMA(fast %K, slowk_period)
|
||||
/// - Slow %D = SMA(slow %K, slowd_period)
|
||||
/// Returns `(slow_k, slow_d)`, both in the range `[0, 100]`.
|
||||
/// - Fast %K = 100 * (close - lowest low) / (highest high - lowest low)
|
||||
/// - Slow %K = SMA(fast %K, `slowk_period`)
|
||||
/// - Slow %D = SMA(slow %K, `slowd_period`)
|
||||
///
|
||||
/// Uses O(n) sliding max/min via monotonic deques.
|
||||
/// Uses O(n) sliding max/min via monotonic deques. Both outputs are
|
||||
/// `NaN`-padded until slow %D becomes valid (TA-Lib convention).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` / `close` - OHLC price series (same length).
|
||||
/// * `fastk_period` - Lookback for highest high / lowest low.
|
||||
/// * `slowk_period` - SMA period applied to fast %K.
|
||||
/// * `slowd_period` - SMA period applied to slow %K.
|
||||
pub fn stoch(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
@@ -84,29 +101,42 @@ pub fn stoch(
|
||||
return nan_pair();
|
||||
}
|
||||
|
||||
let max_h = sliding_max(high, fastk_period);
|
||||
let min_l = sliding_min(low, fastk_period);
|
||||
|
||||
let mut slowk = vec![f64::NAN; n];
|
||||
let mut slowd = vec![f64::NAN; n];
|
||||
|
||||
// Fast %K is valid from index fastk_period-1 onward.
|
||||
// Fused pass: compute fast %K inline with sliding max/min.
|
||||
// For typical small windows (5-14), inline scan beats VecDeque overhead.
|
||||
let fastk_start = fastk_period - 1;
|
||||
let mut fastk_valid = vec![0.0; n - fastk_start];
|
||||
let fk_len = n - fastk_start;
|
||||
let mut fastk_valid = vec![0.0_f64; fk_len];
|
||||
|
||||
for i in fastk_start..n {
|
||||
let range = max_h[i] - min_l[i];
|
||||
// Inline sliding max(high) and min(low) over [i - fastk_period + 1 .. i].
|
||||
let win_start = i + 1 - fastk_period;
|
||||
let mut hh = high[win_start];
|
||||
let mut ll = low[win_start];
|
||||
for j in (win_start + 1)..=i {
|
||||
let h = high[j];
|
||||
let l = low[j];
|
||||
if h > hh {
|
||||
hh = h;
|
||||
}
|
||||
if l < ll {
|
||||
ll = l;
|
||||
}
|
||||
}
|
||||
let range = hh - ll;
|
||||
fastk_valid[i - fastk_start] = if range != 0.0 {
|
||||
100.0 * (close[i] - min_l[i]) / range
|
||||
100.0 * (close[i] - ll) / range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
// Slow %K = SMA(fastk_valid, slowk_period); write directly into `slowk` offset by `fastk_start`.
|
||||
// Slow %K = SMA(fastk_valid, slowk_period).
|
||||
crate::overlap::sma_into(&fastk_valid, slowk_period, &mut slowk, fastk_start);
|
||||
|
||||
// Slow %D = SMA(slowk, slowd_period).
|
||||
// The valid part of slowk starts at `fastk_start + slowk_period - 1`.
|
||||
let slowk_valid_start = fastk_start + slowk_period - 1;
|
||||
let slowd_valid_start = slowk_valid_start + slowd_period - 1;
|
||||
|
||||
@@ -249,50 +279,164 @@ fn adx_inner(high: &[f64], low: &[f64], close: &[f64], period: usize) -> AdxInne
|
||||
(b_pdm, b_mdm, b_pdi, b_mdi, b_dx, b_adx)
|
||||
}
|
||||
|
||||
/// Plus Directional Movement (Wilder smoothed). Output length = n (bar 0 is NaN).
|
||||
pub fn plus_dm(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
/// Compute all six ADX-family outputs in a single pass.
|
||||
///
|
||||
/// Returns `(plus_dm, minus_dm, plus_di, minus_di, dx, adx)`.
|
||||
/// Use this when you need multiple ADX-family outputs to avoid redundant
|
||||
/// computation. All values are in `[0, 100]` except DM which is unbounded.
|
||||
/// Warmup: DI/DX valid from index `timeperiod`; ADX from `2 * timeperiod - 1`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` / `close` - OHLC price series (same length).
|
||||
/// * `timeperiod` - Wilder smoothing period (typically 14).
|
||||
pub fn adx_all(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> AdxInnerOutput {
|
||||
adx_inner(high, low, close, timeperiod)
|
||||
}
|
||||
|
||||
/// Internal helper for plus_dm and minus_dm that doesn't allocate dummy close prices.
|
||||
/// Returns (plus_dm, minus_dm) smoothed with Wilder's method.
|
||||
fn dm_only_inner(high: &[f64], low: &[f64], period: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = high.len();
|
||||
let closes = vec![0.0_f64; n];
|
||||
let (pdm, _, _, _, _, _) = adx_inner(high, low, &closes, timeperiod);
|
||||
let mut b_pdm = vec![f64::NAN; n];
|
||||
let mut b_mdm = vec![f64::NAN; n];
|
||||
|
||||
if n < period || period < 1 || n < 2 {
|
||||
return (b_pdm, b_mdm);
|
||||
}
|
||||
|
||||
let m = n - 1;
|
||||
let mut pdm = vec![0.0_f64; m];
|
||||
let mut mdm = vec![0.0_f64; m];
|
||||
|
||||
for i in 0..m {
|
||||
let j = i + 1;
|
||||
let h_diff = high[j] - high[i];
|
||||
let l_diff = low[i] - low[j];
|
||||
pdm[i] = if h_diff > l_diff && h_diff > 0.0 {
|
||||
h_diff
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
mdm[i] = if l_diff > h_diff && l_diff > 0.0 {
|
||||
l_diff
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
if m < period {
|
||||
return (b_pdm, b_mdm);
|
||||
}
|
||||
|
||||
let mut pdm_s = pdm[..period].iter().sum::<f64>();
|
||||
let mut mdm_s = mdm[..period].iter().sum::<f64>();
|
||||
|
||||
b_pdm[period] = pdm_s;
|
||||
b_mdm[period] = mdm_s;
|
||||
|
||||
let decay = (period - 1) as f64 / period as f64;
|
||||
for i in period..m {
|
||||
pdm_s = pdm_s * decay + pdm[i];
|
||||
mdm_s = mdm_s * decay + mdm[i];
|
||||
b_pdm[i + 1] = pdm_s;
|
||||
b_mdm[i + 1] = mdm_s;
|
||||
}
|
||||
|
||||
(b_pdm, b_mdm)
|
||||
}
|
||||
|
||||
/// Compute the Plus Directional Movement (+DM), Wilder smoothed.
|
||||
///
|
||||
/// Measures upward price movement. Returns a `Vec<f64>` of length `n`;
|
||||
/// the first `timeperiod` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` - High and low price series (same length).
|
||||
/// * `timeperiod` - Wilder smoothing period.
|
||||
pub fn plus_dm(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (pdm, _) = dm_only_inner(high, low, timeperiod);
|
||||
pdm
|
||||
}
|
||||
|
||||
/// Minus Directional Movement (Wilder smoothed). Output length = n (bar 0 is NaN).
|
||||
/// Compute the Minus Directional Movement (-DM), Wilder smoothed.
|
||||
///
|
||||
/// Measures downward price movement. Returns a `Vec<f64>` of length `n`;
|
||||
/// the first `timeperiod` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` - High and low price series (same length).
|
||||
/// * `timeperiod` - Wilder smoothing period.
|
||||
pub fn minus_dm(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let closes = vec![0.0_f64; n];
|
||||
let (_, mdm, _, _, _, _) = adx_inner(high, low, &closes, timeperiod);
|
||||
let (_, mdm) = dm_only_inner(high, low, timeperiod);
|
||||
mdm
|
||||
}
|
||||
|
||||
/// Plus Directional Indicator (Wilder smoothed). Output length = n.
|
||||
/// Compute the Plus Directional Indicator (+DI), Wilder smoothed.
|
||||
///
|
||||
/// `+DI = 100 * smoothed(+DM) / smoothed(TR)`. Returns values in `[0, 100]`.
|
||||
/// The first `timeperiod` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` / `close` - OHLC price series (same length).
|
||||
/// * `timeperiod` - Wilder smoothing period.
|
||||
pub fn plus_di(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (_, _, pdi, _, _, _) = adx_inner(high, low, close, timeperiod);
|
||||
pdi
|
||||
}
|
||||
|
||||
/// Minus Directional Indicator (Wilder smoothed). Output length = n.
|
||||
/// Compute the Minus Directional Indicator (-DI), Wilder smoothed.
|
||||
///
|
||||
/// `-DI = 100 * smoothed(-DM) / smoothed(TR)`. Returns values in `[0, 100]`.
|
||||
/// The first `timeperiod` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` / `close` - OHLC price series (same length).
|
||||
/// * `timeperiod` - Wilder smoothing period.
|
||||
pub fn minus_di(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (_, _, _, mdi, _, _) = adx_inner(high, low, close, timeperiod);
|
||||
mdi
|
||||
}
|
||||
|
||||
/// Directional Movement Index: 100 * |+DI − −DI| / (+DI + −DI).
|
||||
/// Compute the Directional Movement Index (DX).
|
||||
///
|
||||
/// `DX = 100 * |+DI - -DI| / (+DI + -DI)`. Returns values in `[0, 100]`.
|
||||
/// The first `timeperiod` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` / `close` - OHLC price series (same length).
|
||||
/// * `timeperiod` - Wilder smoothing period.
|
||||
pub fn dx(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (_, _, _, _, dx_vals, _) = adx_inner(high, low, close, timeperiod);
|
||||
dx_vals
|
||||
}
|
||||
|
||||
/// Average Directional Movement Index (Wilder smoothing of DX).
|
||||
/// Compute the Average Directional Movement Index (ADX).
|
||||
///
|
||||
/// ADX is Wilder's smoothing of DX, measuring trend strength regardless of
|
||||
/// direction. Returns values in `[0, 100]`. The first `2 * timeperiod - 1`
|
||||
/// values are `NaN` (DX warmup + ADX smoothing warmup).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` / `close` - OHLC price series (same length).
|
||||
/// * `timeperiod` - Wilder smoothing period (typically 14).
|
||||
pub fn adx(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (_, _, _, _, _, adx_vals) = adx_inner(high, low, close, timeperiod);
|
||||
adx_vals
|
||||
}
|
||||
|
||||
/// ADX Rating: (ADX[i] + ADX[i − timeperiod]) / 2.
|
||||
/// Compute the ADX Rating (ADXR).
|
||||
///
|
||||
/// `ADXR[i] = (ADX[i] + ADX[i - timeperiod]) / 2`. Smooths ADX further
|
||||
/// by averaging current ADX with its value `timeperiod` bars ago.
|
||||
/// Returns values in `[0, 100]`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` / `close` - OHLC price series (same length).
|
||||
/// * `timeperiod` - Wilder smoothing period (typically 14).
|
||||
pub fn adxr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let adx_vals = adx(high, low, close, timeperiod);
|
||||
// Reuse adx_all to compute ADX once, then derive ADXR from it
|
||||
let (_, _, _, _, _, adx_vals) = adx_inner(high, low, close, timeperiod);
|
||||
let mut result = vec![f64::NAN; n];
|
||||
for i in timeperiod..n {
|
||||
if !adx_vals[i].is_nan() && !adx_vals[i - timeperiod].is_nan() {
|
||||
@@ -302,6 +446,435 @@ pub fn adxr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate of Change variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rate of Change: `(close[i] - close[i-p]) / close[i-p] * 100`.
|
||||
pub fn roc(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
for i in timeperiod..n {
|
||||
let prev = close[i - timeperiod];
|
||||
if prev != 0.0 {
|
||||
result[i] = (close[i] - prev) / prev * 100.0;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Rate of Change Percentage: `(close[i] - close[i-p]) / close[i-p]`.
|
||||
pub fn rocp(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
for i in timeperiod..n {
|
||||
let prev = close[i - timeperiod];
|
||||
if prev != 0.0 {
|
||||
result[i] = (close[i] - prev) / prev;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Rate of Change Ratio: `close[i] / close[i-p]`.
|
||||
pub fn rocr(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
for i in timeperiod..n {
|
||||
let prev = close[i - timeperiod];
|
||||
if prev != 0.0 {
|
||||
result[i] = close[i] / prev;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Rate of Change Ratio x 100: `close[i] / close[i-p] * 100`.
|
||||
pub fn rocr100(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
for i in timeperiod..n {
|
||||
let prev = close[i - timeperiod];
|
||||
if prev != 0.0 {
|
||||
result[i] = close[i] / prev * 100.0;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Williams %R
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Williams %R: `-100 * (HH - close) / (HH - LL)` over the window.
|
||||
/// Returns values in `[-100, 0]`.
|
||||
pub fn willr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
let start = i + 1 - timeperiod;
|
||||
let mut highest = f64::NEG_INFINITY;
|
||||
let mut lowest = f64::INFINITY;
|
||||
for j in start..=i {
|
||||
if high[j] > highest {
|
||||
highest = high[j];
|
||||
}
|
||||
if low[j] < lowest {
|
||||
lowest = low[j];
|
||||
}
|
||||
}
|
||||
let range = highest - lowest;
|
||||
result[i] = if range != 0.0 {
|
||||
-100.0 * (highest - close[i]) / range
|
||||
} else {
|
||||
-50.0
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aroon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aroon indicator. Returns `(aroon_down, aroon_up)`.
|
||||
pub fn aroon(high: &[f64], low: &[f64], timeperiod: usize) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = high.len();
|
||||
let mut aroon_down = vec![f64::NAN; n];
|
||||
let mut aroon_up = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n <= timeperiod {
|
||||
return (aroon_down, aroon_up);
|
||||
}
|
||||
let period_f = timeperiod as f64;
|
||||
let window_size = timeperiod + 1;
|
||||
for i in timeperiod..n {
|
||||
let start = i + 1 - window_size;
|
||||
let mut max_val = high[start];
|
||||
let mut min_val = low[start];
|
||||
let mut max_idx = 0usize;
|
||||
let mut min_idx = 0usize;
|
||||
for j in 0..window_size {
|
||||
if high[start + j] >= max_val {
|
||||
max_val = high[start + j];
|
||||
max_idx = j;
|
||||
}
|
||||
if low[start + j] <= min_val {
|
||||
min_val = low[start + j];
|
||||
min_idx = j;
|
||||
}
|
||||
}
|
||||
aroon_up[i] = 100.0 * (max_idx as f64) / period_f;
|
||||
aroon_down[i] = 100.0 * (min_idx as f64) / period_f;
|
||||
}
|
||||
(aroon_down, aroon_up)
|
||||
}
|
||||
|
||||
/// Aroon Oscillator: `aroon_up - aroon_down`.
|
||||
pub fn aroonosc(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let (down, up) = aroon(high, low, timeperiod);
|
||||
up.iter()
|
||||
.zip(down.iter())
|
||||
.map(|(&u, &d)| {
|
||||
if u.is_nan() || d.is_nan() {
|
||||
f64::NAN
|
||||
} else {
|
||||
u - d
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CCI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Commodity Channel Index: `(tp - SMA(tp)) / (0.015 * MAD)`.
|
||||
pub fn cci(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let tp: Vec<f64> = high
|
||||
.iter()
|
||||
.zip(low.iter())
|
||||
.zip(close.iter())
|
||||
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
|
||||
.collect();
|
||||
for i in (timeperiod - 1)..n {
|
||||
let window = &tp[(i + 1 - timeperiod)..=i];
|
||||
let mean: f64 = window.iter().sum::<f64>() / timeperiod as f64;
|
||||
let mad: f64 = window.iter().map(|&x| (x - mean).abs()).sum::<f64>() / timeperiod as f64;
|
||||
result[i] = if mad != 0.0 {
|
||||
(tp[i] - mean) / (0.015 * mad)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BOP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Balance of Power: `(close - open) / (high - low)`.
|
||||
pub fn bop(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
open.iter()
|
||||
.zip(high.iter())
|
||||
.zip(low.iter())
|
||||
.zip(close.iter())
|
||||
.map(|(((&o, &h), &l), &c)| {
|
||||
let range = h - l;
|
||||
if range != 0.0 {
|
||||
(c - o) / range
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stochastic RSI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Stochastic RSI. Returns `(fastk, fastd)`.
|
||||
pub fn stochrsi(
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
fastk_period: usize,
|
||||
fastd_period: usize,
|
||||
) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let nan_pair = || (vec![f64::NAN; n], vec![f64::NAN; n]);
|
||||
if timeperiod == 0 || fastk_period == 0 || fastd_period == 0 {
|
||||
return nan_pair();
|
||||
}
|
||||
|
||||
let rsi_vals = rsi(close, timeperiod);
|
||||
let rsi_warmup = timeperiod;
|
||||
let k_warmup = rsi_warmup + fastk_period - 1;
|
||||
let d_warmup = k_warmup + fastd_period - 1;
|
||||
|
||||
let mut fastk = vec![f64::NAN; n];
|
||||
let mut fastd = vec![f64::NAN; n];
|
||||
|
||||
for i in k_warmup..n {
|
||||
if rsi_vals[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
let start = i + 1 - fastk_period;
|
||||
if (start..=i).any(|j| rsi_vals[j].is_nan()) {
|
||||
continue;
|
||||
}
|
||||
let mx = rsi_vals[start..=i]
|
||||
.iter()
|
||||
.cloned()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let mn = rsi_vals[start..=i]
|
||||
.iter()
|
||||
.cloned()
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
fastk[i] = if mx != mn {
|
||||
100.0 * (rsi_vals[i] - mn) / (mx - mn)
|
||||
} else {
|
||||
50.0
|
||||
};
|
||||
}
|
||||
|
||||
for i in d_warmup..n {
|
||||
let start = i + 1 - fastd_period;
|
||||
let window = &fastk[start..=i];
|
||||
if window.iter().all(|v| !v.is_nan()) {
|
||||
fastd[i] = window.iter().sum::<f64>() / fastd_period as f64;
|
||||
}
|
||||
}
|
||||
(fastk, fastd)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// APO / PPO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Absolute Price Oscillator: `fast EMA - slow EMA`.
|
||||
pub fn apo(close: &[f64], fastperiod: usize, slowperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if fastperiod == 0 || slowperiod == 0 || fastperiod >= slowperiod {
|
||||
return result;
|
||||
}
|
||||
let fast = crate::overlap::ema(close, fastperiod);
|
||||
let slow = crate::overlap::ema(close, slowperiod);
|
||||
let warmup = slowperiod - 1;
|
||||
for i in warmup..n {
|
||||
if !fast[i].is_nan() && !slow[i].is_nan() {
|
||||
result[i] = fast[i] - slow[i];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Percentage Price Oscillator: `(fast EMA - slow EMA) / slow EMA * 100`.
|
||||
/// Returns `(ppo_line, signal_line, histogram)`.
|
||||
pub fn ppo(
|
||||
close: &[f64],
|
||||
fastperiod: usize,
|
||||
slowperiod: usize,
|
||||
signalperiod: usize,
|
||||
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]);
|
||||
if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod {
|
||||
return nan3();
|
||||
}
|
||||
let fast = crate::overlap::ema(close, fastperiod);
|
||||
let slow = crate::overlap::ema(close, slowperiod);
|
||||
let warmup = slowperiod - 1;
|
||||
|
||||
let mut ppo_line = vec![f64::NAN; n];
|
||||
for i in warmup..n {
|
||||
if !fast[i].is_nan() && !slow[i].is_nan() && slow[i] != 0.0 {
|
||||
ppo_line[i] = (fast[i] - slow[i]) / slow[i] * 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Signal line = EMA of PPO line (only over valid values)
|
||||
let signal = crate::overlap::ema(&ppo_line, signalperiod);
|
||||
let mut signal_line = vec![f64::NAN; n];
|
||||
let mut hist = vec![f64::NAN; n];
|
||||
let sig_warmup = warmup + signalperiod - 1;
|
||||
for i in sig_warmup..n {
|
||||
if !ppo_line[i].is_nan() && !signal[i].is_nan() {
|
||||
signal_line[i] = signal[i];
|
||||
hist[i] = ppo_line[i] - signal[i];
|
||||
}
|
||||
}
|
||||
(ppo_line, signal_line, hist)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CMO
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Chande Momentum Oscillator: `100 * (sum_gains - sum_losses) / (sum_gains + sum_losses)`.
|
||||
pub fn cmo(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod + 1 {
|
||||
return result;
|
||||
}
|
||||
let changes: Vec<f64> = close.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
for i in timeperiod..n {
|
||||
let mut ups = 0.0_f64;
|
||||
let mut downs = 0.0_f64;
|
||||
for ch in &changes[(i - timeperiod)..i] {
|
||||
if *ch > 0.0 {
|
||||
ups += ch;
|
||||
} else {
|
||||
downs -= ch;
|
||||
}
|
||||
}
|
||||
let denom = ups + downs;
|
||||
result[i] = if denom != 0.0 {
|
||||
100.0 * (ups - downs) / denom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TRIX
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// TRIX: 1-period rate of change of triple-smoothed EMA.
|
||||
pub fn trix(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
let warmup = 3 * (timeperiod - 1);
|
||||
|
||||
// Triple EMA: EMA(EMA(EMA(close)))
|
||||
let ema1 = crate::overlap::ema(close, timeperiod);
|
||||
let ema2 = crate::overlap::ema(&ema1, timeperiod);
|
||||
let ema3 = crate::overlap::ema(&ema2, timeperiod);
|
||||
|
||||
for i in (warmup + 1)..n {
|
||||
let prev = ema3[i - 1];
|
||||
if !ema3[i].is_nan() && !prev.is_nan() && prev != 0.0 {
|
||||
result[i] = (ema3[i] - prev) / prev * 100.0;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ultimate Oscillator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ultimate Oscillator: weighted average of buying pressure over three periods.
|
||||
pub fn ultosc(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
timeperiod1: usize,
|
||||
timeperiod2: usize,
|
||||
timeperiod3: usize,
|
||||
) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod1 == 0 || timeperiod2 == 0 || timeperiod3 == 0 || n < 2 {
|
||||
return result;
|
||||
}
|
||||
let max_period = timeperiod1.max(timeperiod2).max(timeperiod3);
|
||||
if n <= max_period {
|
||||
return result;
|
||||
}
|
||||
|
||||
let mut bp = vec![0.0_f64; n];
|
||||
let mut tr = vec![0.0_f64; n];
|
||||
for i in 1..n {
|
||||
let true_low = low[i].min(close[i - 1]);
|
||||
let true_high = high[i].max(close[i - 1]);
|
||||
bp[i] = close[i] - true_low;
|
||||
tr[i] = true_high - true_low;
|
||||
}
|
||||
|
||||
for i in max_period..n {
|
||||
let avg = |period: usize| -> f64 {
|
||||
let sum_bp: f64 = bp[(i + 1 - period)..=i].iter().sum();
|
||||
let sum_tr: f64 = tr[(i + 1 - period)..=i].iter().sum();
|
||||
if sum_tr != 0.0 {
|
||||
sum_bp / sum_tr
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
result[i] =
|
||||
100.0 * (4.0 * avg(timeperiod1) + 2.0 * avg(timeperiod2) + avg(timeperiod3)) / 7.0;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
//! American option pricing via the Barone-Adesi-Whaley (1987) quadratic approximation.
|
||||
|
||||
use super::normal::cdf;
|
||||
use super::pricing::black_scholes_price;
|
||||
use super::OptionKind;
|
||||
|
||||
fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
|
||||
!spot.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| !volatility.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
|| volatility < 0.0
|
||||
}
|
||||
|
||||
/// Compute d1 for BSM given spot S* (used inside the Newton-Raphson loop).
|
||||
fn d1_fn(s: f64, strike: f64, rate: f64, carry: f64, time_to_expiry: f64, volatility: f64) -> f64 {
|
||||
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
|
||||
((s / strike).ln() + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry)
|
||||
/ sigma_sqrt_t
|
||||
}
|
||||
|
||||
/// Find the critical spot price S* for American call early exercise using Newton-Raphson.
|
||||
///
|
||||
/// S* satisfies: C(S*) - (S* - K) = (S*/q2) * (1 - e^{-q*T} * N(d1(S*)))
|
||||
/// Rearranged as F(S*) = 0:
|
||||
/// F(x) = C(x) - (x - K) - (x/q2) * (1 - carry_discount * N(d1(x))) = 0
|
||||
fn find_critical_call(
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
q2: f64,
|
||||
) -> f64 {
|
||||
let carry_discount = (-carry * time_to_expiry).exp();
|
||||
|
||||
// Initial guess: S* ≈ K * q2 / (q2 - 1), clamped to be above strike
|
||||
let mut s = if q2 > 1.0 {
|
||||
strike * q2 / (q2 - 1.0)
|
||||
} else {
|
||||
// q2 <= 1 means the denominator is small/negative; fall back to a safe value
|
||||
strike * 2.0
|
||||
};
|
||||
// Ensure starting guess is positive
|
||||
if s <= 0.0 {
|
||||
s = strike * 1.5;
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
let c = black_scholes_price(
|
||||
s,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
OptionKind::Call,
|
||||
);
|
||||
let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility);
|
||||
let nd1 = cdf(d1);
|
||||
let lhs = c - (s - strike);
|
||||
let rhs = (s / q2) * (1.0 - carry_discount * nd1);
|
||||
let f = lhs - rhs;
|
||||
|
||||
// Derivative of F with respect to s:
|
||||
// dC/ds = e^{-q*T} * N(d1) (BSM delta for call)
|
||||
// d(s - K)/ds = 1
|
||||
// d(rhs)/ds = (1/q2) * (1 - carry_discount * N(d1))
|
||||
// + (s/q2) * (-carry_discount * phi(d1) / (s * vol * sqrt(T)))
|
||||
// = (1/q2) * (1 - carry_discount * N(d1)) - carry_discount * phi(d1) / (q2 * vol * sqrt(T))
|
||||
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
|
||||
let phi_d1 = super::normal::pdf(d1);
|
||||
let d_lhs_ds = carry_discount * nd1 - 1.0;
|
||||
let d_rhs_ds = (1.0 / q2) * (1.0 - carry_discount * nd1)
|
||||
- carry_discount * phi_d1 / (q2 * sigma_sqrt_t);
|
||||
let df = d_lhs_ds - d_rhs_ds;
|
||||
|
||||
if df.abs() < 1e-14 {
|
||||
break;
|
||||
}
|
||||
let step = f / df;
|
||||
s -= step;
|
||||
// Keep s positive
|
||||
if s <= 0.0 {
|
||||
s = strike * 0.1;
|
||||
}
|
||||
if step.abs() < 1e-8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Find the critical spot price S** for American put early exercise using Newton-Raphson.
|
||||
///
|
||||
/// S** satisfies: P(S**) - (K - S**) = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**)))
|
||||
/// F(x) = P(x) - (K - x) + (x/q1) * (1 - carry_discount * N(-d1(x))) = 0
|
||||
fn find_critical_put(
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
q1: f64,
|
||||
) -> f64 {
|
||||
let carry_discount = (-carry * time_to_expiry).exp();
|
||||
|
||||
// Initial guess for put: S** ≈ K * q1 / (q1 - 1)
|
||||
// q1 is negative, so q1 - 1 < 0, and the guess should be below strike.
|
||||
let mut s = if (q1 - 1.0).abs() > 1e-10 {
|
||||
strike * q1 / (q1 - 1.0)
|
||||
} else {
|
||||
strike * 0.5
|
||||
};
|
||||
if s <= 0.0 || s >= strike {
|
||||
s = strike * 0.5;
|
||||
}
|
||||
|
||||
for _ in 0..50 {
|
||||
let p = black_scholes_price(
|
||||
s,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
OptionKind::Put,
|
||||
);
|
||||
let d1 = d1_fn(s, strike, rate, carry, time_to_expiry, volatility);
|
||||
let n_neg_d1 = cdf(-d1);
|
||||
let lhs = p - (strike - s);
|
||||
// rhs = -(s/q1) * (1 - carry_discount * N(-d1))
|
||||
let rhs = -(s / q1) * (1.0 - carry_discount * n_neg_d1);
|
||||
let f = lhs - rhs;
|
||||
|
||||
// Derivative:
|
||||
// dP/ds = -e^{-q*T} * N(-d1) (BSM delta for put = e^{-q*T}*(N(d1)-1))
|
||||
// d(K - s)/ds = -1 so d(lhs)/ds = dP/ds - (-1) = dP/ds + 1
|
||||
// d(rhs)/ds = -(1/q1)*(1 - carry_discount*N(-d1))
|
||||
// + -(s/q1)*carry_discount*phi(d1)/(s*vol*sqrt(T)) [since d(N(-d1))/ds = -phi(d1)*dd1/ds]
|
||||
// = -(1/q1)*(1 - carry_discount*N(-d1))
|
||||
// - carry_discount*phi(d1)/(q1*vol*sqrt(T))
|
||||
let sigma_sqrt_t = volatility * time_to_expiry.sqrt();
|
||||
let phi_d1 = super::normal::pdf(d1);
|
||||
let d_lhs_ds = -carry_discount * n_neg_d1 + 1.0;
|
||||
let d_rhs_ds = -(1.0 / q1) * (1.0 - carry_discount * n_neg_d1)
|
||||
- carry_discount * phi_d1 / (q1 * sigma_sqrt_t);
|
||||
let df = d_lhs_ds - d_rhs_ds;
|
||||
|
||||
if df.abs() < 1e-14 {
|
||||
break;
|
||||
}
|
||||
let step = f / df;
|
||||
s -= step;
|
||||
if s <= 0.0 {
|
||||
s = strike * 0.01;
|
||||
}
|
||||
if s >= strike {
|
||||
s = strike * 0.99;
|
||||
}
|
||||
if step.abs() < 1e-8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// American option price using the Barone-Adesi-Whaley (1987) quadratic approximation.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `spot`: current underlying price
|
||||
/// - `strike`: option strike price
|
||||
/// - `rate`: risk-free rate (annualized, decimal)
|
||||
/// - `carry`: continuous dividend yield / carry rate
|
||||
/// - `time_to_expiry`: time to expiry in years
|
||||
/// - `volatility`: implied vol (annualized, decimal)
|
||||
/// - `kind`: call or put
|
||||
pub fn american_price_baw(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> f64 {
|
||||
if invalid_inputs(spot, strike, time_to_expiry, volatility)
|
||||
|| !rate.is_finite()
|
||||
|| !carry.is_finite()
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
|
||||
// At expiry: immediate exercise value
|
||||
if time_to_expiry == 0.0 {
|
||||
return match kind {
|
||||
OptionKind::Call => (spot - strike).max(0.0),
|
||||
OptionKind::Put => (strike - spot).max(0.0),
|
||||
};
|
||||
}
|
||||
|
||||
// At zero vol: deterministic — exercise if ITM
|
||||
if volatility == 0.0 {
|
||||
return match kind {
|
||||
OptionKind::Call => (spot - strike).max(0.0),
|
||||
OptionKind::Put => (strike - spot).max(0.0),
|
||||
};
|
||||
}
|
||||
|
||||
let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind);
|
||||
|
||||
match kind {
|
||||
OptionKind::Call => {
|
||||
// No early exercise premium when there are no dividends (carry == 0 means q==0
|
||||
// in BSM parameterisation where carry = q).
|
||||
if carry <= 0.0 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let sigma2 = volatility * volatility;
|
||||
let m = 2.0 * rate / sigma2;
|
||||
let n = 2.0 * (rate - carry) / sigma2;
|
||||
let h = 1.0 - (-rate * time_to_expiry).exp();
|
||||
|
||||
if h.abs() < 1e-14 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h;
|
||||
if discriminant < 0.0 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let q2 = (-(n - 1.0) + discriminant.sqrt()) / 2.0;
|
||||
|
||||
// Find critical price S*
|
||||
let s_star = find_critical_call(strike, rate, carry, time_to_expiry, volatility, q2);
|
||||
|
||||
if s_star <= strike {
|
||||
// Degenerate critical price; fall back to European
|
||||
return european;
|
||||
}
|
||||
|
||||
// A2 = (S*/q2) * (1 - e^{-q*T} * N(d1(S*)))
|
||||
let carry_discount = (-carry * time_to_expiry).exp();
|
||||
let d1_star = d1_fn(s_star, strike, rate, carry, time_to_expiry, volatility);
|
||||
let a2 = (s_star / q2) * (1.0 - carry_discount * cdf(d1_star));
|
||||
|
||||
if spot >= s_star {
|
||||
// Immediate exercise is optimal
|
||||
(spot - strike).max(0.0)
|
||||
} else {
|
||||
(european + a2 * (spot / s_star).powf(q2)).max(european)
|
||||
}
|
||||
}
|
||||
|
||||
OptionKind::Put => {
|
||||
// No early exercise when rate == 0 (no time value of money)
|
||||
if rate <= 0.0 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let sigma2 = volatility * volatility;
|
||||
let m = 2.0 * rate / sigma2;
|
||||
let n = 2.0 * (rate - carry) / sigma2;
|
||||
let h = 1.0 - (-rate * time_to_expiry).exp();
|
||||
|
||||
if h.abs() < 1e-14 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let discriminant = (n - 1.0) * (n - 1.0) + 4.0 * m / h;
|
||||
if discriminant < 0.0 {
|
||||
return european;
|
||||
}
|
||||
|
||||
let q1 = (-(n - 1.0) - discriminant.sqrt()) / 2.0;
|
||||
|
||||
// Find critical price S**
|
||||
let s_star_star =
|
||||
find_critical_put(strike, rate, carry, time_to_expiry, volatility, q1);
|
||||
|
||||
if s_star_star <= 0.0 || s_star_star >= strike {
|
||||
return european;
|
||||
}
|
||||
|
||||
// A1 = -(S**/q1) * (1 - e^{-q*T} * N(-d1(S**)))
|
||||
let carry_discount = (-carry * time_to_expiry).exp();
|
||||
let d1_star = d1_fn(s_star_star, strike, rate, carry, time_to_expiry, volatility);
|
||||
let a1 = -(s_star_star / q1) * (1.0 - carry_discount * cdf(-d1_star));
|
||||
|
||||
if spot <= s_star_star {
|
||||
// Immediate exercise is optimal
|
||||
(strike - spot).max(0.0)
|
||||
} else {
|
||||
(european + a1 * (spot / s_star_star).powf(q1)).max(european)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Early exercise premium = american_price - european_bsm_price.
|
||||
///
|
||||
/// Always non-negative for valid inputs.
|
||||
pub fn early_exercise_premium(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> f64 {
|
||||
let american = american_price_baw(spot, strike, rate, carry, time_to_expiry, volatility, kind);
|
||||
let european = black_scholes_price(spot, strike, rate, carry, time_to_expiry, volatility, kind);
|
||||
if american.is_nan() || european.is_nan() {
|
||||
return f64::NAN;
|
||||
}
|
||||
(american - european).max(0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::options::OptionKind;
|
||||
|
||||
#[test]
|
||||
fn american_call_gte_european_call() {
|
||||
let european = crate::options::pricing::black_scholes_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.03,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
);
|
||||
let american = american_price_baw(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call);
|
||||
assert!(american >= european - 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_put_gte_european_put() {
|
||||
let european = crate::options::pricing::black_scholes_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Put,
|
||||
);
|
||||
let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Put);
|
||||
assert!(american >= european - 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_exercise_premium_nonneg() {
|
||||
let prem = early_exercise_premium(100.0, 100.0, 0.05, 0.03, 1.0, 0.2, OptionKind::Call);
|
||||
assert!(prem >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_call_no_dividends_equals_european() {
|
||||
// With no dividends (carry == 0), no early exercise is optimal for calls
|
||||
let european = crate::options::pricing::black_scholes_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
);
|
||||
let american = american_price_baw(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
assert!((american - european).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_price_returns_nan_for_invalid() {
|
||||
let price = american_price_baw(-1.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
assert!(price.is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_price_at_expiry_is_intrinsic() {
|
||||
let call = american_price_baw(110.0, 100.0, 0.05, 0.03, 0.0, 0.2, OptionKind::Call);
|
||||
assert!((call - 10.0).abs() < 1e-10);
|
||||
let put = american_price_baw(90.0, 100.0, 0.05, 0.0, 0.0, 0.2, OptionKind::Put);
|
||||
assert!((put - 10.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_put_itm_has_positive_premium() {
|
||||
// Deep ITM put with high rate should have meaningful early exercise premium
|
||||
let prem = early_exercise_premium(80.0, 100.0, 0.10, 0.0, 1.0, 0.2, OptionKind::Put);
|
||||
assert!(prem >= 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn american_prices_are_finite_for_valid_inputs() {
|
||||
let call = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Call);
|
||||
let put = american_price_baw(100.0, 100.0, 0.05, 0.02, 1.0, 0.25, OptionKind::Put);
|
||||
assert!(call.is_finite());
|
||||
assert!(put.is_finite());
|
||||
}
|
||||
}
|
||||
@@ -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,382 @@
|
||||
//! Digital (binary) option pricing.
|
||||
|
||||
use super::normal::cdf;
|
||||
use super::OptionKind;
|
||||
|
||||
/// Type of digital option payoff.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum DigitalKind {
|
||||
/// Pays 1 unit of cash if option expires in the money.
|
||||
CashOrNothing,
|
||||
/// Pays the underlying asset if option expires in the money.
|
||||
AssetOrNothing,
|
||||
}
|
||||
|
||||
fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
|
||||
!spot.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| !volatility.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
|| volatility < 0.0
|
||||
}
|
||||
|
||||
/// Price a digital (binary) option under BSM.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `spot`: current underlying price
|
||||
/// - `strike`: option strike price
|
||||
/// - `rate`: risk-free rate (annualized, decimal)
|
||||
/// - `carry`: continuous dividend yield / carry rate
|
||||
/// - `time_to_expiry`: time to expiry in years
|
||||
/// - `volatility`: implied vol (annualized, decimal)
|
||||
/// - `option_kind`: call or put
|
||||
/// - `digital_kind`: cash-or-nothing or asset-or-nothing
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn digital_price(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_kind: OptionKind,
|
||||
digital_kind: DigitalKind,
|
||||
) -> f64 {
|
||||
if invalid_inputs(spot, strike, time_to_expiry, volatility)
|
||||
|| !rate.is_finite()
|
||||
|| !carry.is_finite()
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
|
||||
// At expiry: pay intrinsic based on ITM status
|
||||
if time_to_expiry == 0.0 {
|
||||
let itm = match option_kind {
|
||||
OptionKind::Call => spot > strike,
|
||||
OptionKind::Put => spot < strike,
|
||||
};
|
||||
return if itm {
|
||||
match digital_kind {
|
||||
DigitalKind::CashOrNothing => 1.0,
|
||||
DigitalKind::AssetOrNothing => spot,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
let discount = (-rate * time_to_expiry).exp();
|
||||
let carry_discount = (-carry * time_to_expiry).exp();
|
||||
|
||||
// At zero vol: deterministic payoff
|
||||
if volatility == 0.0 {
|
||||
let forward = spot * (carry_discount / discount); // S * e^{(r-q)*T} equivalent: S*e^{-q*T}/e^{-r*T}
|
||||
// forward = S * e^{(r-q)*T}; ITM if forward > K for call
|
||||
let itm = match option_kind {
|
||||
OptionKind::Call => spot * carry_discount > strike * discount,
|
||||
OptionKind::Put => spot * carry_discount < strike * discount,
|
||||
};
|
||||
let _ = forward; // suppress unused warning
|
||||
return if itm {
|
||||
match digital_kind {
|
||||
DigitalKind::CashOrNothing => discount,
|
||||
DigitalKind::AssetOrNothing => spot * carry_discount,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let d1 = ((spot / strike).ln()
|
||||
+ (rate - carry + 0.5 * volatility * volatility) * time_to_expiry)
|
||||
/ sigma_sqrt_t;
|
||||
let d2 = d1 - sigma_sqrt_t;
|
||||
|
||||
match digital_kind {
|
||||
DigitalKind::CashOrNothing => match option_kind {
|
||||
OptionKind::Call => discount * cdf(d2),
|
||||
OptionKind::Put => discount * cdf(-d2),
|
||||
},
|
||||
DigitalKind::AssetOrNothing => match option_kind {
|
||||
OptionKind::Call => spot * carry_discount * cdf(d1),
|
||||
OptionKind::Put => spot * carry_discount * cdf(-d1),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute numerical delta, gamma, and vega for a digital option.
|
||||
///
|
||||
/// Uses central finite differences:
|
||||
/// - delta/gamma: bump spot by ε = spot * 1e-3
|
||||
/// - vega: bump volatility by 1e-3
|
||||
///
|
||||
/// Returns `(delta, gamma, vega)`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn digital_greeks(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
option_kind: OptionKind,
|
||||
digital_kind: DigitalKind,
|
||||
) -> (f64, f64, f64) {
|
||||
let eps = spot * 1e-3;
|
||||
if eps <= 0.0 {
|
||||
return (f64::NAN, f64::NAN, f64::NAN);
|
||||
}
|
||||
|
||||
let price_mid = digital_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
let price_up = digital_price(
|
||||
spot + eps,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
let price_dn = digital_price(
|
||||
spot - eps,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
|
||||
let delta = (price_up - price_dn) / (2.0 * eps);
|
||||
let gamma = (price_up - 2.0 * price_mid + price_dn) / (eps * eps);
|
||||
|
||||
let vol_bump = 1e-3;
|
||||
let vega = if volatility + vol_bump > 0.0 && volatility - vol_bump > 0.0 {
|
||||
let price_vup = digital_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility + vol_bump,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
let price_vdn = digital_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility - vol_bump,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
(price_vup - price_vdn) / (2.0 * vol_bump)
|
||||
} else {
|
||||
// vol too close to zero; one-sided bump
|
||||
let price_vup = digital_price(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
volatility + vol_bump,
|
||||
option_kind,
|
||||
digital_kind,
|
||||
);
|
||||
(price_vup - price_mid) / vol_bump
|
||||
};
|
||||
|
||||
(delta, gamma, vega)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::options::OptionKind;
|
||||
|
||||
#[test]
|
||||
fn cash_or_nothing_call_atm() {
|
||||
// ATM cash-or-nothing call: price = e^{-rT} * N(d2)
|
||||
// At S=K=100, r=0.05, q=0, T=1, σ=0.2:
|
||||
// d1 = (0 + 0.07) / 0.2 = 0.35, d2 = 0.15 → N(0.15) ≈ 0.5596
|
||||
// price ≈ e^{-0.05} * 0.5596 ≈ 0.532
|
||||
let price = digital_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
assert!(
|
||||
price > 0.0 && price < 1.0,
|
||||
"price should be between 0 and 1"
|
||||
);
|
||||
assert!((price - 0.532).abs() < 0.01, "price ≈ 0.532, got {price}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_or_nothing_call_at_zero_vol() {
|
||||
// At zero vol, ITM asset-or-nothing call should equal S * e^{-q*T}
|
||||
let price = digital_price(
|
||||
110.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
OptionKind::Call,
|
||||
DigitalKind::AssetOrNothing,
|
||||
);
|
||||
assert!((price - 110.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_price_returns_nan_for_invalid() {
|
||||
let price = digital_price(
|
||||
-1.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
assert!(price.is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_or_nothing_put_call_parity() {
|
||||
// Cash-or-nothing call + cash-or-nothing put = e^{-rT}
|
||||
let call = digital_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.02,
|
||||
1.0,
|
||||
0.25,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
let put = digital_price(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.02,
|
||||
1.0,
|
||||
0.25,
|
||||
OptionKind::Put,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
let discount = (-0.05_f64).exp();
|
||||
assert!((call + put - discount).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_or_nothing_put_call_parity() {
|
||||
// Asset-or-nothing call + asset-or-nothing put = S * e^{-q*T}
|
||||
let s = 100.0_f64;
|
||||
let q = 0.02_f64;
|
||||
let call = digital_price(
|
||||
s,
|
||||
100.0,
|
||||
0.05,
|
||||
q,
|
||||
1.0,
|
||||
0.25,
|
||||
OptionKind::Call,
|
||||
DigitalKind::AssetOrNothing,
|
||||
);
|
||||
let put = digital_price(
|
||||
s,
|
||||
100.0,
|
||||
0.05,
|
||||
q,
|
||||
1.0,
|
||||
0.25,
|
||||
OptionKind::Put,
|
||||
DigitalKind::AssetOrNothing,
|
||||
);
|
||||
let expected = s * (-q).exp();
|
||||
assert!((call + put - expected).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_greeks_are_finite_for_valid_inputs() {
|
||||
let (delta, gamma, vega) = digital_greeks(
|
||||
100.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
1.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
assert!(delta.is_finite());
|
||||
assert!(gamma.is_finite());
|
||||
assert!(vega.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_at_expiry_itm_returns_intrinsic() {
|
||||
let price = digital_price(
|
||||
110.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
0.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
assert!((price - 1.0).abs() < 1e-10);
|
||||
let price2 = digital_price(
|
||||
110.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
0.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::AssetOrNothing,
|
||||
);
|
||||
assert!((price2 - 110.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_at_expiry_otm_returns_zero() {
|
||||
let price = digital_price(
|
||||
90.0,
|
||||
100.0,
|
||||
0.05,
|
||||
0.0,
|
||||
0.0,
|
||||
0.2,
|
||||
OptionKind::Call,
|
||||
DigitalKind::CashOrNothing,
|
||||
);
|
||||
assert!((price - 0.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Option Greeks.
|
||||
|
||||
use super::normal::{cdf, pdf};
|
||||
use super::pricing::{black_76_price, black_scholes_price};
|
||||
use super::{ExtendedGreeks, 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,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Extended Greeks under Black-Scholes-Merton (closed-form).
|
||||
///
|
||||
/// All inputs must be positive finite; returns NaN fields for invalid inputs.
|
||||
pub fn black_scholes_extended_greeks(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
dividend_yield: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
_kind: OptionKind,
|
||||
) -> ExtendedGreeks {
|
||||
if !bs_inputs_valid(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
dividend_yield,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
) {
|
||||
return ExtendedGreeks {
|
||||
vanna: f64::NAN,
|
||||
volga: f64::NAN,
|
||||
charm: f64::NAN,
|
||||
speed: f64::NAN,
|
||||
color: f64::NAN,
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let carry_discount = (-dividend_yield * time_to_expiry).exp();
|
||||
let d1 = ((spot / strike).ln()
|
||||
+ (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry)
|
||||
/ sigma_sqrt_t;
|
||||
let d2 = d1 - sigma_sqrt_t;
|
||||
let pdf_d1 = pdf(d1);
|
||||
|
||||
let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t);
|
||||
|
||||
let vanna = -carry_discount * pdf_d1 * d2 / volatility;
|
||||
let volga = spot * carry_discount * pdf_d1 * sqrt_t * d1 * d2 / volatility;
|
||||
let charm = -carry_discount
|
||||
* pdf_d1
|
||||
* (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t)
|
||||
/ (2.0 * time_to_expiry * sigma_sqrt_t);
|
||||
let speed = -gamma / spot * (d1 / sigma_sqrt_t + 1.0);
|
||||
let color = -carry_discount * pdf_d1 / (2.0 * spot * time_to_expiry * sigma_sqrt_t)
|
||||
* (2.0 * (rate - dividend_yield) * time_to_expiry + 1.0
|
||||
- d1 * (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t)
|
||||
/ sigma_sqrt_t);
|
||||
|
||||
ExtendedGreeks {
|
||||
vanna,
|
||||
volga,
|
||||
charm,
|
||||
speed,
|
||||
color,
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-dispatched extended Greeks.
|
||||
/// Only BSM is supported with closed-form; Black-76 is not yet supported (returns NaN).
|
||||
pub fn model_extended_greeks(input: OptionEvaluation) -> ExtendedGreeks {
|
||||
let contract = input.contract;
|
||||
match contract.model {
|
||||
PricingModel::BlackScholes => black_scholes_extended_greeks(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.carry,
|
||||
contract.time_to_expiry,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
PricingModel::Black76 => ExtendedGreeks {
|
||||
vanna: f64::NAN,
|
||||
volga: f64::NAN,
|
||||
charm: f64::NAN,
|
||||
speed: f64::NAN,
|
||||
color: f64::NAN,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{black_76_greeks, black_scholes_extended_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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_greeks_finite_for_valid_inputs() {
|
||||
let eg = black_scholes_extended_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
assert!(eg.vanna.is_finite());
|
||||
assert!(eg.volga.is_finite());
|
||||
assert!(eg.charm.is_finite());
|
||||
assert!(eg.speed.is_finite());
|
||||
assert!(eg.color.is_finite());
|
||||
// Volga must be positive (convex in vol)
|
||||
assert!(eg.volga >= 0.0);
|
||||
}
|
||||
}
|
||||
@@ -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,102 @@
|
||||
//! 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 american;
|
||||
pub mod chain;
|
||||
pub mod digital;
|
||||
pub mod greeks;
|
||||
pub mod iv;
|
||||
pub mod normal;
|
||||
pub mod payoff;
|
||||
pub mod pricing;
|
||||
pub mod realized_vol;
|
||||
pub mod surface;
|
||||
|
||||
/// Option side.
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Second-order and cross Greeks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ExtendedGreeks {
|
||||
pub vanna: f64, // ∂Δ/∂σ
|
||||
pub volga: f64, // ∂²V/∂σ² (vomma)
|
||||
pub charm: f64, // ∂Δ/∂t
|
||||
pub speed: f64, // ∂Γ/∂S
|
||||
pub color: f64, // ∂Γ/∂t
|
||||
}
|
||||
|
||||
/// Shared contract fields for model-based option analytics.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OptionContract {
|
||||
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,392 @@
|
||||
//! Pure-Rust (no PyO3, no numpy) strategy payoff and value functions.
|
||||
//!
|
||||
//! NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;`
|
||||
//! for this module to be reachable from the rest of the crate and from the PyO3 bridge.
|
||||
|
||||
use super::pricing::black_scholes_price;
|
||||
use super::OptionKind;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Instrument codes: 0=option, 1=future, 2=stock.
|
||||
const INSTRUMENT_OPTION: i64 = 0;
|
||||
const INSTRUMENT_FUTURE: i64 = 1;
|
||||
const INSTRUMENT_STOCK: i64 = 2;
|
||||
|
||||
/// Side sign from encoded value: 1=long (+1.0), -1=short (-1.0).
|
||||
#[inline]
|
||||
fn side_sign(v: i64) -> f64 {
|
||||
if v == 1 {
|
||||
1.0
|
||||
} else if v == -1 {
|
||||
-1.0
|
||||
} else {
|
||||
f64::NAN
|
||||
}
|
||||
}
|
||||
|
||||
/// Option kind from encoded value: 1=call, -1=put.
|
||||
#[inline]
|
||||
fn option_kind(v: i64) -> Option<OptionKind> {
|
||||
match v {
|
||||
1 => Some(OptionKind::Call),
|
||||
-1 => Some(OptionKind::Put),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// strategy_payoff_dense
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate strategy payoff over a spot grid.
|
||||
///
|
||||
/// Parameters (all slices of length n_legs):
|
||||
/// - `instruments`: 0=option, 1=future, 2=stock
|
||||
/// - `sides`: 1=long, -1=short
|
||||
/// - `option_types`: 1=call, -1=put (ignored for futures/stocks)
|
||||
/// - `strikes`: strike for options
|
||||
/// - `premiums`: premium for options
|
||||
/// - `entry_prices`: entry price for futures/stocks
|
||||
/// - `quantities`, `multipliers`: applied to all instruments
|
||||
///
|
||||
/// Returns a Vec<f64> of length spot_grid.len() with aggregate P&L per spot point.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strategy_payoff_dense(
|
||||
spot_grid: &[f64],
|
||||
instruments: &[i64],
|
||||
sides: &[i64],
|
||||
option_types: &[i64],
|
||||
strikes: &[f64],
|
||||
premiums: &[f64],
|
||||
entry_prices: &[f64],
|
||||
quantities: &[f64],
|
||||
multipliers: &[f64],
|
||||
) -> Vec<f64> {
|
||||
let n_legs = instruments.len();
|
||||
// Validate that all leg slices are the same length; return zeros if not.
|
||||
if sides.len() != n_legs
|
||||
|| option_types.len() != n_legs
|
||||
|| strikes.len() != n_legs
|
||||
|| premiums.len() != n_legs
|
||||
|| entry_prices.len() != n_legs
|
||||
|| quantities.len() != n_legs
|
||||
|| multipliers.len() != n_legs
|
||||
{
|
||||
return vec![0.0; spot_grid.len()];
|
||||
}
|
||||
|
||||
let mut total = vec![0.0_f64; spot_grid.len()];
|
||||
|
||||
for leg_idx in 0..n_legs {
|
||||
let inst = instruments[leg_idx];
|
||||
let sign = side_sign(sides[leg_idx]);
|
||||
if sign.is_nan() {
|
||||
// Invalid side — skip leg (treat as zero contribution).
|
||||
continue;
|
||||
}
|
||||
let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx];
|
||||
|
||||
match inst {
|
||||
INSTRUMENT_OPTION => {
|
||||
let kind = match option_kind(option_types[leg_idx]) {
|
||||
Some(k) => k,
|
||||
None => continue, // Invalid option type — skip.
|
||||
};
|
||||
let k = strikes[leg_idx];
|
||||
let p = premiums[leg_idx];
|
||||
for (i, &s) in spot_grid.iter().enumerate() {
|
||||
let intrinsic = match kind {
|
||||
OptionKind::Call => (s - k).max(0.0),
|
||||
OptionKind::Put => (k - s).max(0.0),
|
||||
};
|
||||
total[i] += leg_scale * (intrinsic - p);
|
||||
}
|
||||
}
|
||||
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
|
||||
let e = entry_prices[leg_idx];
|
||||
for (i, &s) in spot_grid.iter().enumerate() {
|
||||
total[i] += leg_scale * (s - e);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Unknown instrument code — skip leg (NaN would propagate; zeros are safer).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// strategy_value_dense / strategy_value_grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Current BSM value of a strategy at a single spot (pre-expiry).
|
||||
///
|
||||
/// Unlike `strategy_payoff_dense`, this uses BSM pricing for option legs rather
|
||||
/// than intrinsic value.
|
||||
///
|
||||
/// Parameters: same as `strategy_payoff_dense` plus per-leg BSM inputs:
|
||||
/// - `time_to_expiries`: TTE for each option leg (ignored for futures/stocks)
|
||||
/// - `volatilities`: vol for each option leg (ignored for futures/stocks)
|
||||
/// - `rates`: risk-free rate for each leg
|
||||
/// - `carries`: carry/dividend yield for each option leg
|
||||
///
|
||||
/// Returns a scalar f64 (strategy P&L at the given spot).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strategy_value_dense(
|
||||
spot: f64,
|
||||
instruments: &[i64],
|
||||
sides: &[i64],
|
||||
option_types: &[i64],
|
||||
strikes: &[f64],
|
||||
premiums: &[f64],
|
||||
entry_prices: &[f64],
|
||||
quantities: &[f64],
|
||||
multipliers: &[f64],
|
||||
time_to_expiries: &[f64],
|
||||
volatilities: &[f64],
|
||||
rates: &[f64],
|
||||
carries: &[f64],
|
||||
) -> f64 {
|
||||
let n_legs = instruments.len();
|
||||
// Validate that all leg slices are the same length; return NaN if not.
|
||||
if sides.len() != n_legs
|
||||
|| option_types.len() != n_legs
|
||||
|| strikes.len() != n_legs
|
||||
|| premiums.len() != n_legs
|
||||
|| entry_prices.len() != n_legs
|
||||
|| quantities.len() != n_legs
|
||||
|| multipliers.len() != n_legs
|
||||
|| time_to_expiries.len() != n_legs
|
||||
|| volatilities.len() != n_legs
|
||||
|| rates.len() != n_legs
|
||||
|| carries.len() != n_legs
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
|
||||
let mut total = 0.0_f64;
|
||||
|
||||
for leg_idx in 0..n_legs {
|
||||
let inst = instruments[leg_idx];
|
||||
let sign = side_sign(sides[leg_idx]);
|
||||
if sign.is_nan() {
|
||||
continue;
|
||||
}
|
||||
let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx];
|
||||
|
||||
match inst {
|
||||
INSTRUMENT_OPTION => {
|
||||
let kind = match option_kind(option_types[leg_idx]) {
|
||||
Some(k) => k,
|
||||
None => continue,
|
||||
};
|
||||
let bsm = black_scholes_price(
|
||||
spot,
|
||||
strikes[leg_idx],
|
||||
rates[leg_idx],
|
||||
carries[leg_idx],
|
||||
time_to_expiries[leg_idx],
|
||||
volatilities[leg_idx],
|
||||
kind,
|
||||
);
|
||||
total += leg_scale * (bsm - premiums[leg_idx]);
|
||||
}
|
||||
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
|
||||
total += leg_scale * (spot - entry_prices[leg_idx]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// aggregate_greeks_dense
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate BSM Greeks for a multi-leg strategy at a single spot.
|
||||
///
|
||||
/// Parameters (all slices of length n_legs):
|
||||
/// - `instruments`: 0=option, 1=future, 2=stock
|
||||
/// - `sides`: 1=long, -1=short
|
||||
/// - `option_types`: 1=call, -1=put (ignored for futures/stocks)
|
||||
/// - `strikes`: strike price for option legs
|
||||
/// - `volatilities`: implied vol for option legs
|
||||
/// - `time_to_expiries`: TTE in years for option legs
|
||||
/// - `rates`: risk-free rate for each leg
|
||||
/// - `carries`: carry/dividend yield for option legs
|
||||
/// - `quantities`, `multipliers`: applied to all instruments
|
||||
///
|
||||
/// Returns `(delta, gamma, vega, theta, rho)` aggregate across all legs.
|
||||
/// Future/stock legs contribute `leg_scale` to delta only (all other Greeks = 0).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn aggregate_greeks_dense(
|
||||
spot: f64,
|
||||
instruments: &[i64],
|
||||
sides: &[i64],
|
||||
option_types: &[i64],
|
||||
strikes: &[f64],
|
||||
volatilities: &[f64],
|
||||
time_to_expiries: &[f64],
|
||||
rates: &[f64],
|
||||
carries: &[f64],
|
||||
quantities: &[f64],
|
||||
multipliers: &[f64],
|
||||
) -> (f64, f64, f64, f64, f64) {
|
||||
use super::greeks::model_greeks;
|
||||
use super::{OptionContract, OptionEvaluation, PricingModel};
|
||||
|
||||
let n_legs = instruments.len();
|
||||
if sides.len() != n_legs
|
||||
|| option_types.len() != n_legs
|
||||
|| strikes.len() != n_legs
|
||||
|| volatilities.len() != n_legs
|
||||
|| time_to_expiries.len() != n_legs
|
||||
|| rates.len() != n_legs
|
||||
|| carries.len() != n_legs
|
||||
|| quantities.len() != n_legs
|
||||
|| multipliers.len() != n_legs
|
||||
{
|
||||
return (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN);
|
||||
}
|
||||
|
||||
let mut delta = 0.0_f64;
|
||||
let mut gamma = 0.0_f64;
|
||||
let mut vega = 0.0_f64;
|
||||
let mut theta = 0.0_f64;
|
||||
let mut rho = 0.0_f64;
|
||||
|
||||
for i in 0..n_legs {
|
||||
let sign = side_sign(sides[i]);
|
||||
if sign.is_nan() {
|
||||
continue;
|
||||
}
|
||||
let leg_scale = sign * quantities[i] * multipliers[i];
|
||||
|
||||
match instruments[i] {
|
||||
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
|
||||
delta += leg_scale;
|
||||
}
|
||||
INSTRUMENT_OPTION => {
|
||||
let kind = match option_kind(option_types[i]) {
|
||||
Some(k) => k,
|
||||
None => continue,
|
||||
};
|
||||
let greeks = model_greeks(OptionEvaluation {
|
||||
contract: OptionContract {
|
||||
model: PricingModel::BlackScholes,
|
||||
underlying: spot,
|
||||
strike: strikes[i],
|
||||
rate: rates[i],
|
||||
carry: carries[i],
|
||||
time_to_expiry: time_to_expiries[i],
|
||||
kind,
|
||||
},
|
||||
volatility: volatilities[i],
|
||||
});
|
||||
delta += leg_scale * greeks.delta;
|
||||
gamma += leg_scale * greeks.gamma;
|
||||
vega += leg_scale * greeks.vega;
|
||||
theta += leg_scale * greeks.theta;
|
||||
rho += leg_scale * greeks.rho;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(delta, gamma, vega, theta, rho)
|
||||
}
|
||||
|
||||
/// Evaluate `strategy_value_dense` for each point in `spot_grid`.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of length `spot_grid.len()`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strategy_value_grid(
|
||||
spot_grid: &[f64],
|
||||
instruments: &[i64],
|
||||
sides: &[i64],
|
||||
option_types: &[i64],
|
||||
strikes: &[f64],
|
||||
premiums: &[f64],
|
||||
entry_prices: &[f64],
|
||||
quantities: &[f64],
|
||||
multipliers: &[f64],
|
||||
time_to_expiries: &[f64],
|
||||
volatilities: &[f64],
|
||||
rates: &[f64],
|
||||
carries: &[f64],
|
||||
) -> Vec<f64> {
|
||||
spot_grid
|
||||
.iter()
|
||||
.map(|&s| {
|
||||
strategy_value_dense(
|
||||
s,
|
||||
instruments,
|
||||
sides,
|
||||
option_types,
|
||||
strikes,
|
||||
premiums,
|
||||
entry_prices,
|
||||
quantities,
|
||||
multipliers,
|
||||
time_to_expiries,
|
||||
volatilities,
|
||||
rates,
|
||||
carries,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn payoff_single_call() {
|
||||
let grid = vec![90.0, 100.0, 110.0, 120.0];
|
||||
let out = strategy_payoff_dense(
|
||||
&grid,
|
||||
&[0],
|
||||
&[1],
|
||||
&[1],
|
||||
&[100.0],
|
||||
&[5.0],
|
||||
&[0.0],
|
||||
&[1.0],
|
||||
&[1.0],
|
||||
);
|
||||
assert!(out[0] < 0.0); // below strike, loss = premium
|
||||
assert!((out[0] - (-5.0)).abs() < 1e-10);
|
||||
assert!((out[2] - 5.0).abs() < 1e-10); // at 110, intrinsic=10, net=10-5=5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_leg_linear() {
|
||||
let grid = vec![90.0, 100.0, 110.0];
|
||||
let out = strategy_payoff_dense(
|
||||
&grid,
|
||||
&[2],
|
||||
&[1],
|
||||
&[0],
|
||||
&[0.0],
|
||||
&[0.0],
|
||||
&[100.0],
|
||||
&[1.0],
|
||||
&[1.0],
|
||||
);
|
||||
assert!((out[0] - (-10.0)).abs() < 1e-10);
|
||||
assert!((out[1] - 0.0).abs() < 1e-10);
|
||||
assert!((out[2] - 10.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! 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,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put-call parity deviation: `C - P - (S·e^{-q·T} - K·e^{-r·T})`.
|
||||
///
|
||||
/// Returns 0.0 when no arbitrage exists. A non-zero value indicates the
|
||||
/// magnitude of mispricing or data error.
|
||||
pub fn put_call_parity_deviation(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
) -> f64 {
|
||||
if !call_price.is_finite()
|
||||
|| !put_price.is_finite()
|
||||
|| !spot.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !rate.is_finite()
|
||||
|| !carry.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
let pv_forward = spot * (-carry * time_to_expiry).exp();
|
||||
let pv_strike = strike * (-rate * time_to_expiry).exp();
|
||||
call_price - put_price - (pv_forward - pv_strike)
|
||||
}
|
||||
|
||||
/// Lower no-arbitrage bound for the option price.
|
||||
pub fn price_lower_bound(contract: OptionContract) -> f64 {
|
||||
match contract.model {
|
||||
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,445 @@
|
||||
//! Historical (realized) volatility estimators and volatility cone.
|
||||
|
||||
/// Rolling close-to-close realized volatility.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of the same length as `close`. The first `window` values
|
||||
/// are NaN (we need `window` log-returns, which require `window+1` prices, so the
|
||||
/// first valid output sits at index `window`).
|
||||
///
|
||||
/// Annualization: `sqrt(sum(r²) / window * trading_days)`.
|
||||
pub fn close_to_close_vol(close: &[f64], window: usize, trading_days: f64) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if window == 0 || n <= window {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Precompute log-returns; returns[i] = ln(close[i+1] / close[i])
|
||||
let mut returns = vec![f64::NAN; n - 1];
|
||||
for i in 0..(n - 1) {
|
||||
if close[i] > 0.0 && close[i + 1] > 0.0 {
|
||||
returns[i] = (close[i + 1] / close[i]).ln();
|
||||
}
|
||||
}
|
||||
|
||||
// Rolling sum of squared returns over `window` bars.
|
||||
// The output at position `end` (in the original close array) uses
|
||||
// returns[end-window .. end-1], i.e. `window` returns.
|
||||
for end in window..n {
|
||||
let slice = &returns[(end - window)..end];
|
||||
let sum_sq: f64 = slice.iter().map(|&r| r * r).sum();
|
||||
let var = sum_sq / window as f64 * trading_days;
|
||||
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling Parkinson high-low realized volatility estimator.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of the same length as `high`. The first `window-1` values
|
||||
/// are NaN.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
pub fn parkinson_vol(high: &[f64], low: &[f64], window: usize, trading_days: f64) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if window == 0 || n < window || low.len() != n {
|
||||
return out;
|
||||
}
|
||||
|
||||
let factor = 1.0 / (4.0 * 2_f64.ln());
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let mut sum_sq = 0.0;
|
||||
let mut valid = true;
|
||||
for i in start..=end {
|
||||
if high[i] <= 0.0 || low[i] <= 0.0 || !high[i].is_finite() || !low[i].is_finite() {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
let u = (high[i] / low[i]).ln();
|
||||
sum_sq += u * u;
|
||||
}
|
||||
if valid {
|
||||
let var = factor * sum_sq / window as f64 * trading_days;
|
||||
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling Garman-Klass OHLC realized volatility estimator.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of the same length as the inputs. The first `window-1`
|
||||
/// values are NaN. All four slices must have the same length.
|
||||
pub fn garman_klass_vol(
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Vec<f64> {
|
||||
let n = open.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n {
|
||||
return out;
|
||||
}
|
||||
|
||||
let ln2 = 2_f64.ln();
|
||||
|
||||
// Precompute per-bar GK contributions.
|
||||
let mut gk = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let o = open[i];
|
||||
let h = high[i];
|
||||
let l = low[i];
|
||||
let c = close[i];
|
||||
if o > 0.0
|
||||
&& h > 0.0
|
||||
&& l > 0.0
|
||||
&& c > 0.0
|
||||
&& o.is_finite()
|
||||
&& h.is_finite()
|
||||
&& l.is_finite()
|
||||
&& c.is_finite()
|
||||
{
|
||||
let u = (h / o).ln();
|
||||
let d = (l / o).ln();
|
||||
let ci = (c / o).ln();
|
||||
gk[i] = 0.5 * (u - d).powi(2) - (2.0 * ln2 - 1.0) * ci * ci;
|
||||
}
|
||||
}
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let slice = &gk[start..=end];
|
||||
if slice.iter().all(|v| v.is_finite()) {
|
||||
let sum: f64 = slice.iter().sum();
|
||||
let var = sum / window as f64 * trading_days;
|
||||
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the Rogers-Satchell per-bar variance contribution.
|
||||
fn rs_bar(open: f64, high: f64, low: f64, close: f64) -> f64 {
|
||||
let u = (high / close).ln();
|
||||
let d = (low / close).ln();
|
||||
let uo = (high / open).ln();
|
||||
let do_ = (low / open).ln();
|
||||
u * uo + d * do_
|
||||
}
|
||||
|
||||
/// Rolling Rogers-Satchell OHLC realized volatility estimator.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of the same length as the inputs. The first `window-1`
|
||||
/// values are NaN. All four slices must have the same length.
|
||||
pub fn rogers_satchell_vol(
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Vec<f64> {
|
||||
let n = open.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Precompute per-bar RS contributions.
|
||||
let mut rs = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let o = open[i];
|
||||
let h = high[i];
|
||||
let l = low[i];
|
||||
let c = close[i];
|
||||
if o > 0.0
|
||||
&& h > 0.0
|
||||
&& l > 0.0
|
||||
&& c > 0.0
|
||||
&& o.is_finite()
|
||||
&& h.is_finite()
|
||||
&& l.is_finite()
|
||||
&& c.is_finite()
|
||||
{
|
||||
rs[i] = rs_bar(o, h, l, c);
|
||||
}
|
||||
}
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let slice = &rs[start..=end];
|
||||
if slice.iter().all(|v| v.is_finite()) {
|
||||
let sum: f64 = slice.iter().sum();
|
||||
let var = sum / window as f64 * trading_days;
|
||||
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling Yang-Zhang OHLC realized volatility estimator.
|
||||
///
|
||||
/// Handles overnight gaps. Returns a `Vec<f64>` of the same length as the inputs.
|
||||
/// The first `window` values are NaN (we need `window` bars plus the prior close
|
||||
/// for overnight returns, so valid output starts at index `window`).
|
||||
/// All four slices must have the same length.
|
||||
pub fn yang_zhang_vol(
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
window: usize,
|
||||
trading_days: f64,
|
||||
) -> Vec<f64> {
|
||||
let n = open.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if window == 0 || n <= window || high.len() != n || low.len() != n || close.len() != n {
|
||||
return out;
|
||||
}
|
||||
|
||||
let k = 0.34 / (1.34 + (window as f64 + 1.0) / (window as f64 - 1.0).max(1e-10));
|
||||
|
||||
// Precompute per-bar components; index 0 has no overnight return.
|
||||
// overnight[i] = ln(O_i / C_{i-1}), valid for i >= 1
|
||||
// openclose[i] = ln(C_i / O_i)
|
||||
// rs[i] = Rogers-Satchell for bar i
|
||||
let mut overnight = vec![f64::NAN; n];
|
||||
let mut openclose = vec![f64::NAN; n];
|
||||
let mut rs = vec![f64::NAN; n];
|
||||
|
||||
for i in 0..n {
|
||||
let o = open[i];
|
||||
let h = high[i];
|
||||
let l = low[i];
|
||||
let c = close[i];
|
||||
if o > 0.0
|
||||
&& h > 0.0
|
||||
&& l > 0.0
|
||||
&& c > 0.0
|
||||
&& o.is_finite()
|
||||
&& h.is_finite()
|
||||
&& l.is_finite()
|
||||
&& c.is_finite()
|
||||
{
|
||||
openclose[i] = (c / o).ln();
|
||||
rs[i] = rs_bar(o, h, l, c);
|
||||
|
||||
if i > 0 {
|
||||
let prev_c = close[i - 1];
|
||||
if prev_c > 0.0 && prev_c.is_finite() {
|
||||
overnight[i] = (o / prev_c).ln();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Valid windows start at index `window` (using bars [end-window+1 .. end],
|
||||
// all of which have valid overnight returns since they start at index >= 1).
|
||||
for end in window..n {
|
||||
let start = end + 1 - window; // start >= 1 because end >= window
|
||||
|
||||
let o_slice = &overnight[start..=end];
|
||||
let c_slice = &openclose[start..=end];
|
||||
let r_slice = &rs[start..=end];
|
||||
|
||||
if !o_slice.iter().all(|v| v.is_finite())
|
||||
|| !c_slice.iter().all(|v| v.is_finite())
|
||||
|| !r_slice.iter().all(|v| v.is_finite())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let w = window as f64;
|
||||
|
||||
let o_sum: f64 = o_slice.iter().sum();
|
||||
let o_sum_sq: f64 = o_slice.iter().map(|&x| x * x).sum();
|
||||
let overnight_var = o_sum_sq / (w - 1.0) - (o_sum / w).powi(2) * w / (w - 1.0);
|
||||
|
||||
let c_sum: f64 = c_slice.iter().sum();
|
||||
let c_sum_sq: f64 = c_slice.iter().map(|&x| x * x).sum();
|
||||
let openclose_var = c_sum_sq / (w - 1.0) - (c_sum / w).powi(2) * w / (w - 1.0);
|
||||
|
||||
let rs_sum: f64 = r_slice.iter().sum();
|
||||
let rs_var = rs_sum / w;
|
||||
|
||||
let yz_var = overnight_var + k * openclose_var + (1.0 - k) * rs_var;
|
||||
let annualized = yz_var * trading_days;
|
||||
out[end] = if annualized >= 0.0 {
|
||||
annualized.sqrt()
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Summary statistics of realized vol distribution for one window length.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct VolConeSlice {
|
||||
pub window: usize,
|
||||
pub min: f64,
|
||||
pub p25: f64,
|
||||
pub median: f64,
|
||||
pub p75: f64,
|
||||
pub max: f64,
|
||||
}
|
||||
|
||||
/// Compute a percentile via linear interpolation on a sorted slice.
|
||||
///
|
||||
/// `sorted` must be non-empty and already sorted ascending.
|
||||
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
|
||||
let n = sorted.len();
|
||||
if n == 1 {
|
||||
return sorted[0];
|
||||
}
|
||||
let idx = (n - 1) as f64 * p;
|
||||
let lo = idx.floor() as usize;
|
||||
let hi = idx.ceil() as usize;
|
||||
let frac = idx - lo as f64;
|
||||
sorted[lo] + frac * (sorted[hi] - sorted[lo])
|
||||
}
|
||||
|
||||
/// Compute vol cone: distribution of realized vols across multiple window lengths.
|
||||
///
|
||||
/// For each window in `windows`, the close-to-close rolling vol is computed,
|
||||
/// NaN values are filtered out, and the distribution statistics (min, p25,
|
||||
/// median, p75, max) are derived via linear interpolation.
|
||||
pub fn vol_cone(close: &[f64], windows: &[usize], trading_days: f64) -> Vec<VolConeSlice> {
|
||||
windows
|
||||
.iter()
|
||||
.map(|&w| {
|
||||
let vols = close_to_close_vol(close, w, trading_days);
|
||||
let mut valid: Vec<f64> = vols.into_iter().filter(|v| v.is_finite()).collect();
|
||||
valid.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
|
||||
if valid.is_empty() {
|
||||
return VolConeSlice {
|
||||
window: w,
|
||||
min: f64::NAN,
|
||||
p25: f64::NAN,
|
||||
median: f64::NAN,
|
||||
p75: f64::NAN,
|
||||
max: f64::NAN,
|
||||
};
|
||||
}
|
||||
|
||||
VolConeSlice {
|
||||
window: w,
|
||||
min: valid[0],
|
||||
p25: percentile_sorted(&valid, 0.25),
|
||||
median: percentile_sorted(&valid, 0.5),
|
||||
p75: percentile_sorted(&valid, 0.75),
|
||||
max: *valid.last().unwrap(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fake_prices(n: usize) -> Vec<f64> {
|
||||
// simple synthetic price series
|
||||
let mut prices = vec![100.0_f64; n];
|
||||
for i in 1..n {
|
||||
prices[i] = prices[i - 1] * (1.0 + 0.01 * (i as f64 % 7_f64 - 3.0) * 0.01);
|
||||
}
|
||||
prices
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_to_close_returns_nans_for_warmup() {
|
||||
let close = fake_prices(100);
|
||||
let result = close_to_close_vol(&close, 20, 252.0);
|
||||
assert_eq!(result.len(), 100);
|
||||
// first 20 values should be NaN (window-1 of returns warmup + 1 for diff)
|
||||
for i in 0..20 {
|
||||
assert!(result[i].is_nan(), "result[{i}] should be NaN");
|
||||
}
|
||||
assert!(result[20].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parkinson_vol_is_positive() {
|
||||
let close = fake_prices(100);
|
||||
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
|
||||
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
|
||||
let result = parkinson_vol(&high, &low, 20, 252.0);
|
||||
for v in result.iter().skip(19) {
|
||||
assert!(v.is_finite() && *v >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vol_cone_is_ordered() {
|
||||
let close = fake_prices(300);
|
||||
let cones = vol_cone(&close, &[20, 60], 252.0);
|
||||
assert_eq!(cones.len(), 2);
|
||||
for cone in &cones {
|
||||
assert!(cone.min <= cone.p25);
|
||||
assert!(cone.p25 <= cone.median);
|
||||
assert!(cone.median <= cone.p75);
|
||||
assert!(cone.p75 <= cone.max);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garman_klass_returns_nans_for_warmup() {
|
||||
let close = fake_prices(50);
|
||||
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
|
||||
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
|
||||
let result = garman_klass_vol(&close, &high, &low, &close, 10, 252.0);
|
||||
assert_eq!(result.len(), 50);
|
||||
for i in 0..9 {
|
||||
assert!(result[i].is_nan(), "result[{i}] should be NaN");
|
||||
}
|
||||
assert!(result[9].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rogers_satchell_returns_nans_for_warmup() {
|
||||
let close = fake_prices(50);
|
||||
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
|
||||
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
|
||||
let result = rogers_satchell_vol(&close, &high, &low, &close, 10, 252.0);
|
||||
assert_eq!(result.len(), 50);
|
||||
for i in 0..9 {
|
||||
assert!(result[i].is_nan(), "result[{i}] should be NaN");
|
||||
}
|
||||
assert!(result[9].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yang_zhang_returns_nans_for_warmup() {
|
||||
let close = fake_prices(50);
|
||||
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
|
||||
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
|
||||
let result = yang_zhang_vol(&close, &high, &low, &close, 10, 252.0);
|
||||
assert_eq!(result.len(), 50);
|
||||
for i in 0..10 {
|
||||
assert!(result[i].is_nan(), "result[{i}] should be NaN");
|
||||
}
|
||||
assert!(result[10].is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_lengths_return_all_nan() {
|
||||
let a = vec![100.0_f64; 20];
|
||||
let b = vec![101.0_f64; 15]; // wrong length
|
||||
let result = parkinson_vol(&a, &b, 5, 252.0);
|
||||
assert!(result.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_larger_than_data_returns_all_nan() {
|
||||
let close = fake_prices(10);
|
||||
let result = close_to_close_vol(&close, 20, 252.0);
|
||||
assert!(result.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! 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)
|
||||
}
|
||||
|
||||
/// Expected ±1σ move over `days_to_expiry` calendar days.
|
||||
///
|
||||
/// Returns `(lower_move, upper_move)` as absolute changes from `spot`.
|
||||
/// Example: if spot=100 and upper_move=5.0 then the 1σ upper bound is 105.
|
||||
///
|
||||
/// Uses the log-normal approximation: `spot × e^{±σ√(days/trading_days)} − spot`.
|
||||
pub fn expected_move(
|
||||
spot: f64,
|
||||
iv: f64,
|
||||
days_to_expiry: f64,
|
||||
trading_days_per_year: f64,
|
||||
) -> (f64, f64) {
|
||||
if !spot.is_finite()
|
||||
|| !iv.is_finite()
|
||||
|| !days_to_expiry.is_finite()
|
||||
|| !trading_days_per_year.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| iv < 0.0
|
||||
|| days_to_expiry < 0.0
|
||||
|| trading_days_per_year <= 0.0
|
||||
{
|
||||
return (f64::NAN, f64::NAN);
|
||||
}
|
||||
let sigma_sqrt_t = iv * (days_to_expiry / trading_days_per_year).sqrt();
|
||||
let upper = spot * sigma_sqrt_t.exp() - spot;
|
||||
let lower = spot * (-sigma_sqrt_t).exp() - spot;
|
||||
(lower, upper)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{atm_iv, smile_metrics, term_structure_slope};
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,14 @@
|
||||
//! All functions return a `Vec<f64>` of the same length as the input.
|
||||
//! Leading values are `f64::NAN` for the warm-up period.
|
||||
|
||||
/// Simple Moving Average over `timeperiod` bars.
|
||||
/// Compute the Simple Moving Average (SMA) over a rolling window.
|
||||
///
|
||||
/// Returns a `Vec<f64>` of the same length as `close`. The first
|
||||
/// `timeperiod - 1` values are `NaN` (warmup period).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `close` - Price series.
|
||||
/// * `timeperiod` - Rolling window size (must be >= 1).
|
||||
///
|
||||
/// # Edge Cases
|
||||
/// Returns all-NaN when `timeperiod < 1` or `close.len() < timeperiod`.
|
||||
@@ -14,8 +21,17 @@ pub fn sma(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Simple Moving Average written directly into `dest` starting at `dest_offset`.
|
||||
/// Leaves values before `dest_offset + timeperiod - 1` untouched (e.g. they can be NaN).
|
||||
/// Write a Simple Moving Average directly into a pre-allocated buffer.
|
||||
///
|
||||
/// Values before `dest_offset + timeperiod - 1` are left untouched.
|
||||
/// This avoids an intermediate allocation when composing indicators
|
||||
/// (e.g., Stochastic slow %K and slow %D).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `src` - Input price series.
|
||||
/// * `timeperiod` - Rolling window size (must be >= 1).
|
||||
/// * `dest` - Output buffer (must be at least `dest_offset + src.len()` long).
|
||||
/// * `dest_offset` - Starting index in `dest` to write results.
|
||||
pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: usize) {
|
||||
let n = src.len();
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
@@ -66,7 +82,15 @@ pub fn sma_into(src: &[f64], timeperiod: usize, dest: &mut [f64], dest_offset: u
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential Moving Average — seeded with SMA of first `timeperiod` bars.
|
||||
/// Compute the Exponential Moving Average (EMA).
|
||||
///
|
||||
/// The EMA is seeded with the SMA of the first `timeperiod` bars and uses
|
||||
/// a smoothing factor of `k = 2 / (timeperiod + 1)`. Returns a `Vec<f64>`
|
||||
/// of the same length as `close`; the first `timeperiod - 1` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `close` - Price series.
|
||||
/// * `timeperiod` - Lookback period (must be >= 1).
|
||||
pub fn ema(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
@@ -82,10 +106,15 @@ pub fn ema(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Weighted Moving Average — O(n) incremental algorithm using running weighted sum.
|
||||
/// Compute the Weighted Moving Average (WMA).
|
||||
///
|
||||
/// Recurrence: `T[i] = T[i-1] + n*close[i] - S[i-1]`
|
||||
/// where `S[i]` is the rolling sum over `timeperiod` bars.
|
||||
/// Assigns linearly increasing weights (1, 2, ..., timeperiod) to the window.
|
||||
/// Uses an O(n) incremental recurrence to avoid recomputing weights each bar.
|
||||
/// Returns a `Vec<f64>` of length `n`; the first `timeperiod - 1` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `close` - Price series.
|
||||
/// * `timeperiod` - Rolling window size (must be >= 1).
|
||||
pub fn wma(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
@@ -157,10 +186,43 @@ pub fn wma(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Bollinger Bands — returns `(upper, middle, lower)`.
|
||||
/// Compute Bollinger Bands, returning `(upper, middle, lower)`.
|
||||
///
|
||||
/// Middle is SMA; bands are `± nbdev * stddev`.
|
||||
/// Uses O(n) sliding `sum` and `sum_sq` windows for mean and variance.
|
||||
/// The middle band is the SMA; upper and lower bands are offset by
|
||||
/// `nbdevup` and `nbdevdn` standard deviations respectively. Uses
|
||||
/// Welford's rolling algorithm for numerically stable variance in O(n).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `close` - Price series.
|
||||
/// * `timeperiod` - SMA / standard deviation window (must be >= 1).
|
||||
/// * `nbdevup` - Number of standard deviations above the mean for the upper band.
|
||||
/// * `nbdevdn` - Number of standard deviations below the mean for the lower band.
|
||||
///
|
||||
/// # Returns
|
||||
/// `(upper, middle, lower)` -- each `Vec<f64>` of length `n`. The first
|
||||
/// `timeperiod - 1` values in each vector are `NaN`.
|
||||
///
|
||||
/// ## Welford's rolling algorithm
|
||||
///
|
||||
/// We maintain `mean` and `m2` (sum of squared deviations from the current
|
||||
/// mean) across a sliding window of size `N`. When a new value `x_new`
|
||||
/// replaces an old value `x_old` (window size stays constant):
|
||||
///
|
||||
/// ```text
|
||||
/// delta = x_new - x_old
|
||||
/// old_mean = mean
|
||||
/// mean += delta / N
|
||||
/// m2 += delta * ((x_new - mean) + (x_old - old_mean))
|
||||
///
|
||||
/// variance = m2 / N // population variance
|
||||
/// stddev = sqrt(variance)
|
||||
/// ```
|
||||
///
|
||||
/// The initial window is seeded using the standard (non-rolling) Welford
|
||||
/// incremental algorithm.
|
||||
///
|
||||
/// This avoids the catastrophic cancellation inherent in the naïve
|
||||
/// `Σx²/N − mean²` formula when values are large but close together.
|
||||
pub fn bbands(
|
||||
close: &[f64],
|
||||
timeperiod: usize,
|
||||
@@ -177,90 +239,135 @@ pub fn bbands(
|
||||
let mut lower = vec![f64::NAN; n];
|
||||
let p = timeperiod as f64;
|
||||
|
||||
// Seed sliding sums for the first window.
|
||||
#[cfg(feature = "simd")]
|
||||
let (mut sum, mut sum_sq) = {
|
||||
use wide::f64x4;
|
||||
let p_data = &close[..timeperiod];
|
||||
let mut sum_simd = f64x4::splat(0.0);
|
||||
let mut sq_simd = f64x4::splat(0.0);
|
||||
let mut chunks = p_data.chunks_exact(4);
|
||||
for chunk in &mut chunks {
|
||||
let vals = f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
|
||||
sum_simd += vals;
|
||||
sq_simd += vals * vals;
|
||||
}
|
||||
let s_arr = sum_simd.to_array();
|
||||
let sq_arr = sq_simd.to_array();
|
||||
let mut sum = s_arr[0] + s_arr[1] + s_arr[2] + s_arr[3];
|
||||
let mut sum_sq = sq_arr[0] + sq_arr[1] + sq_arr[2] + sq_arr[3];
|
||||
for &v in chunks.remainder() {
|
||||
sum += v;
|
||||
sum_sq += v * v;
|
||||
}
|
||||
(sum, sum_sq)
|
||||
};
|
||||
// --- Seed: build initial mean and m2 for the first window using
|
||||
// Welford's incremental (non-rolling) algorithm. ---
|
||||
let mut mean = 0.0_f64;
|
||||
let mut m2 = 0.0_f64;
|
||||
for (k, &x) in close[..timeperiod].iter().enumerate() {
|
||||
let count = (k + 1) as f64;
|
||||
let delta = x - mean;
|
||||
mean += delta / count;
|
||||
let delta2 = x - mean;
|
||||
m2 += delta * delta2;
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "simd"))]
|
||||
let (mut sum, mut sum_sq) = {
|
||||
let s: f64 = close[..timeperiod].iter().sum();
|
||||
let sq: f64 = close[..timeperiod].iter().map(|&x| x * x).sum();
|
||||
(s, sq)
|
||||
};
|
||||
|
||||
let mean = sum / p;
|
||||
let var = (sum_sq / p - mean * mean).max(0.0);
|
||||
let var = (m2 / p).max(0.0);
|
||||
let std = var.sqrt();
|
||||
middle[timeperiod - 1] = mean;
|
||||
upper[timeperiod - 1] = mean + nbdevup * std;
|
||||
lower[timeperiod - 1] = mean - nbdevdn * std;
|
||||
|
||||
// --- Rolling phase: slide the window one element at a time,
|
||||
// removing the oldest value and adding the newest. ---
|
||||
|
||||
/// Inline helper: replace `x_old` with `x_new` in the Welford accumulator
|
||||
/// (constant window size `p`), then write band values into the output slots.
|
||||
///
|
||||
/// Combined rolling Welford update (window size stays constant at N):
|
||||
///
|
||||
/// ```text
|
||||
/// delta = x_new - x_old
|
||||
/// old_mean = mean
|
||||
/// mean += delta / N
|
||||
/// m2 += delta * ((x_new - mean) + (x_old - old_mean))
|
||||
/// ```
|
||||
///
|
||||
/// This is algebraically equivalent to removing `x_old` and adding `x_new`
|
||||
/// in two separate Welford steps, but avoids the intermediate N-1 state.
|
||||
#[inline(always)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn welford_step(
|
||||
x_old: f64,
|
||||
x_new: f64,
|
||||
mean: &mut f64,
|
||||
m2: &mut f64,
|
||||
p: f64,
|
||||
nbdevup: f64,
|
||||
nbdevdn: f64,
|
||||
upper: &mut f64,
|
||||
middle: &mut f64,
|
||||
lower: &mut f64,
|
||||
) {
|
||||
let delta = x_new - x_old;
|
||||
let old_mean = *mean;
|
||||
*mean += delta / p;
|
||||
// Update m2 using both old and new deviations.
|
||||
*m2 += delta * ((x_new - *mean) + (x_old - old_mean));
|
||||
|
||||
// Clamp m2 to zero to guard against floating-point drift.
|
||||
if *m2 < 0.0 {
|
||||
*m2 = 0.0;
|
||||
}
|
||||
|
||||
let var = *m2 / p;
|
||||
let std = var.sqrt();
|
||||
*middle = *mean;
|
||||
*upper = *mean + nbdevup * std;
|
||||
*lower = *mean - nbdevdn * std;
|
||||
}
|
||||
|
||||
// Process two iterations at a time (loop unrolling) for throughput.
|
||||
let mut i = timeperiod;
|
||||
while i + 1 < n {
|
||||
let old0 = close[i - timeperiod];
|
||||
sum += close[i] - old0;
|
||||
sum_sq += close[i] * close[i] - old0 * old0;
|
||||
let mean = sum / p;
|
||||
let var = (sum_sq / p - mean * mean).max(0.0);
|
||||
let std = var.sqrt();
|
||||
middle[i] = mean;
|
||||
upper[i] = mean + nbdevup * std;
|
||||
lower[i] = mean - nbdevdn * std;
|
||||
|
||||
let old1 = close[i + 1 - timeperiod];
|
||||
sum += close[i + 1] - old1;
|
||||
sum_sq += close[i + 1] * close[i + 1] - old1 * old1;
|
||||
let mean1 = sum / p;
|
||||
let var1 = (sum_sq / p - mean1 * mean1).max(0.0);
|
||||
let std1 = var1.sqrt();
|
||||
middle[i + 1] = mean1;
|
||||
upper[i + 1] = mean1 + nbdevup * std1;
|
||||
lower[i + 1] = mean1 - nbdevdn * std1;
|
||||
|
||||
welford_step(
|
||||
close[i - timeperiod],
|
||||
close[i],
|
||||
&mut mean,
|
||||
&mut m2,
|
||||
p,
|
||||
nbdevup,
|
||||
nbdevdn,
|
||||
&mut upper[i],
|
||||
&mut middle[i],
|
||||
&mut lower[i],
|
||||
);
|
||||
welford_step(
|
||||
close[i + 1 - timeperiod],
|
||||
close[i + 1],
|
||||
&mut mean,
|
||||
&mut m2,
|
||||
p,
|
||||
nbdevup,
|
||||
nbdevdn,
|
||||
&mut upper[i + 1],
|
||||
&mut middle[i + 1],
|
||||
&mut lower[i + 1],
|
||||
);
|
||||
i += 2;
|
||||
}
|
||||
if i < n {
|
||||
let old = close[i - timeperiod];
|
||||
sum += close[i] - old;
|
||||
sum_sq += close[i] * close[i] - old * old;
|
||||
let mean = sum / p;
|
||||
let var = (sum_sq / p - mean * mean).max(0.0);
|
||||
let std = var.sqrt();
|
||||
middle[i] = mean;
|
||||
upper[i] = mean + nbdevup * std;
|
||||
lower[i] = mean - nbdevdn * std;
|
||||
welford_step(
|
||||
close[i - timeperiod],
|
||||
close[i],
|
||||
&mut mean,
|
||||
&mut m2,
|
||||
p,
|
||||
nbdevup,
|
||||
nbdevdn,
|
||||
&mut upper[i],
|
||||
&mut middle[i],
|
||||
&mut lower[i],
|
||||
);
|
||||
}
|
||||
|
||||
(upper, middle, lower)
|
||||
}
|
||||
|
||||
/// MACD — EMA(fastperiod) minus EMA(slowperiod), signal = EMA(macd, signalperiod).
|
||||
/// Compute the Moving Average Convergence/Divergence (MACD).
|
||||
///
|
||||
/// Returns `(macd_line, signal_line, histogram)`, each of length `n`.
|
||||
/// Leading values are `NaN` during warmup.
|
||||
/// `fastperiod` must be less than `slowperiod`.
|
||||
/// `MACD = EMA(close, fastperiod) - EMA(close, slowperiod)`.
|
||||
/// The signal line is `EMA(macd, signalperiod)` and the histogram is
|
||||
/// `macd - signal`. TA-Lib compatible: leading values are `NaN` up to
|
||||
/// the point where all three outputs are valid.
|
||||
///
|
||||
/// Fast and slow EMAs are computed in a **single combined loop** to minimise
|
||||
/// memory round-trips, then the signal EMA is computed in a second pass.
|
||||
/// # Arguments
|
||||
/// * `close` - Price series.
|
||||
/// * `fastperiod` - Fast EMA period (must be < `slowperiod`).
|
||||
/// * `slowperiod` - Slow EMA period.
|
||||
/// * `signalperiod` - Signal line EMA period.
|
||||
///
|
||||
/// # Returns
|
||||
/// `(macd_line, signal_line, histogram)` -- each `Vec<f64>` of length `n`.
|
||||
pub fn macd(
|
||||
close: &[f64],
|
||||
fastperiod: usize,
|
||||
@@ -340,6 +447,526 @@ pub fn macd(
|
||||
(macd_line, signal_line, histogram)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DEMA — Double Exponential Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Double Exponential Moving Average: `2*EMA - EMA(EMA)`.
|
||||
pub fn dema(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
let warmup = 2 * (timeperiod - 1);
|
||||
let ema1 = ema(close, timeperiod);
|
||||
let ema2 = ema(&ema1, timeperiod);
|
||||
for i in warmup..n {
|
||||
if !ema1[i].is_nan() && !ema2[i].is_nan() {
|
||||
result[i] = 2.0 * ema1[i] - ema2[i];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TEMA — Triple Exponential Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Triple Exponential Moving Average: `3*EMA - 3*EMA(EMA) + EMA(EMA(EMA))`.
|
||||
pub fn tema(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
let warmup = 3 * (timeperiod - 1);
|
||||
let ema1 = ema(close, timeperiod);
|
||||
let ema2 = ema(&ema1, timeperiod);
|
||||
let ema3 = ema(&ema2, timeperiod);
|
||||
for i in warmup..n {
|
||||
if !ema1[i].is_nan() && !ema2[i].is_nan() && !ema3[i].is_nan() {
|
||||
result[i] = 3.0 * ema1[i] - 3.0 * ema2[i] + ema3[i];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TRIMA — Triangular Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Triangular Moving Average (triangle-weighted).
|
||||
pub fn trima(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let half = timeperiod.div_ceil(2);
|
||||
let mut weights = Vec::with_capacity(timeperiod);
|
||||
for i in 1..=timeperiod {
|
||||
let w = if i <= half { i } else { timeperiod + 1 - i };
|
||||
weights.push(w as f64);
|
||||
}
|
||||
let weight_sum: f64 = weights.iter().sum();
|
||||
for i in (timeperiod - 1)..n {
|
||||
let mut val = 0.0_f64;
|
||||
for (j, &w) in weights.iter().enumerate() {
|
||||
val += close[i - (timeperiod - 1 - j)] * w;
|
||||
}
|
||||
result[i] = val / weight_sum;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KAMA — Kaufman Adaptive Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Kaufman Adaptive Moving Average.
|
||||
pub fn kama(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let fast_sc = 2.0 / 3.0_f64;
|
||||
let slow_sc = 2.0 / 31.0_f64;
|
||||
let mut kama_val = close[timeperiod - 1];
|
||||
result[timeperiod - 1] = kama_val;
|
||||
for i in timeperiod..n {
|
||||
let direction = (close[i] - close[i - timeperiod]).abs();
|
||||
let mut volatility = 0.0_f64;
|
||||
for j in 1..=timeperiod {
|
||||
volatility += (close[i - j + 1] - close[i - j]).abs();
|
||||
}
|
||||
let er = if volatility > 0.0 {
|
||||
direction / volatility
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let sc = (er * (fast_sc - slow_sc) + slow_sc).powi(2);
|
||||
kama_val += sc * (close[i] - kama_val);
|
||||
result[i] = kama_val;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T3 — Tillson T3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tillson T3: 6x smoothed EMA with volume factor.
|
||||
pub fn t3(close: &[f64], timeperiod: usize, vfactor: f64) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 {
|
||||
return result;
|
||||
}
|
||||
let k = 2.0 / (timeperiod as f64 + 1.0);
|
||||
let v = vfactor;
|
||||
let c1 = -(v * v * v);
|
||||
let c2 = 3.0 * v * v + 3.0 * v * v * v;
|
||||
let c3 = -6.0 * v * v - 3.0 * v - 3.0 * v * v * v;
|
||||
let c4 = 1.0 + 3.0 * v + v * v * v + 3.0 * v * v;
|
||||
let warmup = 6 * (timeperiod - 1);
|
||||
let mut e = [0.0_f64; 6];
|
||||
for (i, &price) in close.iter().enumerate() {
|
||||
if i == 0 {
|
||||
for ej in e.iter_mut() {
|
||||
*ej = price;
|
||||
}
|
||||
} else {
|
||||
e[0] += k * (price - e[0]);
|
||||
for j in 1..6 {
|
||||
e[j] += k * (e[j - 1] - e[j]);
|
||||
}
|
||||
}
|
||||
if i >= warmup {
|
||||
result[i] = c1 * e[5] + c2 * e[4] + c3 * e[3] + c4 * e[2];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SAR — Parabolic SAR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parabolic SAR.
|
||||
pub fn sar(high: &[f64], low: &[f64], acceleration: f64, maximum: f64) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
if n < 2 {
|
||||
return vec![f64::NAN; n];
|
||||
}
|
||||
let mut result = vec![f64::NAN; n];
|
||||
let mut is_rising = high[1] >= high[0];
|
||||
let mut af = acceleration;
|
||||
let (mut ep, mut sar_val) = if is_rising {
|
||||
(high[1], low[0])
|
||||
} else {
|
||||
(low[1], high[0])
|
||||
};
|
||||
result[1] = sar_val;
|
||||
for i in 2..n {
|
||||
let prev_sar = sar_val;
|
||||
sar_val = prev_sar + af * (ep - prev_sar);
|
||||
if is_rising {
|
||||
sar_val = sar_val.min(low[i - 1]).min(low[i - 2]);
|
||||
if low[i] < sar_val {
|
||||
is_rising = false;
|
||||
sar_val = ep;
|
||||
ep = low[i];
|
||||
af = acceleration;
|
||||
} else if high[i] > ep {
|
||||
ep = high[i];
|
||||
af = (af + acceleration).min(maximum);
|
||||
}
|
||||
} else {
|
||||
sar_val = sar_val.max(high[i - 1]).max(high[i - 2]);
|
||||
if high[i] > sar_val {
|
||||
is_rising = true;
|
||||
sar_val = ep;
|
||||
ep = high[i];
|
||||
af = acceleration;
|
||||
} else if low[i] < ep {
|
||||
ep = low[i];
|
||||
af = (af + acceleration).min(maximum);
|
||||
}
|
||||
}
|
||||
result[i] = sar_val;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SAREXT — Extended Parabolic SAR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parabolic SAR Extended with configurable acceleration factors.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn sarext(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
startvalue: f64,
|
||||
offsetonreverse: f64,
|
||||
accelerationinitlong: f64,
|
||||
accelerationlong: f64,
|
||||
accelerationmaxlong: f64,
|
||||
accelerationinitshort: f64,
|
||||
accelerationshort: f64,
|
||||
accelerationmaxshort: f64,
|
||||
) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
if n < 2 {
|
||||
return vec![f64::NAN; n];
|
||||
}
|
||||
let mut result = vec![f64::NAN; n];
|
||||
let mut is_rising = high[1] >= high[0];
|
||||
let (mut af, mut af_step_cur, mut af_max_cur) = if is_rising {
|
||||
(accelerationinitlong, accelerationlong, accelerationmaxlong)
|
||||
} else {
|
||||
(
|
||||
accelerationinitshort,
|
||||
accelerationshort,
|
||||
accelerationmaxshort,
|
||||
)
|
||||
};
|
||||
let (mut ep, mut sar_val) = if is_rising {
|
||||
(
|
||||
high[1],
|
||||
if startvalue != 0.0 {
|
||||
startvalue
|
||||
} else {
|
||||
low[0]
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
low[1],
|
||||
if startvalue != 0.0 {
|
||||
-startvalue
|
||||
} else {
|
||||
high[0]
|
||||
},
|
||||
)
|
||||
};
|
||||
result[1] = sar_val;
|
||||
for i in 2..n {
|
||||
let prev_sar = sar_val;
|
||||
sar_val = prev_sar + af * (ep - prev_sar);
|
||||
if is_rising {
|
||||
sar_val = sar_val.min(low[i - 1]).min(low[i - 2]);
|
||||
if low[i] < sar_val {
|
||||
is_rising = false;
|
||||
sar_val = ep + sar_val.abs() * offsetonreverse;
|
||||
ep = low[i];
|
||||
af = accelerationinitshort;
|
||||
af_step_cur = accelerationshort;
|
||||
af_max_cur = accelerationmaxshort;
|
||||
} else if high[i] > ep {
|
||||
ep = high[i];
|
||||
af = (af + af_step_cur).min(af_max_cur);
|
||||
}
|
||||
} else {
|
||||
sar_val = sar_val.max(high[i - 1]).max(high[i - 2]);
|
||||
if high[i] > sar_val {
|
||||
is_rising = true;
|
||||
sar_val = ep - sar_val.abs() * offsetonreverse;
|
||||
ep = high[i];
|
||||
af = accelerationinitlong;
|
||||
af_step_cur = accelerationlong;
|
||||
af_max_cur = accelerationmaxlong;
|
||||
} else if low[i] < ep {
|
||||
ep = low[i];
|
||||
af = (af + af_step_cur).min(af_max_cur);
|
||||
}
|
||||
}
|
||||
result[i] = sar_val;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MAMA — MESA Adaptive Moving Average
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// MESA Adaptive Moving Average. Returns `(mama, fama)`.
|
||||
pub fn mama(close: &[f64], fastlimit: f64, slowlimit: f64) -> (Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let lookback = 32;
|
||||
let mut mama_arr = vec![f64::NAN; n];
|
||||
let mut fama_arr = vec![f64::NAN; n];
|
||||
if n <= lookback {
|
||||
return (mama_arr, fama_arr);
|
||||
}
|
||||
|
||||
let mut smooth = vec![0.0f64; n];
|
||||
for i in 0..n {
|
||||
smooth[i] = if i >= 3 {
|
||||
(4.0 * close[i] + 3.0 * close[i - 1] + 2.0 * close[i - 2] + close[i - 3]) / 10.0
|
||||
} else {
|
||||
close[i]
|
||||
};
|
||||
}
|
||||
|
||||
let mut detrender = vec![0.0f64; n];
|
||||
let mut q1 = vec![0.0f64; n];
|
||||
let mut i1 = vec![0.0f64; n];
|
||||
let mut ji = vec![0.0f64; n];
|
||||
let mut jq = vec![0.0f64; n];
|
||||
let mut i2 = vec![0.0f64; n];
|
||||
let mut q2 = vec![0.0f64; n];
|
||||
let mut re = vec![0.0f64; n];
|
||||
let mut im = vec![0.0f64; n];
|
||||
let mut period = vec![0.0f64; n];
|
||||
let mut phase = vec![0.0f64; n];
|
||||
let mut mama_val = close[0];
|
||||
let mut fama_val = close[0];
|
||||
|
||||
for i in 6..n {
|
||||
let prev_period = period[i - 1].max(1.0);
|
||||
let alpha = 0.075 * prev_period + 0.54;
|
||||
detrender[i] = (0.0962 * smooth[i] + 0.5769 * smooth[i - 2]
|
||||
- 0.5769 * smooth[i - 4]
|
||||
- 0.0962 * smooth[i - 6])
|
||||
* alpha;
|
||||
if i >= 12 {
|
||||
q1[i] = (0.0962 * detrender[i] + 0.5769 * detrender[i - 2]
|
||||
- 0.5769 * detrender[i - 4]
|
||||
- 0.0962 * detrender[i - 6])
|
||||
* alpha;
|
||||
}
|
||||
if i >= 9 {
|
||||
i1[i] = detrender[i - 3];
|
||||
}
|
||||
if i >= 15 {
|
||||
ji[i] = (0.0962 * i1[i] + 0.5769 * i1[i - 2] - 0.5769 * i1[i - 4] - 0.0962 * i1[i - 6])
|
||||
* alpha;
|
||||
}
|
||||
if i >= 18 {
|
||||
jq[i] = (0.0962 * q1[i] + 0.5769 * q1[i - 2] - 0.5769 * q1[i - 4] - 0.0962 * q1[i - 6])
|
||||
* alpha;
|
||||
}
|
||||
let i2_raw = i1[i] - jq[i];
|
||||
let q2_raw = q1[i] + ji[i];
|
||||
i2[i] = 0.2 * i2_raw + 0.8 * i2[i - 1];
|
||||
q2[i] = 0.2 * q2_raw + 0.8 * q2[i - 1];
|
||||
re[i] = 0.2 * (i2[i] * i2[i - 1] + q2[i] * q2[i - 1]) + 0.8 * re[i - 1];
|
||||
im[i] = 0.2 * (i2[i] * q2[i - 1] - q2[i] * i2[i - 1]) + 0.8 * im[i - 1];
|
||||
let mut p = if re[i] != 0.0 && im[i] != 0.0 && re[i] > 0.0 {
|
||||
std::f64::consts::PI * 2.0 / (im[i] / re[i]).atan()
|
||||
} else {
|
||||
prev_period
|
||||
};
|
||||
p = p
|
||||
.clamp(0.67 * prev_period, 1.5 * prev_period)
|
||||
.clamp(6.0, 50.0);
|
||||
period[i] = 0.2 * p + 0.8 * prev_period;
|
||||
phase[i] = if i1[i] != 0.0 {
|
||||
q1[i].atan2(i1[i]) * 180.0 / std::f64::consts::PI
|
||||
} else if q1[i] > 0.0 {
|
||||
90.0
|
||||
} else if q1[i] < 0.0 {
|
||||
-90.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut delta_phase = phase[i - 1] - phase[i];
|
||||
if delta_phase < 1.0 {
|
||||
delta_phase = 1.0;
|
||||
}
|
||||
let adaptive_alpha = (fastlimit / delta_phase).clamp(slowlimit, fastlimit);
|
||||
if i >= lookback {
|
||||
mama_val = adaptive_alpha * close[i] + (1.0 - adaptive_alpha) * mama_val;
|
||||
fama_val = 0.5 * adaptive_alpha * mama_val + (1.0 - 0.5 * adaptive_alpha) * fama_val;
|
||||
mama_arr[i] = mama_val;
|
||||
fama_arr[i] = fama_val;
|
||||
} else {
|
||||
mama_val = close[i];
|
||||
fama_val = close[i];
|
||||
}
|
||||
}
|
||||
(mama_arr, fama_arr)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MIDPOINT / MIDPRICE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Midpoint: `(max(close) + min(close)) / 2` over rolling window.
|
||||
pub fn midpoint(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
let window = &close[(i + 1 - timeperiod)..=i];
|
||||
let mx = window.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let mn = window.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
result[i] = (mx + mn) / 2.0;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// MidPrice: `(highest_high + lowest_low) / 2` over rolling window.
|
||||
pub fn midprice(high: &[f64], low: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
let start = i + 1 - timeperiod;
|
||||
let mx = high[start..=i]
|
||||
.iter()
|
||||
.cloned()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let mn = low[start..=i].iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
result[i] = (mx + mn) / 2.0;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MACDFIX / MACDEXT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// MACD with fixed 12/26 periods.
|
||||
pub fn macdfix(close: &[f64], signalperiod: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
macd(close, 12, 26, signalperiod)
|
||||
}
|
||||
|
||||
/// Compute MA by type: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3.
|
||||
fn compute_ma_by_type(close: &[f64], timeperiod: usize, matype: u8) -> Vec<f64> {
|
||||
match matype {
|
||||
0 => sma(close, timeperiod),
|
||||
1 => ema(close, timeperiod),
|
||||
2 => wma(close, timeperiod),
|
||||
3 => dema(close, timeperiod),
|
||||
4 => tema(close, timeperiod),
|
||||
5 => trima(close, timeperiod),
|
||||
6 => kama(close, timeperiod),
|
||||
7 => t3(close, timeperiod, 0.7),
|
||||
_ => sma(close, timeperiod),
|
||||
}
|
||||
}
|
||||
|
||||
/// MACD with configurable MA types for fast/slow/signal.
|
||||
pub fn macdext(
|
||||
close: &[f64],
|
||||
fastperiod: usize,
|
||||
fastmatype: u8,
|
||||
slowperiod: usize,
|
||||
slowmatype: u8,
|
||||
signalperiod: usize,
|
||||
signalmatype: u8,
|
||||
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let n = close.len();
|
||||
let nan3 = || (vec![f64::NAN; n], vec![f64::NAN; n], vec![f64::NAN; n]);
|
||||
if fastperiod == 0 || slowperiod == 0 || signalperiod == 0 || fastperiod >= slowperiod {
|
||||
return nan3();
|
||||
}
|
||||
let fast_ma = compute_ma_by_type(close, fastperiod, fastmatype);
|
||||
let slow_ma = compute_ma_by_type(close, slowperiod, slowmatype);
|
||||
let macd_start = slowperiod - 1;
|
||||
let mut macd_line = vec![f64::NAN; n];
|
||||
for i in macd_start..n {
|
||||
if !fast_ma[i].is_nan() && !slow_ma[i].is_nan() {
|
||||
macd_line[i] = fast_ma[i] - slow_ma[i];
|
||||
}
|
||||
}
|
||||
let macd_valid: Vec<f64> = macd_line[macd_start..].to_vec();
|
||||
let signal_slice = compute_ma_by_type(&macd_valid, signalperiod, signalmatype);
|
||||
let mut signal_line = vec![f64::NAN; n];
|
||||
let warmup = macd_start + signalperiod - 1;
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in warmup..n {
|
||||
let j = i - macd_start;
|
||||
if j < signal_slice.len() && !signal_slice[j].is_nan() {
|
||||
signal_line[i] = signal_slice[j];
|
||||
}
|
||||
}
|
||||
let mut histogram = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
if !macd_line[i].is_nan() && !signal_line[i].is_nan() {
|
||||
histogram[i] = macd_line[i] - signal_line[i];
|
||||
}
|
||||
}
|
||||
(macd_line, signal_line, histogram)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MA (generic dispatcher) / MAVP (variable period)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generic Moving Average. matype: 0=SMA, 1=EMA, 2=WMA, 3=DEMA, 4=TEMA, 5=TRIMA, 6=KAMA, 7=T3.
|
||||
pub fn ma(close: &[f64], timeperiod: usize, matype: u8) -> Vec<f64> {
|
||||
compute_ma_by_type(close, timeperiod, matype)
|
||||
}
|
||||
|
||||
/// Moving Average with Variable Period per bar (SMA over period from periods array).
|
||||
pub fn mavp(close: &[f64], periods: &[f64], minperiod: usize, maxperiod: usize) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if minperiod == 0 || maxperiod < minperiod {
|
||||
return result;
|
||||
}
|
||||
for i in 0..n {
|
||||
if i >= periods.len() {
|
||||
break;
|
||||
}
|
||||
let p = (periods[i].round() as usize).clamp(minperiod, maxperiod);
|
||||
if i + 1 >= p {
|
||||
let sum: f64 = close[(i + 1 - p)..=i].iter().sum();
|
||||
result[i] = sum / p as f64;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -383,6 +1010,75 @@ mod tests {
|
||||
assert!((lower[2] - 2.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bbands_varying_prices() {
|
||||
// Verify against hand-computed values for a small window.
|
||||
let prices = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let (upper, middle, lower) = bbands(&prices, 3, 2.0, 2.0);
|
||||
|
||||
// First two values should be NaN (warmup).
|
||||
assert!(middle[0].is_nan());
|
||||
assert!(middle[1].is_nan());
|
||||
|
||||
// Window [1,2,3]: mean = 2.0, pop_var = 2/3, std = sqrt(2/3)
|
||||
let expected_mean = 2.0;
|
||||
let expected_std = (2.0_f64 / 3.0).sqrt();
|
||||
assert!((middle[2] - expected_mean).abs() < 1e-10);
|
||||
assert!((upper[2] - (expected_mean + 2.0 * expected_std)).abs() < 1e-10);
|
||||
assert!((lower[2] - (expected_mean - 2.0 * expected_std)).abs() < 1e-10);
|
||||
|
||||
// Window [2,3,4]: mean = 3.0, pop_var = 2/3, std = sqrt(2/3)
|
||||
assert!((middle[3] - 3.0).abs() < 1e-10);
|
||||
assert!((upper[3] - (3.0 + 2.0 * expected_std)).abs() < 1e-10);
|
||||
|
||||
// Window [3,4,5]: mean = 4.0, pop_var = 2/3, std = sqrt(2/3)
|
||||
assert!((middle[4] - 4.0).abs() < 1e-10);
|
||||
assert!((upper[4] - (4.0 + 2.0 * expected_std)).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bbands_numerical_stability() {
|
||||
// Large offset with tiny variation — this is where the naïve sum_sq
|
||||
// formula suffers from catastrophic cancellation.
|
||||
let base = 1e12;
|
||||
let prices: Vec<f64> = (0..100).map(|i| base + (i as f64) * 0.01).collect();
|
||||
let (upper, middle, lower) = bbands(&prices, 20, 2.0, 2.0);
|
||||
|
||||
// Check that middle band matches SMA.
|
||||
for i in 19..100 {
|
||||
let window = &prices[i - 19..=i];
|
||||
let expected_mean: f64 = window.iter().sum::<f64>() / 20.0;
|
||||
// At scale 1e12, f64 absolute precision is ~2.2e-4; use 1e-3 headroom.
|
||||
assert!(
|
||||
(middle[i] - expected_mean).abs() < 1e-3,
|
||||
"mean mismatch at {i}: got {} expected {}",
|
||||
middle[i],
|
||||
expected_mean,
|
||||
);
|
||||
// Bands should be above/below middle.
|
||||
assert!(upper[i] >= middle[i]);
|
||||
assert!(lower[i] <= middle[i]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bbands_edge_cases() {
|
||||
// timeperiod == 1: every bar should have std = 0, bands == price.
|
||||
let prices = vec![10.0, 20.0, 30.0];
|
||||
let (upper, middle, lower) = bbands(&prices, 1, 2.0, 2.0);
|
||||
for i in 0..3 {
|
||||
assert!((middle[i] - prices[i]).abs() < 1e-10);
|
||||
assert!((upper[i] - prices[i]).abs() < 1e-10);
|
||||
assert!((lower[i] - prices[i]).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// Input shorter than timeperiod: all NaN.
|
||||
let (u, m, l) = bbands(&[1.0, 2.0], 5, 2.0, 2.0);
|
||||
assert!(u.iter().all(|v| v.is_nan()));
|
||||
assert!(m.iter().all(|v| v.is_nan()));
|
||||
assert!(l.iter().all(|v| v.is_nan()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macd_basic() {
|
||||
// 40 bars of linearly increasing prices — MACD line should converge
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,631 @@
|
||||
//! Pure Rust portfolio analytics — no PyO3, no numpy, no ndarray.
|
||||
//!
|
||||
//! Functions:
|
||||
//! - `portfolio_volatility` — sqrt(w' Σ w)
|
||||
//! - `beta_full` — Cov/Var OLS beta
|
||||
//! - `rolling_beta` — rolling beta with NaN warmup
|
||||
//! - `drawdown_series` — per-bar drawdown + max drawdown
|
||||
//! - `correlation_matrix` — pairwise Pearson correlation
|
||||
//! - `relative_strength` — cumulative return ratio
|
||||
//! - `spread` — A - hedge * B
|
||||
//! - `ratio` — A / B (NaN for zero)
|
||||
//! - `zscore_series` — rolling z-score, NaN warmup
|
||||
//! - `compose_weighted` — weighted sum per row
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// portfolio_volatility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute portfolio volatility: sqrt(w' Σ w).
|
||||
///
|
||||
/// `cov_matrix` is an n×n covariance matrix stored as a slice of row-Vecs.
|
||||
/// `weights` has length n.
|
||||
///
|
||||
/// Panics if dimensions are inconsistent.
|
||||
pub fn portfolio_volatility(cov_matrix: &[Vec<f64>], weights: &[f64]) -> f64 {
|
||||
let n = weights.len();
|
||||
assert!(
|
||||
cov_matrix.len() == n,
|
||||
"cov_matrix must have {} rows, got {}",
|
||||
n,
|
||||
cov_matrix.len()
|
||||
);
|
||||
let mut variance = 0.0_f64;
|
||||
for i in 0..n {
|
||||
assert!(
|
||||
cov_matrix[i].len() == n,
|
||||
"cov_matrix row {} must have length {}, got {}",
|
||||
i,
|
||||
n,
|
||||
cov_matrix[i].len()
|
||||
);
|
||||
let mut row_sum = 0.0_f64;
|
||||
for j in 0..n {
|
||||
row_sum += weights[j] * cov_matrix[i][j];
|
||||
}
|
||||
variance += weights[i] * row_sum;
|
||||
}
|
||||
variance.max(0.0).sqrt()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// beta_full
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the full-sample OLS beta of `asset_returns` vs `benchmark_returns`.
|
||||
///
|
||||
/// Beta = Cov(asset, bench) / Var(bench).
|
||||
///
|
||||
/// Panics if lengths differ or are < 2, or if benchmark has zero variance.
|
||||
pub fn beta_full(asset_returns: &[f64], benchmark_returns: &[f64]) -> f64 {
|
||||
let n = asset_returns.len();
|
||||
assert!(
|
||||
n >= 2 && benchmark_returns.len() == n,
|
||||
"asset_returns and benchmark_returns must have equal length >= 2"
|
||||
);
|
||||
let mean_a: f64 = asset_returns.iter().sum::<f64>() / n as f64;
|
||||
let mean_b: f64 = benchmark_returns.iter().sum::<f64>() / n as f64;
|
||||
let mut cov = 0.0_f64;
|
||||
let mut var_b = 0.0_f64;
|
||||
for i in 0..n {
|
||||
let da = asset_returns[i] - mean_a;
|
||||
let db = benchmark_returns[i] - mean_b;
|
||||
cov += da * db;
|
||||
var_b += db * db;
|
||||
}
|
||||
assert!(
|
||||
var_b != 0.0,
|
||||
"benchmark_returns has zero variance; cannot compute beta"
|
||||
);
|
||||
cov / var_b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rolling_beta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute rolling beta of `asset` vs `benchmark` over a sliding `window`.
|
||||
///
|
||||
/// Returns a Vec of the same length as the inputs. The first `window - 1`
|
||||
/// entries are NaN (warmup period). `window` must be >= 2.
|
||||
pub fn rolling_beta(asset: &[f64], benchmark: &[f64], window: usize) -> Vec<f64> {
|
||||
assert!(window >= 2, "window must be >= 2");
|
||||
let n = asset.len();
|
||||
assert!(
|
||||
n > 0 && benchmark.len() == n,
|
||||
"asset and benchmark must be non-empty and equal length"
|
||||
);
|
||||
let mut result = vec![f64::NAN; n];
|
||||
for i in (window - 1)..n {
|
||||
let start = i + 1 - window;
|
||||
let a_win = &asset[start..=i];
|
||||
let b_win = &benchmark[start..=i];
|
||||
let mean_a: f64 = a_win.iter().sum::<f64>() / window as f64;
|
||||
let mean_b: f64 = b_win.iter().sum::<f64>() / window as f64;
|
||||
let mut cov = 0.0_f64;
|
||||
let mut var_b = 0.0_f64;
|
||||
for k in 0..window {
|
||||
let da = a_win[k] - mean_a;
|
||||
let db = b_win[k] - mean_b;
|
||||
cov += da * db;
|
||||
var_b += db * db;
|
||||
}
|
||||
result[i] = if var_b == 0.0 { f64::NAN } else { cov / var_b };
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// drawdown_series
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the drawdown series and maximum drawdown for an equity/price series.
|
||||
///
|
||||
/// Drawdown at bar i = (equity[i] - running_max) / running_max (always <= 0).
|
||||
///
|
||||
/// Returns `(dd_array, max_dd)` where `max_dd` is the most negative drawdown.
|
||||
///
|
||||
/// Panics if `equity` is empty.
|
||||
pub fn drawdown_series(equity: &[f64]) -> (Vec<f64>, f64) {
|
||||
let n = equity.len();
|
||||
assert!(n > 0, "equity must be non-empty");
|
||||
let mut dd = vec![0.0_f64; n];
|
||||
let mut peak = equity[0];
|
||||
let mut max_dd = 0.0_f64;
|
||||
for i in 0..n {
|
||||
if equity[i] > peak {
|
||||
peak = equity[i];
|
||||
}
|
||||
let d = if peak == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(equity[i] - peak) / peak
|
||||
};
|
||||
dd[i] = d;
|
||||
if d < max_dd {
|
||||
max_dd = d;
|
||||
}
|
||||
}
|
||||
(dd, max_dd)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// correlation_matrix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the pairwise Pearson correlation matrix.
|
||||
///
|
||||
/// `data` is a slice of column vectors — `data[j]` is the return series for
|
||||
/// asset j, so `data[j][i]` is the return of asset j at bar i. All columns
|
||||
/// must have the same length (>= 2).
|
||||
///
|
||||
/// Returns an n_assets × n_assets matrix stored as `Vec<Vec<f64>>`.
|
||||
pub fn correlation_matrix(data: &[Vec<f64>]) -> Vec<Vec<f64>> {
|
||||
let n_assets = data.len();
|
||||
assert!(n_assets > 0, "data must contain at least one asset column");
|
||||
let n_bars = data[0].len();
|
||||
assert!(n_bars >= 2, "data must have at least 2 rows (bars)");
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for j in 1..n_assets {
|
||||
assert!(
|
||||
data[j].len() == n_bars,
|
||||
"all columns must have equal length; column 0 has {} but column {} has {}",
|
||||
n_bars,
|
||||
j,
|
||||
data[j].len()
|
||||
);
|
||||
}
|
||||
|
||||
// Means
|
||||
let mut means = vec![0.0_f64; n_assets];
|
||||
for j in 0..n_assets {
|
||||
means[j] = data[j].iter().sum::<f64>() / n_bars as f64;
|
||||
}
|
||||
|
||||
// Standard deviations (population)
|
||||
let mut stds = vec![0.0_f64; n_assets];
|
||||
for j in 0..n_assets {
|
||||
let var: f64 = data[j].iter().map(|&v| (v - means[j]).powi(2)).sum::<f64>() / n_bars as f64;
|
||||
stds[j] = var.sqrt();
|
||||
}
|
||||
|
||||
// Build correlation matrix (exploit symmetry: compute each pair once)
|
||||
let mut result = vec![vec![0.0_f64; n_assets]; n_assets];
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for j1 in 0..n_assets {
|
||||
result[j1][j1] = 1.0;
|
||||
for j2 in (j1 + 1)..n_assets {
|
||||
let mut cov = 0.0_f64;
|
||||
for i in 0..n_bars {
|
||||
cov += (data[j1][i] - means[j1]) * (data[j2][i] - means[j2]);
|
||||
}
|
||||
cov /= n_bars as f64;
|
||||
let denom = stds[j1] * stds[j2];
|
||||
let corr = if denom == 0.0 { f64::NAN } else { cov / denom };
|
||||
result[j1][j2] = corr;
|
||||
result[j2][j1] = corr;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// relative_strength
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute relative strength of an asset vs a benchmark.
|
||||
///
|
||||
/// result[i] = cumprod(1 + asset_returns[0..=i]) / cumprod(1 + benchmark_returns[0..=i])
|
||||
///
|
||||
/// Panics if lengths differ or are zero.
|
||||
pub fn relative_strength(asset_returns: &[f64], benchmark_returns: &[f64]) -> Vec<f64> {
|
||||
let n = asset_returns.len();
|
||||
assert!(
|
||||
n > 0 && benchmark_returns.len() == n,
|
||||
"asset_returns and benchmark_returns must be non-empty and equal length"
|
||||
);
|
||||
let mut result = vec![0.0_f64; n];
|
||||
let mut cum_a = 1.0_f64;
|
||||
let mut cum_b = 1.0_f64;
|
||||
for i in 0..n {
|
||||
cum_a *= 1.0 + asset_returns[i];
|
||||
cum_b *= 1.0 + benchmark_returns[i];
|
||||
result[i] = if cum_b == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
cum_a / cum_b
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// spread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the spread between two series: a - hedge * b.
|
||||
///
|
||||
/// Panics if lengths differ or are zero.
|
||||
pub fn spread(a: &[f64], b: &[f64], hedge: f64) -> Vec<f64> {
|
||||
let n = a.len();
|
||||
assert!(
|
||||
n > 0 && b.len() == n,
|
||||
"a and b must be non-empty and equal length"
|
||||
);
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(&x, &y)| x - hedge * y)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ratio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the ratio between two series: a / b.
|
||||
///
|
||||
/// Where b is 0, returns NaN.
|
||||
///
|
||||
/// Panics if lengths differ or are zero.
|
||||
pub fn ratio(a: &[f64], b: &[f64]) -> Vec<f64> {
|
||||
let n = a.len();
|
||||
assert!(
|
||||
n > 0 && b.len() == n,
|
||||
"a and b must be non-empty and equal length"
|
||||
);
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(&x, &y)| if y == 0.0 { f64::NAN } else { x / y })
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// zscore_series
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the rolling Z-score of a 1-D series.
|
||||
///
|
||||
/// Z[i] = (x[i] - mean(window)) / std(window)
|
||||
///
|
||||
/// The first `window - 1` entries are NaN. `window` must be >= 2.
|
||||
///
|
||||
/// Panics if `x` is empty or `window < 2`.
|
||||
pub fn zscore_series(x: &[f64], window: usize) -> Vec<f64> {
|
||||
assert!(window >= 2, "window must be >= 2");
|
||||
let n = x.len();
|
||||
assert!(n > 0, "x must be non-empty");
|
||||
let mut result = vec![f64::NAN; n];
|
||||
for i in (window - 1)..n {
|
||||
let win = &x[i + 1 - window..=i];
|
||||
let mean: f64 = win.iter().sum::<f64>() / window as f64;
|
||||
let var: f64 = win.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / window as f64;
|
||||
let std = var.sqrt();
|
||||
result[i] = if std == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
(x[i] - mean) / std
|
||||
};
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// compose_weighted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Weighted combination of multiple signal columns.
|
||||
///
|
||||
/// `data` is a slice of column vectors — `data[j]` is one signal column.
|
||||
/// `weights` has one entry per column.
|
||||
///
|
||||
/// Returns a Vec of length n_bars where each entry is the weighted sum across
|
||||
/// columns for that bar.
|
||||
///
|
||||
/// Panics if weights length != number of columns, or columns have unequal lengths.
|
||||
pub fn compose_weighted(data: &[Vec<f64>], weights: &[f64]) -> Vec<f64> {
|
||||
let n_sigs = data.len();
|
||||
assert!(
|
||||
weights.len() == n_sigs,
|
||||
"weights length ({}) must equal number of signal columns ({})",
|
||||
weights.len(),
|
||||
n_sigs
|
||||
);
|
||||
if n_sigs == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let n_bars = data[0].len();
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for j in 1..n_sigs {
|
||||
assert!(
|
||||
data[j].len() == n_bars,
|
||||
"all columns must have equal length"
|
||||
);
|
||||
}
|
||||
let mut result = vec![0.0_f64; n_bars];
|
||||
for i in 0..n_bars {
|
||||
let mut s = 0.0_f64;
|
||||
for j in 0..n_sigs {
|
||||
s += data[j][i] * weights[j];
|
||||
}
|
||||
result[i] = s;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f64 = 1e-10;
|
||||
|
||||
fn approx_eq(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < EPS
|
||||
}
|
||||
|
||||
// -- portfolio_volatility -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_portfolio_volatility_identity_cov() {
|
||||
// Identity covariance, equal weights => sqrt(sum(w_i^2))
|
||||
let cov = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
|
||||
let w = vec![0.5, 0.5];
|
||||
let vol = portfolio_volatility(&cov, &w);
|
||||
// w' I w = 0.25 + 0.25 = 0.5, sqrt = 0.7071...
|
||||
assert!(approx_eq(vol, (0.5_f64).sqrt()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_portfolio_volatility_single_asset() {
|
||||
let cov = vec![vec![0.04]];
|
||||
let w = vec![1.0];
|
||||
assert!(approx_eq(portfolio_volatility(&cov, &w), 0.2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_portfolio_volatility_correlated() {
|
||||
// Fully correlated: cov = [[0.04, 0.04], [0.04, 0.04]]
|
||||
let cov = vec![vec![0.04, 0.04], vec![0.04, 0.04]];
|
||||
let w = vec![0.5, 0.5];
|
||||
// w' Σ w = 0.04, sqrt = 0.2
|
||||
let vol = portfolio_volatility(&cov, &w);
|
||||
assert!(approx_eq(vol, 0.2));
|
||||
}
|
||||
|
||||
// -- beta_full ------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_beta_full_same_series() {
|
||||
let r = vec![0.01, -0.02, 0.03, -0.01, 0.02];
|
||||
assert!(approx_eq(beta_full(&r, &r), 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_beta_full_double() {
|
||||
let bench = vec![0.01, -0.02, 0.03, -0.01, 0.02];
|
||||
let asset: Vec<f64> = bench.iter().map(|x| x * 2.0).collect();
|
||||
assert!(approx_eq(beta_full(&asset, &bench), 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_beta_full_zero_variance() {
|
||||
let a = vec![0.01, 0.02];
|
||||
let b = vec![0.05, 0.05]; // zero variance
|
||||
beta_full(&a, &b);
|
||||
}
|
||||
|
||||
// -- rolling_beta ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_rolling_beta_warmup_nan() {
|
||||
let a = vec![0.01, -0.02, 0.03, -0.01, 0.02];
|
||||
let b = vec![0.01, -0.02, 0.03, -0.01, 0.02];
|
||||
let rb = rolling_beta(&a, &b, 3);
|
||||
assert_eq!(rb.len(), 5);
|
||||
assert!(rb[0].is_nan());
|
||||
assert!(rb[1].is_nan());
|
||||
// From index 2 onward, beta of identical series = 1.0
|
||||
assert!(approx_eq(rb[2], 1.0));
|
||||
assert!(approx_eq(rb[3], 1.0));
|
||||
assert!(approx_eq(rb[4], 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_beta_double() {
|
||||
let bench = vec![0.01, -0.02, 0.03, -0.01, 0.02];
|
||||
let asset: Vec<f64> = bench.iter().map(|x| x * 3.0).collect();
|
||||
let rb = rolling_beta(&asset, &bench, 3);
|
||||
for i in 2..5 {
|
||||
assert!(approx_eq(rb[i], 3.0));
|
||||
}
|
||||
}
|
||||
|
||||
// -- drawdown_series ------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_drawdown_series_monotonic_up() {
|
||||
let eq = vec![100.0, 110.0, 120.0, 130.0];
|
||||
let (dd, max_dd) = drawdown_series(&eq);
|
||||
for &d in &dd {
|
||||
assert!(approx_eq(d, 0.0));
|
||||
}
|
||||
assert!(approx_eq(max_dd, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_drawdown_series_with_dip() {
|
||||
let eq = vec![100.0, 120.0, 90.0, 110.0];
|
||||
let (dd, max_dd) = drawdown_series(&eq);
|
||||
assert!(approx_eq(dd[0], 0.0));
|
||||
assert!(approx_eq(dd[1], 0.0));
|
||||
// dd[2] = (90 - 120) / 120 = -0.25
|
||||
assert!(approx_eq(dd[2], -0.25));
|
||||
// dd[3] = (110 - 120) / 120 = -1/12
|
||||
assert!((dd[3] - (-1.0 / 12.0)).abs() < EPS);
|
||||
assert!(approx_eq(max_dd, -0.25));
|
||||
}
|
||||
|
||||
// -- correlation_matrix ---------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_correlation_matrix_identical() {
|
||||
let col = vec![0.01, -0.02, 0.03, -0.01, 0.02];
|
||||
let data = vec![col.clone(), col.clone()];
|
||||
let cm = correlation_matrix(&data);
|
||||
assert_eq!(cm.len(), 2);
|
||||
assert!(approx_eq(cm[0][0], 1.0));
|
||||
assert!(approx_eq(cm[1][1], 1.0));
|
||||
assert!(approx_eq(cm[0][1], 1.0));
|
||||
assert!(approx_eq(cm[1][0], 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_correlation_matrix_negatively_correlated() {
|
||||
let col_a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let col_b: Vec<f64> = col_a.iter().map(|x| -x).collect();
|
||||
let data = vec![col_a, col_b];
|
||||
let cm = correlation_matrix(&data);
|
||||
assert!(approx_eq(cm[0][1], -1.0));
|
||||
assert!(approx_eq(cm[1][0], -1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_correlation_matrix_single_asset() {
|
||||
let data = vec![vec![1.0, 2.0, 3.0]];
|
||||
let cm = correlation_matrix(&data);
|
||||
assert_eq!(cm.len(), 1);
|
||||
assert!(approx_eq(cm[0][0], 1.0));
|
||||
}
|
||||
|
||||
// -- relative_strength ----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_relative_strength_equal() {
|
||||
let r = vec![0.01, -0.02, 0.03];
|
||||
let rs = relative_strength(&r, &r);
|
||||
for &v in &rs {
|
||||
assert!(approx_eq(v, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_strength_outperformance() {
|
||||
let a = vec![0.10, 0.10];
|
||||
let b = vec![0.05, 0.05];
|
||||
let rs = relative_strength(&a, &b);
|
||||
// rs[0] = 1.10 / 1.05
|
||||
assert!((rs[0] - 1.10 / 1.05).abs() < EPS);
|
||||
// rs[1] = 1.21 / 1.1025
|
||||
assert!((rs[1] - 1.21 / 1.1025).abs() < EPS);
|
||||
}
|
||||
|
||||
// -- spread ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_spread_basic() {
|
||||
let a = vec![10.0, 20.0, 30.0];
|
||||
let b = vec![5.0, 10.0, 15.0];
|
||||
let s = spread(&a, &b, 2.0);
|
||||
assert!(approx_eq(s[0], 0.0));
|
||||
assert!(approx_eq(s[1], 0.0));
|
||||
assert!(approx_eq(s[2], 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spread_hedge_one() {
|
||||
let a = vec![10.0, 20.0];
|
||||
let b = vec![3.0, 7.0];
|
||||
let s = spread(&a, &b, 1.0);
|
||||
assert!(approx_eq(s[0], 7.0));
|
||||
assert!(approx_eq(s[1], 13.0));
|
||||
}
|
||||
|
||||
// -- ratio ----------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_ratio_basic() {
|
||||
let a = vec![10.0, 20.0, 30.0];
|
||||
let b = vec![5.0, 10.0, 15.0];
|
||||
let r = ratio(&a, &b);
|
||||
assert!(approx_eq(r[0], 2.0));
|
||||
assert!(approx_eq(r[1], 2.0));
|
||||
assert!(approx_eq(r[2], 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ratio_zero_denominator() {
|
||||
let a = vec![10.0, 20.0];
|
||||
let b = vec![0.0, 5.0];
|
||||
let r = ratio(&a, &b);
|
||||
assert!(r[0].is_nan());
|
||||
assert!(approx_eq(r[1], 4.0));
|
||||
}
|
||||
|
||||
// -- zscore_series --------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_zscore_warmup_nan() {
|
||||
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let z = zscore_series(&x, 3);
|
||||
assert!(z[0].is_nan());
|
||||
assert!(z[1].is_nan());
|
||||
assert!(!z[2].is_nan());
|
||||
assert!(!z[3].is_nan());
|
||||
assert!(!z[4].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zscore_constant_window() {
|
||||
// All same values in window => std = 0 => NaN
|
||||
let x = vec![5.0, 5.0, 5.0, 5.0];
|
||||
let z = zscore_series(&x, 3);
|
||||
assert!(z[2].is_nan());
|
||||
assert!(z[3].is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zscore_known_value() {
|
||||
// Window [1, 2, 3]: mean=2, pop_std = sqrt(2/3) ~0.8165
|
||||
// z = (3 - 2) / sqrt(2/3) = sqrt(3/2) ~ 1.2247
|
||||
let x = vec![1.0, 2.0, 3.0];
|
||||
let z = zscore_series(&x, 3);
|
||||
let expected = (3.0_f64 / 2.0).sqrt();
|
||||
assert!((z[2] - expected).abs() < EPS);
|
||||
}
|
||||
|
||||
// -- compose_weighted -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_compose_weighted_basic() {
|
||||
let data = vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]];
|
||||
let weights = vec![0.3, 0.7];
|
||||
let cw = compose_weighted(&data, &weights);
|
||||
// bar 0: 1*0.3 + 4*0.7 = 3.1
|
||||
assert!(approx_eq(cw[0], 3.1));
|
||||
// bar 1: 2*0.3 + 5*0.7 = 4.1
|
||||
assert!(approx_eq(cw[1], 4.1));
|
||||
// bar 2: 3*0.3 + 6*0.7 = 5.1
|
||||
assert!(approx_eq(cw[2], 5.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_weighted_single_column() {
|
||||
let data = vec![vec![10.0, 20.0]];
|
||||
let weights = vec![2.0];
|
||||
let cw = compose_weighted(&data, &weights);
|
||||
assert!(approx_eq(cw[0], 20.0));
|
||||
assert!(approx_eq(cw[1], 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_weighted_empty() {
|
||||
let data: Vec<Vec<f64>> = vec![];
|
||||
let weights: Vec<f64> = vec![];
|
||||
let cw = compose_weighted(&data, &weights);
|
||||
assert!(cw.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Price transformations — synthesize OHLC arrays into single price arrays.
|
||||
|
||||
/// Average Price: (open + high + low + close) / 4.
|
||||
pub fn avgprice(open: &[f64], high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
open.iter()
|
||||
.zip(high.iter())
|
||||
.zip(low.iter())
|
||||
.zip(close.iter())
|
||||
.map(|(((&o, &h), &l), &c)| (o + h + l + c) / 4.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Median Price: (high + low) / 2.
|
||||
pub fn medprice(high: &[f64], low: &[f64]) -> Vec<f64> {
|
||||
high.iter()
|
||||
.zip(low.iter())
|
||||
.map(|(&h, &l)| (h + l) / 2.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Typical Price: (high + low + close) / 3.
|
||||
pub fn typprice(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
high.iter()
|
||||
.zip(low.iter())
|
||||
.zip(close.iter())
|
||||
.map(|((&h, &l), &c)| (h + l + c) / 3.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Weighted Close Price: (high + low + close * 2) / 4.
|
||||
pub fn wclprice(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
high.iter()
|
||||
.zip(low.iter())
|
||||
.zip(close.iter())
|
||||
.map(|((&h, &l), &c)| (h + l + c * 2.0) / 4.0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_avgprice() {
|
||||
let o = vec![1.0, 2.0, 3.0];
|
||||
let h = vec![4.0, 5.0, 6.0];
|
||||
let l = vec![0.5, 1.5, 2.5];
|
||||
let c = vec![2.5, 3.5, 4.5];
|
||||
let result = avgprice(&o, &h, &l, &c);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert!((result[0] - 2.0).abs() < 1e-10); // (1+4+0.5+2.5)/4 = 2.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_medprice() {
|
||||
let h = vec![10.0, 20.0];
|
||||
let l = vec![6.0, 12.0];
|
||||
let result = medprice(&h, &l);
|
||||
assert!((result[0] - 8.0).abs() < 1e-10);
|
||||
assert!((result[1] - 16.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_typprice() {
|
||||
let h = vec![10.0];
|
||||
let l = vec![6.0];
|
||||
let c = vec![8.0];
|
||||
let result = typprice(&h, &l, &c);
|
||||
assert!((result[0] - 8.0).abs() < 1e-10); // (10+6+8)/3 = 8.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wclprice() {
|
||||
let h = vec![10.0];
|
||||
let l = vec![6.0];
|
||||
let c = vec![8.0];
|
||||
let result = wclprice(&h, &l, &c);
|
||||
assert!((result[0] - 8.0).abs() < 1e-10); // (10+6+16)/4 = 8.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_inputs() {
|
||||
let empty: Vec<f64> = vec![];
|
||||
assert!(avgprice(&empty, &empty, &empty, &empty).is_empty());
|
||||
assert!(medprice(&empty, &empty).is_empty());
|
||||
assert!(typprice(&empty, &empty, &empty).is_empty());
|
||||
assert!(wclprice(&empty, &empty, &empty).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Regime detection and structural breaks.
|
||||
//!
|
||||
//! - `regime_adx` — label trend (1) vs range (0) using ADX threshold
|
||||
//! - `regime_combined` — combine ADX + ATR-ratio for robust regime labelling
|
||||
//! - `detect_breaks_cusum` — CUSUM-based structural break detection
|
||||
//! - `rolling_variance_break` — variance ratio break detection
|
||||
|
||||
/// Label each bar as trend (1) or range (0) based on ADX level.
|
||||
///
|
||||
/// Returns `Vec<i8>`: `1` = trend (ADX > threshold), `0` = range, `-1` = NaN/warmup.
|
||||
pub fn regime_adx(adx: &[f64], threshold: f64) -> Vec<i8> {
|
||||
adx.iter()
|
||||
.map(|&v| {
|
||||
if v.is_nan() {
|
||||
-1i8
|
||||
} else if v > threshold {
|
||||
1i8
|
||||
} else {
|
||||
0i8
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Label each bar as trend (1) or range (0) using ADX + ATR-ratio rule.
|
||||
///
|
||||
/// A bar is trending when: `adx[i] > adx_threshold` AND `atr[i] / close[i] > atr_pct_threshold`.
|
||||
///
|
||||
/// Returns `Vec<i8>`: `1` = trend, `0` = range, `-1` = NaN.
|
||||
pub fn regime_combined(
|
||||
adx: &[f64],
|
||||
atr: &[f64],
|
||||
close: &[f64],
|
||||
adx_threshold: f64,
|
||||
atr_pct_threshold: f64,
|
||||
) -> Vec<i8> {
|
||||
let n = adx.len();
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let av = adx[i];
|
||||
let rv = atr[i];
|
||||
let cv = close[i];
|
||||
if av.is_nan() || rv.is_nan() || cv.is_nan() || cv == 0.0 {
|
||||
-1i8
|
||||
} else if av > adx_threshold && (rv / cv) > atr_pct_threshold {
|
||||
1i8
|
||||
} else {
|
||||
0i8
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Detect structural breaks using a CUSUM (cumulative sum) approach.
|
||||
///
|
||||
/// `window` must be >= 2. Returns `Vec<i8>`: `1` at break bars, `0` elsewhere.
|
||||
pub fn detect_breaks_cusum(series: &[f64], window: usize, threshold: f64, slack: f64) -> Vec<i8> {
|
||||
let n = series.len();
|
||||
let mut out = vec![0i8; n];
|
||||
if n < window || window < 2 {
|
||||
return out;
|
||||
}
|
||||
let mut cusum_pos = 0.0_f64;
|
||||
let mut cusum_neg = 0.0_f64;
|
||||
for i in window..n {
|
||||
let slice = &series[(i - window)..i];
|
||||
let mean: f64 = slice.iter().sum::<f64>() / window as f64;
|
||||
let var: f64 =
|
||||
slice.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (window - 1) as f64;
|
||||
let std = var.sqrt();
|
||||
if std == 0.0 || std.is_nan() || series[i].is_nan() {
|
||||
continue;
|
||||
}
|
||||
let z = (series[i] - mean) / std;
|
||||
cusum_pos = (cusum_pos + z - slack).max(0.0);
|
||||
cusum_neg = (cusum_neg - z - slack).max(0.0);
|
||||
if cusum_pos > threshold || cusum_neg > threshold {
|
||||
out[i] = 1;
|
||||
cusum_pos = 0.0;
|
||||
cusum_neg = 0.0;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Detect volatility regime breaks using rolling variance ratio.
|
||||
///
|
||||
/// `short_window` must be >= 2, `long_window` must be > `short_window`.
|
||||
/// Returns `Vec<i8>`: `1` at break bars, `0` elsewhere.
|
||||
pub fn rolling_variance_break(
|
||||
series: &[f64],
|
||||
short_window: usize,
|
||||
long_window: usize,
|
||||
threshold: f64,
|
||||
) -> Vec<i8> {
|
||||
let n = series.len();
|
||||
let mut out = vec![0i8; n];
|
||||
if n < long_window || short_window < 2 || long_window <= short_window {
|
||||
return out;
|
||||
}
|
||||
|
||||
let variance = |slice: &[f64]| -> f64 {
|
||||
let k = slice.len();
|
||||
let mean: f64 = slice.iter().sum::<f64>() / k as f64;
|
||||
slice.iter().map(|&v| (v - mean) * (v - mean)).sum::<f64>() / (k - 1) as f64
|
||||
};
|
||||
|
||||
for i in long_window..n {
|
||||
let long_slice = &series[(i - long_window)..i];
|
||||
let short_slice = &series[(i - short_window)..i];
|
||||
let long_var = variance(long_slice);
|
||||
let short_var = variance(short_slice);
|
||||
if long_var == 0.0 || long_var.is_nan() || short_var.is_nan() {
|
||||
continue;
|
||||
}
|
||||
if short_var / long_var > threshold {
|
||||
out[i] = 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_regime_adx_basic() {
|
||||
let adx = vec![f64::NAN, 20.0, 30.0, 10.0, 50.0];
|
||||
let result = regime_adx(&adx, 25.0);
|
||||
assert_eq!(result, vec![-1, 0, 1, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regime_combined() {
|
||||
let adx = vec![30.0, 30.0, 10.0];
|
||||
let atr = vec![1.0, 0.001, 1.0];
|
||||
let close = vec![100.0, 100.0, 100.0];
|
||||
let result = regime_combined(&adx, &atr, &close, 25.0, 0.005);
|
||||
assert_eq!(result[0], 1); // ADX>25 and ATR/close=0.01>0.005
|
||||
assert_eq!(result[1], 0); // ATR/close=0.00001 < 0.005
|
||||
assert_eq!(result[2], 0); // ADX<25
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_breaks_cusum_short_input() {
|
||||
let series = vec![1.0, 2.0];
|
||||
let result = detect_breaks_cusum(&series, 5, 3.0, 0.5);
|
||||
assert!(result.iter().all(|&v| v == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rolling_variance_break_short_input() {
|
||||
let series = vec![1.0, 2.0, 3.0];
|
||||
let result = rolling_variance_break(&series, 2, 5, 2.0);
|
||||
assert!(result.iter().all(|&v| v == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty() {
|
||||
assert!(regime_adx(&[], 25.0).is_empty());
|
||||
assert!(regime_combined(&[], &[], &[], 25.0, 0.005).is_empty());
|
||||
assert!(detect_breaks_cusum(&[], 2, 3.0, 0.5).is_empty());
|
||||
assert!(rolling_variance_break(&[], 2, 5, 2.0).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Resampling — OHLCV resampling and multi-timeframe helpers, pure Rust.
|
||||
//!
|
||||
//! # Functions
|
||||
//! - `volume_bars` — Aggregate OHLCV bars into bars of fixed volume size.
|
||||
//! - `ohlcv_agg` — Aggregate OHLCV bars given contiguous integer group labels.
|
||||
|
||||
/// OHLCV 5-tuple return type alias.
|
||||
type Ohlcv5 = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// volume_bars
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate OHLCV data into volume bars of a fixed volume threshold.
|
||||
///
|
||||
/// Each output bar accumulates input bars until `volume_threshold` units of
|
||||
/// volume have been consumed. The resulting bar has:
|
||||
/// - open = first open of the group
|
||||
/// - high = max high of the group
|
||||
/// - low = min low of the group
|
||||
/// - close = last close of the group
|
||||
/// - volume = sum of volumes (approximately `volume_threshold`)
|
||||
///
|
||||
/// Returns `(open, high, low, close, volume)`.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if arrays are empty, have unequal lengths, or `volume_threshold <= 0`.
|
||||
pub fn volume_bars(
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
volume_threshold: f64,
|
||||
) -> Ohlcv5 {
|
||||
assert!(volume_threshold > 0.0, "volume_threshold must be > 0");
|
||||
let n = open.len();
|
||||
assert!(n > 0, "input arrays must be non-empty");
|
||||
assert!(
|
||||
high.len() == n && low.len() == n && close.len() == n && volume.len() == n,
|
||||
"all input arrays must have equal length"
|
||||
);
|
||||
|
||||
let mut out_open: Vec<f64> = Vec::new();
|
||||
let mut out_high: Vec<f64> = Vec::new();
|
||||
let mut out_low: Vec<f64> = Vec::new();
|
||||
let mut out_close: Vec<f64> = Vec::new();
|
||||
let mut out_vol: Vec<f64> = Vec::new();
|
||||
|
||||
let mut bar_open = open[0];
|
||||
let mut bar_high = high[0];
|
||||
let mut bar_low = low[0];
|
||||
let mut bar_close = close[0];
|
||||
let mut bar_vol = volume[0];
|
||||
|
||||
for i in 1..n {
|
||||
bar_high = bar_high.max(high[i]);
|
||||
bar_low = bar_low.min(low[i]);
|
||||
bar_close = close[i];
|
||||
bar_vol += volume[i];
|
||||
|
||||
if bar_vol >= volume_threshold {
|
||||
out_open.push(bar_open);
|
||||
out_high.push(bar_high);
|
||||
out_low.push(bar_low);
|
||||
out_close.push(bar_close);
|
||||
out_vol.push(bar_vol);
|
||||
// Start new bar
|
||||
if i + 1 < n {
|
||||
bar_open = open[i + 1];
|
||||
bar_high = high[i + 1];
|
||||
bar_low = low[i + 1];
|
||||
bar_close = close[i + 1];
|
||||
bar_vol = volume[i + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Push any remaining partial bar
|
||||
if bar_vol > 0.0 && out_vol.last().is_none_or(|&last| last != bar_vol) {
|
||||
out_open.push(bar_open);
|
||||
out_high.push(bar_high);
|
||||
out_low.push(bar_low);
|
||||
out_close.push(bar_close);
|
||||
out_vol.push(bar_vol);
|
||||
}
|
||||
|
||||
(out_open, out_high, out_low, out_close, out_vol)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ohlcv_agg
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Aggregate OHLCV bars by integer group labels.
|
||||
///
|
||||
/// Groups consecutive bars with the same label and computes:
|
||||
/// - open = first open of the group
|
||||
/// - high = max high of the group
|
||||
/// - low = min low of the group
|
||||
/// - close = last close of the group
|
||||
/// - volume = sum of volumes
|
||||
///
|
||||
/// `labels` must be non-decreasing (groups are contiguous).
|
||||
///
|
||||
/// Returns `(open, high, low, close, volume)`.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if arrays are empty or have unequal lengths.
|
||||
pub fn ohlcv_agg(
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
labels: &[i64],
|
||||
) -> Ohlcv5 {
|
||||
let n = open.len();
|
||||
assert!(n > 0, "input arrays must be non-empty");
|
||||
assert!(
|
||||
high.len() == n
|
||||
&& low.len() == n
|
||||
&& close.len() == n
|
||||
&& volume.len() == n
|
||||
&& labels.len() == n,
|
||||
"all input arrays must have equal length"
|
||||
);
|
||||
|
||||
let mut out_open: Vec<f64> = Vec::new();
|
||||
let mut out_high: Vec<f64> = Vec::new();
|
||||
let mut out_low: Vec<f64> = Vec::new();
|
||||
let mut out_close: Vec<f64> = Vec::new();
|
||||
let mut out_vol: Vec<f64> = Vec::new();
|
||||
|
||||
let mut cur_label = labels[0];
|
||||
let mut bar_open = open[0];
|
||||
let mut bar_high = high[0];
|
||||
let mut bar_low = low[0];
|
||||
let mut bar_close = close[0];
|
||||
let mut bar_vol = volume[0];
|
||||
|
||||
for i in 1..n {
|
||||
if labels[i] != cur_label {
|
||||
out_open.push(bar_open);
|
||||
out_high.push(bar_high);
|
||||
out_low.push(bar_low);
|
||||
out_close.push(bar_close);
|
||||
out_vol.push(bar_vol);
|
||||
cur_label = labels[i];
|
||||
bar_open = open[i];
|
||||
bar_high = high[i];
|
||||
bar_low = low[i];
|
||||
bar_close = close[i];
|
||||
bar_vol = volume[i];
|
||||
} else {
|
||||
bar_high = bar_high.max(high[i]);
|
||||
bar_low = bar_low.min(low[i]);
|
||||
bar_close = close[i];
|
||||
bar_vol += volume[i];
|
||||
}
|
||||
}
|
||||
out_open.push(bar_open);
|
||||
out_high.push(bar_high);
|
||||
out_low.push(bar_low);
|
||||
out_close.push(bar_close);
|
||||
out_vol.push(bar_vol);
|
||||
|
||||
(out_open, out_high, out_low, out_close, out_vol)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- volume_bars ---------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_volume_bars_basic() {
|
||||
let o = [100.0, 101.0, 102.0, 103.0, 104.0];
|
||||
let h = [105.0, 106.0, 107.0, 108.0, 109.0];
|
||||
let l = [95.0, 96.0, 97.0, 98.0, 99.0];
|
||||
let c = [101.0, 102.0, 103.0, 104.0, 105.0];
|
||||
let v = [50.0, 60.0, 40.0, 70.0, 30.0];
|
||||
// threshold 100: first bar covers indices 0..2 (vol=110>=100)
|
||||
let (ro, rh, rl, rc, rv) = volume_bars(&o, &h, &l, &c, &v, 100.0);
|
||||
assert!(rv.len() >= 2);
|
||||
// First bar: vol = 50+60 = 110
|
||||
assert!((rv[0] - 110.0).abs() < 1e-10);
|
||||
assert!((ro[0] - 100.0).abs() < 1e-10);
|
||||
assert!((rh[0] - 106.0).abs() < 1e-10);
|
||||
assert!((rl[0] - 95.0).abs() < 1e-10);
|
||||
assert!((rc[0] - 102.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_volume_bars_single_element() {
|
||||
let (ro, rh, rl, rc, rv) = volume_bars(&[10.0], &[12.0], &[8.0], &[11.0], &[50.0], 100.0);
|
||||
assert_eq!(rv.len(), 1);
|
||||
assert!((rv[0] - 50.0).abs() < 1e-10);
|
||||
assert!((ro[0] - 10.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "volume_threshold must be > 0")]
|
||||
fn test_volume_bars_zero_threshold() {
|
||||
volume_bars(&[1.0], &[1.0], &[1.0], &[1.0], &[1.0], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "input arrays must be non-empty")]
|
||||
fn test_volume_bars_empty() {
|
||||
volume_bars(&[], &[], &[], &[], &[], 100.0);
|
||||
}
|
||||
|
||||
// -- ohlcv_agg -----------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_ohlcv_agg_basic() {
|
||||
let o = [100.0, 101.0, 102.0, 103.0];
|
||||
let h = [105.0, 106.0, 108.0, 109.0];
|
||||
let l = [95.0, 96.0, 97.0, 98.0];
|
||||
let c = [101.0, 102.0, 103.0, 104.0];
|
||||
let v = [10.0, 20.0, 30.0, 40.0];
|
||||
let labels: [i64; 4] = [0, 0, 1, 1];
|
||||
let (ro, rh, rl, rc, rv) = ohlcv_agg(&o, &h, &l, &c, &v, &labels);
|
||||
assert_eq!(ro.len(), 2);
|
||||
// Group 0: open=100, high=max(105,106)=106, low=min(95,96)=95, close=102, vol=30
|
||||
assert!((ro[0] - 100.0).abs() < 1e-10);
|
||||
assert!((rh[0] - 106.0).abs() < 1e-10);
|
||||
assert!((rl[0] - 95.0).abs() < 1e-10);
|
||||
assert!((rc[0] - 102.0).abs() < 1e-10);
|
||||
assert!((rv[0] - 30.0).abs() < 1e-10);
|
||||
// Group 1: open=102, high=max(108,109)=109, low=min(97,98)=97, close=104, vol=70
|
||||
assert!((ro[1] - 102.0).abs() < 1e-10);
|
||||
assert!((rh[1] - 109.0).abs() < 1e-10);
|
||||
assert!((rl[1] - 97.0).abs() < 1e-10);
|
||||
assert!((rc[1] - 104.0).abs() < 1e-10);
|
||||
assert!((rv[1] - 70.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ohlcv_agg_single_group() {
|
||||
let o = [100.0, 101.0];
|
||||
let h = [105.0, 106.0];
|
||||
let l = [95.0, 96.0];
|
||||
let c = [101.0, 102.0];
|
||||
let v = [10.0, 20.0];
|
||||
let labels: [i64; 2] = [0, 0];
|
||||
let (ro, rh, rl, rc, rv) = ohlcv_agg(&o, &h, &l, &c, &v, &labels);
|
||||
assert_eq!(ro.len(), 1);
|
||||
assert!((rv[0] - 30.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ohlcv_agg_each_bar_own_group() {
|
||||
let o = [100.0, 101.0, 102.0];
|
||||
let h = [105.0, 106.0, 107.0];
|
||||
let l = [95.0, 96.0, 97.0];
|
||||
let c = [101.0, 102.0, 103.0];
|
||||
let v = [10.0, 20.0, 30.0];
|
||||
let labels: [i64; 3] = [0, 1, 2];
|
||||
let (ro, _rh, _rl, _rc, rv) = ohlcv_agg(&o, &h, &l, &c, &v, &labels);
|
||||
assert_eq!(ro.len(), 3);
|
||||
assert!((rv[0] - 10.0).abs() < 1e-10);
|
||||
assert!((rv[1] - 20.0).abs() < 1e-10);
|
||||
assert!((rv[2] - 30.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "input arrays must be non-empty")]
|
||||
fn test_ohlcv_agg_empty() {
|
||||
ohlcv_agg(&[], &[], &[], &[], &[], &[]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Signal processing helpers.
|
||||
//!
|
||||
//! - `rank_values` — fractional rank of a slice (1-based, ties averaged)
|
||||
//! - `compose_rank` — rank-based composite scores for a 2-D signal matrix
|
||||
//! - `top_n_indices` — indices of the N largest values
|
||||
//! - `bottom_n_indices` — indices of the N smallest values
|
||||
|
||||
/// Compute fractional rank of each element (1-based, ascending).
|
||||
/// Ties receive the average of their rank positions.
|
||||
pub fn rank_values(x: &[f64]) -> Vec<f64> {
|
||||
let n = x.len();
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let mut ranks = vec![0.0_f64; n];
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
let val = x[order[i]];
|
||||
let mut j = i + 1;
|
||||
while j < n && x[order[j]] == val {
|
||||
j += 1;
|
||||
}
|
||||
let avg_rank = (i + 1 + j) as f64 / 2.0;
|
||||
for k in i..j {
|
||||
ranks[order[k]] = avg_rank;
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
ranks
|
||||
}
|
||||
|
||||
/// Compute rank-based composite scores for a 2-D signal matrix.
|
||||
///
|
||||
/// Each column is ranked independently, and the per-row ranks are summed.
|
||||
/// `signals` is a slice of columns, each column being a `&[f64]` of the same length.
|
||||
pub fn compose_rank(signals: &[&[f64]]) -> Vec<f64> {
|
||||
if signals.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let n_bars = signals[0].len();
|
||||
let mut scores = vec![0.0_f64; n_bars];
|
||||
for &column in signals {
|
||||
let ranks = rank_values(column);
|
||||
for (bar_idx, rank) in ranks.into_iter().enumerate() {
|
||||
scores[bar_idx] += rank;
|
||||
}
|
||||
}
|
||||
scores
|
||||
}
|
||||
|
||||
/// Return the indices of the N largest values in `x` (descending by value).
|
||||
pub fn top_n_indices(x: &[f64], n: usize) -> Vec<i64> {
|
||||
let len = x.len();
|
||||
let k = n.min(len);
|
||||
let mut order: Vec<usize> = (0..len).collect();
|
||||
order.sort_by(|&a, &b| x[b].partial_cmp(&x[a]).unwrap_or(std::cmp::Ordering::Equal));
|
||||
order[..k].iter().map(|&i| i as i64).collect()
|
||||
}
|
||||
|
||||
/// Return the indices of the N smallest values in `x` (ascending by value).
|
||||
pub fn bottom_n_indices(x: &[f64], n: usize) -> Vec<i64> {
|
||||
let len = x.len();
|
||||
let k = n.min(len);
|
||||
let mut order: Vec<usize> = (0..len).collect();
|
||||
order.sort_by(|&a, &b| x[a].partial_cmp(&x[b]).unwrap_or(std::cmp::Ordering::Equal));
|
||||
order[..k].iter().map(|&i| i as i64).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rank_values() {
|
||||
let x = vec![3.0, 1.0, 2.0];
|
||||
let ranks = rank_values(&x);
|
||||
assert!((ranks[0] - 3.0).abs() < 1e-10); // 3.0 is largest → rank 3
|
||||
assert!((ranks[1] - 1.0).abs() < 1e-10); // 1.0 is smallest → rank 1
|
||||
assert!((ranks[2] - 2.0).abs() < 1e-10); // 2.0 is middle → rank 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rank_values_ties() {
|
||||
let x = vec![1.0, 2.0, 2.0, 4.0];
|
||||
let ranks = rank_values(&x);
|
||||
assert!((ranks[0] - 1.0).abs() < 1e-10);
|
||||
assert!((ranks[1] - 2.5).abs() < 1e-10); // tied → average
|
||||
assert!((ranks[2] - 2.5).abs() < 1e-10);
|
||||
assert!((ranks[3] - 4.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_rank() {
|
||||
let col1 = vec![3.0, 1.0, 2.0];
|
||||
let col2 = vec![1.0, 3.0, 2.0];
|
||||
let signals: Vec<&[f64]> = vec![&col1, &col2];
|
||||
let scores = compose_rank(&signals);
|
||||
// Row 0: rank(3.0)=3 + rank(1.0)=1 = 4
|
||||
// Row 1: rank(1.0)=1 + rank(3.0)=3 = 4
|
||||
// Row 2: rank(2.0)=2 + rank(2.0)=2 = 4
|
||||
assert!((scores[0] - 4.0).abs() < 1e-10);
|
||||
assert!((scores[1] - 4.0).abs() < 1e-10);
|
||||
assert!((scores[2] - 4.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_top_n_indices() {
|
||||
let x = vec![10.0, 50.0, 30.0, 20.0, 40.0];
|
||||
let result = top_n_indices(&x, 3);
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0], 1); // 50.0
|
||||
assert_eq!(result[1], 4); // 40.0
|
||||
assert_eq!(result[2], 2); // 30.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bottom_n_indices() {
|
||||
let x = vec![10.0, 50.0, 30.0, 20.0, 40.0];
|
||||
let result = bottom_n_indices(&x, 2);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0], 0); // 10.0
|
||||
assert_eq!(result[1], 3); // 20.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_top_n_exceeds_len() {
|
||||
let x = vec![1.0, 2.0];
|
||||
let result = top_n_indices(&x, 5);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
//! Statistic functions.
|
||||
|
||||
/// Standard deviation — population (`ddof = 0`).
|
||||
/// Compute the rolling population standard deviation, scaled by `nbdev`.
|
||||
///
|
||||
/// Uses population variance (`ddof = 0`). Returns `nbdev * stddev` for
|
||||
/// each window. The first `timeperiod - 1` values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `real` - Input series.
|
||||
/// * `timeperiod` - Rolling window size (must be >= 1).
|
||||
/// * `nbdev` - Multiplier applied to the standard deviation (use 1.0 for raw stddev).
|
||||
pub fn stddev(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
@@ -16,6 +24,341 @@ pub fn stddev(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Rolling population variance, scaled by `nbdev²`.
|
||||
pub fn var(real: &[f64], timeperiod: usize, nbdev: f64) -> Vec<f64> {
|
||||
let n = real.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod < 1 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
for i in (timeperiod - 1)..n {
|
||||
let window = &real[i + 1 - timeperiod..=i];
|
||||
let mean: f64 = window.iter().sum::<f64>() / timeperiod as f64;
|
||||
let variance: f64 =
|
||||
window.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / timeperiod as f64;
|
||||
result[i] = variance * nbdev * nbdev;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linear regression helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn rolling_linreg_apply<F>(prices: &[f64], timeperiod: usize, mut map: F) -> Vec<f64>
|
||||
where
|
||||
F: FnMut(f64, f64) -> f64,
|
||||
{
|
||||
let n = prices.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
let period = timeperiod as f64;
|
||||
let last_x = (timeperiod - 1) as f64;
|
||||
let sum_x = last_x * period / 2.0;
|
||||
let sum_x2 = last_x * period * (2.0 * period - 1.0) / 6.0;
|
||||
let denom = period * sum_x2 - sum_x * sum_x;
|
||||
|
||||
let mut sum_y: f64 = prices[..timeperiod].iter().sum();
|
||||
let mut sum_xy: f64 = prices[..timeperiod]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, &v)| idx as f64 * v)
|
||||
.sum();
|
||||
|
||||
for end in (timeperiod - 1)..n {
|
||||
let slope = if denom != 0.0 {
|
||||
(period * sum_xy - sum_x * sum_y) / denom
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let intercept = (sum_y - slope * sum_x) / period;
|
||||
result[end] = map(slope, intercept);
|
||||
if end + 1 < n {
|
||||
let outgoing = prices[end + 1 - timeperiod];
|
||||
let incoming = prices[end + 1];
|
||||
let prev_sum_y = sum_y;
|
||||
sum_y = prev_sum_y - outgoing + incoming;
|
||||
sum_xy = sum_xy - (prev_sum_y - outgoing) + last_x * incoming;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Linear regression fitted value at the last point of the window.
|
||||
pub fn linearreg(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let last_x = if timeperiod > 0 {
|
||||
(timeperiod - 1) as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
rolling_linreg_apply(close, timeperiod, |slope, intercept| {
|
||||
intercept + slope * last_x
|
||||
})
|
||||
}
|
||||
|
||||
/// Slope of the rolling linear regression line.
|
||||
pub fn linearreg_slope(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
rolling_linreg_apply(close, timeperiod, |slope, _| slope)
|
||||
}
|
||||
|
||||
/// Intercept of the rolling linear regression line.
|
||||
pub fn linearreg_intercept(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
rolling_linreg_apply(close, timeperiod, |_, intercept| intercept)
|
||||
}
|
||||
|
||||
/// Angle of the regression line in degrees.
|
||||
pub fn linearreg_angle(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
rolling_linreg_apply(close, timeperiod, |slope, _| {
|
||||
slope.atan() * 180.0 / std::f64::consts::PI
|
||||
})
|
||||
}
|
||||
|
||||
/// Time Series Forecast: linear regression extrapolated one period ahead.
|
||||
pub fn tsf(close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let forecast_x = timeperiod as f64;
|
||||
rolling_linreg_apply(close, timeperiod, |slope, intercept| {
|
||||
intercept + slope * forecast_x
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Beta (rolling, return-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rolling beta: regression of real1 daily returns on real0 daily returns.
|
||||
pub fn beta(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real0.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n <= timeperiod {
|
||||
return result;
|
||||
}
|
||||
|
||||
let price_return = |curr: f64, prev: f64| -> f64 {
|
||||
if prev != 0.0 {
|
||||
curr / prev - 1.0
|
||||
} else {
|
||||
f64::NAN
|
||||
}
|
||||
};
|
||||
let rx: Vec<f64> = real0.windows(2).map(|w| price_return(w[1], w[0])).collect();
|
||||
let ry: Vec<f64> = real1.windows(2).map(|w| price_return(w[1], w[0])).collect();
|
||||
|
||||
let period = timeperiod as f64;
|
||||
let mut sum_rx = 0.0_f64;
|
||||
let mut sum_ry = 0.0_f64;
|
||||
let mut sum_rx2 = 0.0_f64;
|
||||
let mut sum_rxry = 0.0_f64;
|
||||
let mut invalid = 0usize;
|
||||
|
||||
for idx in 0..timeperiod {
|
||||
let (ret_x, ret_y) = (rx[idx], ry[idx]);
|
||||
if ret_x.is_finite() && ret_y.is_finite() {
|
||||
sum_rx += ret_x;
|
||||
sum_ry += ret_y;
|
||||
sum_rx2 += ret_x * ret_x;
|
||||
sum_rxry += ret_x * ret_y;
|
||||
} else {
|
||||
invalid += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for end in timeperiod..n {
|
||||
result[end] = if invalid == 0 {
|
||||
let denom = period * sum_rx2 - sum_rx * sum_rx;
|
||||
if denom != 0.0 {
|
||||
(period * sum_rxry - sum_rx * sum_ry) / denom
|
||||
} else {
|
||||
f64::NAN
|
||||
}
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
|
||||
if end + 1 < n {
|
||||
let out = end - timeperiod;
|
||||
let (ox, oy) = (rx[out], ry[out]);
|
||||
if ox.is_finite() && oy.is_finite() {
|
||||
sum_rx -= ox;
|
||||
sum_ry -= oy;
|
||||
sum_rx2 -= ox * ox;
|
||||
sum_rxry -= ox * oy;
|
||||
} else {
|
||||
invalid -= 1;
|
||||
}
|
||||
let (ix, iy) = (rx[end], ry[end]);
|
||||
if ix.is_finite() && iy.is_finite() {
|
||||
sum_rx += ix;
|
||||
sum_ry += iy;
|
||||
sum_rx2 += ix * ix;
|
||||
sum_rxry += ix * iy;
|
||||
} else {
|
||||
invalid += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Correlation (rolling Pearson)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Rolling Pearson correlation coefficient between two series.
|
||||
pub fn correl(real0: &[f64], real1: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = real0.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
if timeperiod == 0 || n < timeperiod {
|
||||
return result;
|
||||
}
|
||||
|
||||
let period = timeperiod as f64;
|
||||
let mut sum_x: f64 = real0[..timeperiod].iter().sum();
|
||||
let mut sum_y: f64 = real1[..timeperiod].iter().sum();
|
||||
let mut sum_x2: f64 = real0[..timeperiod].iter().map(|v| v * v).sum();
|
||||
let mut sum_y2: f64 = real1[..timeperiod].iter().map(|v| v * v).sum();
|
||||
let mut sum_xy: f64 = real0[..timeperiod]
|
||||
.iter()
|
||||
.zip(real1[..timeperiod].iter())
|
||||
.map(|(&a, &b)| a * b)
|
||||
.sum();
|
||||
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for end in (timeperiod - 1)..n {
|
||||
let denom_x = period * sum_x2 - sum_x * sum_x;
|
||||
let denom_y = period * sum_y2 - sum_y * sum_y;
|
||||
result[end] = if denom_x > 0.0 && denom_y > 0.0 {
|
||||
(period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt()
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
|
||||
if end + 1 < n {
|
||||
let out = end + 1 - timeperiod;
|
||||
let inc = end + 1;
|
||||
sum_x += real0[inc] - real0[out];
|
||||
sum_y += real1[inc] - real1[out];
|
||||
sum_x2 += real0[inc] * real0[inc] - real0[out] * real0[out];
|
||||
sum_y2 += real1[inc] * real1[inc] - real1[out] * real1[out];
|
||||
sum_xy += real0[inc] * real1[inc] - real0[out] * real1[out];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic Time Warping (DTW)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Internal helper: build the full DTW accumulated-cost matrix.
|
||||
///
|
||||
/// Local cost: `|s1[i] - s2[j]|` (Euclidean / L1 for 1-D series).
|
||||
/// This matches the convention used by `dtaidistance.dtw.distance()`.
|
||||
///
|
||||
/// Out-of-band cells (Sakoe-Chiba constraint) are set to `f64::INFINITY`.
|
||||
fn dtw_matrix(s1: &[f64], s2: &[f64], window: Option<usize>) -> Vec<Vec<f64>> {
|
||||
let n = s1.len();
|
||||
let m = s2.len();
|
||||
let mut dp = vec![vec![f64::INFINITY; m]; n];
|
||||
for i in 0..n {
|
||||
// Window convention matches dtaidistance: window=w means |i-j| < w.
|
||||
// None = unconstrained (full matrix).
|
||||
let (j_lo, j_hi) = match window {
|
||||
None => (0, m),
|
||||
Some(w) => {
|
||||
let lo = i.saturating_sub(w.saturating_sub(1));
|
||||
let hi = i.saturating_add(w).min(m);
|
||||
(lo, hi)
|
||||
}
|
||||
};
|
||||
for j in j_lo..j_hi {
|
||||
// Squared Euclidean local cost — matches dtaidistance convention.
|
||||
// The final sqrt is applied only once at the top level (not per-step).
|
||||
let cost = (s1[i] - s2[j]).powi(2);
|
||||
let prev = if i == 0 && j == 0 {
|
||||
0.0
|
||||
} else if i == 0 {
|
||||
dp[0][j - 1]
|
||||
} else if j == 0 {
|
||||
dp[i - 1][0]
|
||||
} else {
|
||||
dp[i - 1][j - 1].min(dp[i - 1][j]).min(dp[i][j - 1])
|
||||
};
|
||||
dp[i][j] = cost + prev;
|
||||
}
|
||||
}
|
||||
dp
|
||||
}
|
||||
|
||||
/// Compute the Dynamic Time Warping distance between two 1-D series.
|
||||
///
|
||||
/// Returns the accumulated Euclidean cost along the optimal warping path.
|
||||
/// Uses `|s1[i] - s2[j]|` as the local cost, matching `dtaidistance` convention.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `s1` - First time series.
|
||||
/// * `s2` - Second time series.
|
||||
/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained.
|
||||
///
|
||||
/// Returns `f64::NAN` if either input is empty.
|
||||
pub fn dtw_distance(s1: &[f64], s2: &[f64], window: Option<usize>) -> f64 {
|
||||
if s1.is_empty() || s2.is_empty() {
|
||||
return f64::NAN;
|
||||
}
|
||||
let dp = dtw_matrix(s1, s2, window);
|
||||
// sqrt applied once at the end — matches dtaidistance.dtw.distance() convention.
|
||||
dp[s1.len() - 1][s2.len() - 1].sqrt()
|
||||
}
|
||||
|
||||
/// Compute the DTW distance and the optimal warping path between two 1-D series.
|
||||
///
|
||||
/// The warping path is a `Vec<(usize, usize)>` of `(i, j)` index pairs,
|
||||
/// starting at `(0, 0)` and ending at `(n-1, m-1)`, monotonically non-decreasing.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `s1` - First time series.
|
||||
/// * `s2` - Second time series.
|
||||
/// * `window` - Optional Sakoe-Chiba band width. `None` = unconstrained.
|
||||
///
|
||||
/// Returns `(f64::NAN, vec![])` if either input is empty.
|
||||
pub fn dtw_path(s1: &[f64], s2: &[f64], window: Option<usize>) -> (f64, Vec<(usize, usize)>) {
|
||||
if s1.is_empty() || s2.is_empty() {
|
||||
return (f64::NAN, vec![]);
|
||||
}
|
||||
let dp = dtw_matrix(s1, s2, window);
|
||||
let dist = dp[s1.len() - 1][s2.len() - 1].sqrt();
|
||||
|
||||
// Backtrace from (n-1, m-1) to (0, 0)
|
||||
let mut path = Vec::new();
|
||||
let (mut i, mut j) = (s1.len() - 1, s2.len() - 1);
|
||||
path.push((i, j));
|
||||
while i > 0 || j > 0 {
|
||||
let (ni, nj) = match (i, j) {
|
||||
(0, _) => (0, j - 1),
|
||||
(_, 0) => (i - 1, 0),
|
||||
_ => {
|
||||
let diag = dp[i - 1][j - 1];
|
||||
let up = dp[i - 1][j];
|
||||
let left = dp[i][j - 1];
|
||||
let best = diag.min(up).min(left);
|
||||
if best == diag {
|
||||
(i - 1, j - 1)
|
||||
} else if best == up {
|
||||
(i - 1, j)
|
||||
} else {
|
||||
(i, j - 1)
|
||||
}
|
||||
}
|
||||
};
|
||||
i = ni;
|
||||
j = nj;
|
||||
path.push((i, j));
|
||||
}
|
||||
path.reverse();
|
||||
(dist, path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -28,4 +371,92 @@ mod tests {
|
||||
assert!(v.abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtw_identical_series_is_zero() {
|
||||
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
assert_eq!(dtw_distance(&a, &a, None), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtw_known_shifted_series() {
|
||||
// [0,1,2] vs [1,2,3]: DTW uses squared Euclidean local cost + final sqrt.
|
||||
// Optimal path (0,0)→(1,0)→(2,1)→(2,2), accumulated cost = 1+0+0+1 = 2, sqrt(2).
|
||||
// Matches dtaidistance.dtw.distance([0,1,2],[1,2,3]) = 1.4142...
|
||||
let a = vec![0.0, 1.0, 2.0];
|
||||
let b = vec![1.0, 2.0, 3.0];
|
||||
let expected = 2.0_f64.sqrt();
|
||||
let result = dtw_distance(&a, &b, None);
|
||||
assert!(
|
||||
(result - expected).abs() < 1e-12,
|
||||
"got {result}, expected {expected}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtw_known_even_shift() {
|
||||
// [0,2,4] vs [1,3,5]: diagonal path, squared costs 1+1+1=3, sqrt(3).
|
||||
// Matches dtaidistance.dtw.distance([0,2,4],[1,3,5]) = 1.7320...
|
||||
let a = vec![0.0, 2.0, 4.0];
|
||||
let b = vec![1.0, 3.0, 5.0];
|
||||
let expected = 3.0_f64.sqrt();
|
||||
let result = dtw_distance(&a, &b, None);
|
||||
assert!(
|
||||
(result - expected).abs() < 1e-12,
|
||||
"got {result}, expected {expected}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtw_single_element() {
|
||||
let a = vec![3.0];
|
||||
let b = vec![7.0];
|
||||
assert_eq!(dtw_distance(&a, &b, None), 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtw_empty_returns_nan() {
|
||||
assert!(dtw_distance(&[], &[1.0, 2.0], None).is_nan());
|
||||
assert!(dtw_distance(&[1.0, 2.0], &[], None).is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtw_path_endpoints() {
|
||||
let a = vec![1.0, 2.0, 3.0, 4.0];
|
||||
let b = vec![1.5, 2.5, 3.5, 4.5];
|
||||
let (_, path) = dtw_path(&a, &b, None);
|
||||
assert_eq!(path.first(), Some(&(0, 0)));
|
||||
assert_eq!(path.last(), Some(&(3, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtw_path_is_monotone() {
|
||||
let a = vec![1.0, 3.0, 2.0, 5.0, 4.0];
|
||||
let b = vec![2.0, 1.0, 4.0, 3.0, 6.0];
|
||||
let (_, path) = dtw_path(&a, &b, None);
|
||||
for k in 1..path.len() {
|
||||
assert!(path[k].0 >= path[k - 1].0);
|
||||
assert!(path[k].1 >= path[k - 1].1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtw_path_distance_matches_distance_only() {
|
||||
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0];
|
||||
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0];
|
||||
let d1 = dtw_distance(&a, &b, None);
|
||||
let (d2, _) = dtw_path(&a, &b, None);
|
||||
assert!((d1 - d2).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtw_window_constrained_ge_unconstrained() {
|
||||
// window convention matches dtaidistance: Some(w) means |i-j| < w.
|
||||
// A narrow window restricts warping, so constrained distance >= unconstrained.
|
||||
let a: Vec<f64> = (0..20).map(|x| x as f64).collect();
|
||||
let b: Vec<f64> = (0..20).map(|x| x as f64 + 3.0).collect();
|
||||
let d_full = dtw_distance(&a, &b, None);
|
||||
let d_narrow = dtw_distance(&a, &b, Some(3));
|
||||
assert!(d_narrow >= d_full - 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,946 @@
|
||||
//! Streaming / Incremental Indicators — bar-by-bar stateful structs.
|
||||
//!
|
||||
//! Pure Rust implementations with no PyO3 dependency. Each struct:
|
||||
//! - Accepts one value per call to `update()`.
|
||||
//! - Returns `NaN` (or a NaN tuple) during the warm-up window.
|
||||
//! - Exposes a `reset()` method to restart from scratch.
|
||||
//! - Has a `period()` accessor (where applicable).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Validation error for streaming indicator parameters.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamingError(pub String);
|
||||
|
||||
impl std::fmt::Display for StreamingError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for StreamingError {}
|
||||
|
||||
fn validate_timeperiod(value: usize, name: &str, minimum: usize) -> Result<(), StreamingError> {
|
||||
if value < minimum {
|
||||
return Err(StreamingError(format!(
|
||||
"{} must be >= {}, got {}",
|
||||
name, minimum, value
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helper: EMA state (used inside composite classes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// SMA-seeded EMA state machine. Not exposed directly — used by
|
||||
/// `StreamingEMA`, `StreamingMACD`, etc.
|
||||
pub(crate) struct EmaState {
|
||||
period: usize,
|
||||
alpha: f64,
|
||||
ema: f64,
|
||||
seed_buf: Vec<f64>,
|
||||
seeded: bool,
|
||||
}
|
||||
|
||||
impl EmaState {
|
||||
pub fn new(period: usize) -> Self {
|
||||
Self {
|
||||
period,
|
||||
alpha: 2.0 / (period as f64 + 1.0),
|
||||
ema: 0.0,
|
||||
seed_buf: Vec::with_capacity(period),
|
||||
seeded: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, value: f64) -> f64 {
|
||||
if !self.seeded {
|
||||
self.seed_buf.push(value);
|
||||
if self.seed_buf.len() < self.period {
|
||||
return f64::NAN;
|
||||
}
|
||||
let seed = self.seed_buf.iter().sum::<f64>() / self.period as f64;
|
||||
self.ema = seed;
|
||||
self.seeded = true;
|
||||
return seed;
|
||||
}
|
||||
self.ema += self.alpha * (value - self.ema);
|
||||
self.ema
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.ema = 0.0;
|
||||
self.seed_buf.clear();
|
||||
self.seeded = false;
|
||||
}
|
||||
|
||||
pub fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helper: ATR state (Wilder smoothing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wilder-smoothed ATR state machine. Used by `StreamingATR` and
|
||||
/// `StreamingSupertrend`.
|
||||
pub(crate) struct AtrState {
|
||||
period: usize,
|
||||
prev_close: f64,
|
||||
tr_buf: Vec<f64>,
|
||||
atr: f64,
|
||||
seeded: bool,
|
||||
has_prev: bool,
|
||||
}
|
||||
|
||||
impl AtrState {
|
||||
pub fn new(period: usize) -> Self {
|
||||
Self {
|
||||
period,
|
||||
prev_close: 0.0,
|
||||
tr_buf: Vec::with_capacity(period),
|
||||
atr: 0.0,
|
||||
seeded: false,
|
||||
has_prev: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> f64 {
|
||||
let tr = if self.has_prev {
|
||||
let hl = high - low;
|
||||
let hc = (high - self.prev_close).abs();
|
||||
let lc = (low - self.prev_close).abs();
|
||||
hl.max(hc).max(lc)
|
||||
} else {
|
||||
high - low
|
||||
};
|
||||
self.prev_close = close;
|
||||
self.has_prev = true;
|
||||
|
||||
if !self.seeded {
|
||||
self.tr_buf.push(tr);
|
||||
if self.tr_buf.len() < self.period {
|
||||
return f64::NAN;
|
||||
}
|
||||
let seed = self.tr_buf.iter().sum::<f64>() / self.period as f64;
|
||||
self.atr = seed;
|
||||
self.seeded = true;
|
||||
return f64::NAN; // first `period` bars (including this one) return NaN
|
||||
}
|
||||
let pf = (self.period - 1) as f64;
|
||||
self.atr = (self.atr * pf + tr) / self.period as f64;
|
||||
self.atr
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.prev_close = 0.0;
|
||||
self.has_prev = false;
|
||||
self.tr_buf.clear();
|
||||
self.atr = 0.0;
|
||||
self.seeded = false;
|
||||
}
|
||||
|
||||
pub fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingSMA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Simple Moving Average — O(1) per update via running sum.
|
||||
///
|
||||
/// Returns NaN during the first `period - 1` bars.
|
||||
pub struct StreamingSMA {
|
||||
period: usize,
|
||||
buf: VecDeque<f64>,
|
||||
running_sum: f64,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl StreamingSMA {
|
||||
pub fn new(period: usize) -> Result<Self, StreamingError> {
|
||||
validate_timeperiod(period, "period", 1)?;
|
||||
Ok(Self {
|
||||
period,
|
||||
buf: VecDeque::with_capacity(period + 1),
|
||||
running_sum: 0.0,
|
||||
count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a new bar and return the current SMA (NaN during warmup).
|
||||
pub fn update(&mut self, value: f64) -> f64 {
|
||||
if self.buf.len() == self.period {
|
||||
if let Some(old) = self.buf.pop_front() {
|
||||
self.running_sum -= old;
|
||||
}
|
||||
}
|
||||
self.buf.push_back(value);
|
||||
self.running_sum += value;
|
||||
self.count += 1;
|
||||
if self.count < self.period {
|
||||
f64::NAN
|
||||
} else {
|
||||
self.running_sum / self.period as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset state to initial condition.
|
||||
pub fn reset(&mut self) {
|
||||
self.buf.clear();
|
||||
self.running_sum = 0.0;
|
||||
self.count = 0;
|
||||
}
|
||||
|
||||
pub fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingEMA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Exponential Moving Average with SMA seeding.
|
||||
///
|
||||
/// Uses a simple SMA for the first `period` bars to seed the EMA, then
|
||||
/// switches to the standard EMA formula (alpha = 2 / (period + 1)).
|
||||
/// Returns NaN during the warmup window.
|
||||
pub struct StreamingEMA {
|
||||
inner: EmaState,
|
||||
}
|
||||
|
||||
impl StreamingEMA {
|
||||
pub fn new(period: usize) -> Result<Self, StreamingError> {
|
||||
validate_timeperiod(period, "period", 1)?;
|
||||
Ok(Self {
|
||||
inner: EmaState::new(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a new bar and return the current EMA (NaN during warmup).
|
||||
pub fn update(&mut self, value: f64) -> f64 {
|
||||
self.inner.update(value)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
|
||||
pub fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingRSI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Relative Strength Index with TA-Lib-compatible Wilder seeding.
|
||||
///
|
||||
/// Returns NaN during the first `period` bars.
|
||||
pub struct StreamingRSI {
|
||||
period: usize,
|
||||
prev: f64,
|
||||
has_prev: bool,
|
||||
gains: Vec<f64>,
|
||||
losses: Vec<f64>,
|
||||
avg_gain: f64,
|
||||
avg_loss: f64,
|
||||
seeded: bool,
|
||||
}
|
||||
|
||||
impl StreamingRSI {
|
||||
pub fn new(period: usize) -> Result<Self, StreamingError> {
|
||||
validate_timeperiod(period, "period", 1)?;
|
||||
Ok(Self {
|
||||
period,
|
||||
prev: 0.0,
|
||||
has_prev: false,
|
||||
gains: Vec::with_capacity(period),
|
||||
losses: Vec::with_capacity(period),
|
||||
avg_gain: 0.0,
|
||||
avg_loss: 0.0,
|
||||
seeded: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a new close and return RSI in [0, 100] (NaN during warmup).
|
||||
pub fn update(&mut self, value: f64) -> f64 {
|
||||
if !self.has_prev {
|
||||
self.prev = value;
|
||||
self.has_prev = true;
|
||||
return f64::NAN;
|
||||
}
|
||||
let delta = value - self.prev;
|
||||
self.prev = value;
|
||||
let gain = if delta > 0.0 { delta } else { 0.0 };
|
||||
let loss = if delta < 0.0 { -delta } else { 0.0 };
|
||||
|
||||
if !self.seeded {
|
||||
self.gains.push(gain);
|
||||
self.losses.push(loss);
|
||||
if self.gains.len() < self.period {
|
||||
return f64::NAN;
|
||||
}
|
||||
self.avg_gain = self.gains.iter().sum::<f64>() / self.period as f64;
|
||||
self.avg_loss = self.losses.iter().sum::<f64>() / self.period as f64;
|
||||
self.seeded = true;
|
||||
} else {
|
||||
let pf = (self.period - 1) as f64;
|
||||
self.avg_gain = (self.avg_gain * pf + gain) / self.period as f64;
|
||||
self.avg_loss = (self.avg_loss * pf + loss) / self.period as f64;
|
||||
}
|
||||
|
||||
if self.avg_loss == 0.0 {
|
||||
return 100.0;
|
||||
}
|
||||
let rs = self.avg_gain / self.avg_loss;
|
||||
100.0 - 100.0 / (1.0 + rs)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.prev = 0.0;
|
||||
self.has_prev = false;
|
||||
self.gains.clear();
|
||||
self.losses.clear();
|
||||
self.avg_gain = 0.0;
|
||||
self.avg_loss = 0.0;
|
||||
self.seeded = false;
|
||||
}
|
||||
|
||||
pub fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingATR
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Average True Range with TA-Lib-compatible Wilder seeding.
|
||||
///
|
||||
/// Accepts (high, low, close) per bar.
|
||||
/// Returns NaN during the first `period` bars.
|
||||
pub struct StreamingATR {
|
||||
inner: AtrState,
|
||||
}
|
||||
|
||||
impl StreamingATR {
|
||||
pub fn new(period: usize) -> Result<Self, StreamingError> {
|
||||
validate_timeperiod(period, "period", 1)?;
|
||||
Ok(Self {
|
||||
inner: AtrState::new(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a new bar (high, low, close) and return ATR (NaN during warmup).
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> f64 {
|
||||
self.inner.update(high, low, close)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
|
||||
pub fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingBBands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Bollinger Bands — streaming variant using Welford's online algorithm.
|
||||
///
|
||||
/// Returns (upper, middle, lower).
|
||||
/// NaN tuple during the warmup window.
|
||||
pub struct StreamingBBands {
|
||||
period: usize,
|
||||
nbdevup: f64,
|
||||
nbdevdn: f64,
|
||||
buf: VecDeque<f64>,
|
||||
mean: f64,
|
||||
m2: f64,
|
||||
}
|
||||
|
||||
impl StreamingBBands {
|
||||
pub fn new(period: usize, nbdevup: f64, nbdevdn: f64) -> Result<Self, StreamingError> {
|
||||
validate_timeperiod(period, "period", 2)?;
|
||||
Ok(Self {
|
||||
period,
|
||||
nbdevup,
|
||||
nbdevdn,
|
||||
buf: VecDeque::with_capacity(period + 1),
|
||||
mean: 0.0,
|
||||
m2: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a new bar; return (upper, middle, lower). NaN tuple during warmup.
|
||||
pub fn update(&mut self, value: f64) -> (f64, f64, f64) {
|
||||
let n = self.buf.len();
|
||||
|
||||
if n == self.period {
|
||||
let x_old = self.buf.pop_front().unwrap();
|
||||
let count = self.period as f64;
|
||||
let delta_old = x_old - self.mean;
|
||||
self.mean -= delta_old / (count - 1.0);
|
||||
let delta2_old = x_old - self.mean;
|
||||
self.m2 -= delta_old * delta2_old;
|
||||
}
|
||||
|
||||
self.buf.push_back(value);
|
||||
let count = self.buf.len() as f64;
|
||||
let delta_new = value - self.mean;
|
||||
self.mean += delta_new / count;
|
||||
let delta2_new = value - self.mean;
|
||||
self.m2 += delta_new * delta2_new;
|
||||
|
||||
if self.m2 < 0.0 {
|
||||
self.m2 = 0.0;
|
||||
}
|
||||
|
||||
if self.buf.len() < self.period {
|
||||
return (f64::NAN, f64::NAN, f64::NAN);
|
||||
}
|
||||
|
||||
let variance = self.m2 / (count - 1.0);
|
||||
let std = variance.sqrt();
|
||||
(
|
||||
self.mean + self.nbdevup * std,
|
||||
self.mean,
|
||||
self.mean - self.nbdevdn * std,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.buf.clear();
|
||||
self.mean = 0.0;
|
||||
self.m2 = 0.0;
|
||||
}
|
||||
|
||||
pub fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingMACD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// MACD — fast EMA, slow EMA, signal EMA.
|
||||
///
|
||||
/// Returns (macd_line, signal_line, histogram).
|
||||
/// NaN values during warmup.
|
||||
pub struct StreamingMACD {
|
||||
fast: EmaState,
|
||||
slow: EmaState,
|
||||
signal: EmaState,
|
||||
}
|
||||
|
||||
impl StreamingMACD {
|
||||
pub fn new(
|
||||
fastperiod: usize,
|
||||
slowperiod: usize,
|
||||
signalperiod: usize,
|
||||
) -> Result<Self, StreamingError> {
|
||||
validate_timeperiod(fastperiod, "fastperiod", 1)?;
|
||||
validate_timeperiod(slowperiod, "slowperiod", 1)?;
|
||||
validate_timeperiod(signalperiod, "signalperiod", 1)?;
|
||||
if fastperiod >= slowperiod {
|
||||
return Err(StreamingError(
|
||||
"fastperiod must be < slowperiod".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
fast: EmaState::new(fastperiod),
|
||||
slow: EmaState::new(slowperiod),
|
||||
signal: EmaState::new(signalperiod),
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a new close; return (macd_line, signal_line, histogram).
|
||||
pub fn update(&mut self, value: f64) -> (f64, f64, f64) {
|
||||
let fast_val = self.fast.update(value);
|
||||
let slow_val = self.slow.update(value);
|
||||
|
||||
if slow_val.is_nan() {
|
||||
return (f64::NAN, f64::NAN, f64::NAN);
|
||||
}
|
||||
|
||||
let macd = fast_val - slow_val;
|
||||
let signal = self.signal.update(macd);
|
||||
if signal.is_nan() {
|
||||
return (macd, f64::NAN, f64::NAN);
|
||||
}
|
||||
(macd, signal, macd - signal)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.fast.reset();
|
||||
self.slow.reset();
|
||||
self.signal.reset();
|
||||
}
|
||||
|
||||
pub fn fast_period(&self) -> usize {
|
||||
self.fast.period()
|
||||
}
|
||||
|
||||
pub fn slow_period(&self) -> usize {
|
||||
self.slow.period()
|
||||
}
|
||||
|
||||
pub fn signal_period(&self) -> usize {
|
||||
self.signal.period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingStoch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Slow Stochastic (SMA-smoothed).
|
||||
///
|
||||
/// Returns (slowk, slowd).
|
||||
/// NaN tuple during warmup.
|
||||
pub struct StreamingStoch {
|
||||
fastk_period: usize,
|
||||
slowk_period: usize,
|
||||
slowd_period: usize,
|
||||
high_buf: VecDeque<f64>,
|
||||
low_buf: VecDeque<f64>,
|
||||
fastk_buf: VecDeque<f64>,
|
||||
slowk_buf: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl StreamingStoch {
|
||||
pub fn new(
|
||||
fastk_period: usize,
|
||||
slowk_period: usize,
|
||||
slowd_period: usize,
|
||||
) -> Result<Self, StreamingError> {
|
||||
validate_timeperiod(fastk_period, "fastk_period", 1)?;
|
||||
validate_timeperiod(slowk_period, "slowk_period", 1)?;
|
||||
validate_timeperiod(slowd_period, "slowd_period", 1)?;
|
||||
Ok(Self {
|
||||
fastk_period,
|
||||
slowk_period,
|
||||
slowd_period,
|
||||
high_buf: VecDeque::with_capacity(fastk_period + 1),
|
||||
low_buf: VecDeque::with_capacity(fastk_period + 1),
|
||||
fastk_buf: VecDeque::with_capacity(slowk_period + 1),
|
||||
slowk_buf: VecDeque::with_capacity(slowd_period + 1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a new bar (high, low, close); return (slowk, slowd).
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> (f64, f64) {
|
||||
if self.high_buf.len() == self.fastk_period {
|
||||
self.high_buf.pop_front();
|
||||
self.low_buf.pop_front();
|
||||
}
|
||||
self.high_buf.push_back(high);
|
||||
self.low_buf.push_back(low);
|
||||
|
||||
if self.high_buf.len() < self.fastk_period {
|
||||
return (f64::NAN, f64::NAN);
|
||||
}
|
||||
|
||||
let max_h = self
|
||||
.high_buf
|
||||
.iter()
|
||||
.cloned()
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let min_l = self.low_buf.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
|
||||
let fastk = if max_h != min_l {
|
||||
100.0 * (close - min_l) / (max_h - min_l)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if self.fastk_buf.len() == self.slowk_period {
|
||||
self.fastk_buf.pop_front();
|
||||
}
|
||||
self.fastk_buf.push_back(fastk);
|
||||
if self.fastk_buf.len() < self.slowk_period {
|
||||
return (f64::NAN, f64::NAN);
|
||||
}
|
||||
|
||||
let slowk = self.fastk_buf.iter().sum::<f64>() / self.slowk_period as f64;
|
||||
|
||||
if self.slowk_buf.len() == self.slowd_period {
|
||||
self.slowk_buf.pop_front();
|
||||
}
|
||||
self.slowk_buf.push_back(slowk);
|
||||
if self.slowk_buf.len() < self.slowd_period {
|
||||
return (slowk, f64::NAN);
|
||||
}
|
||||
|
||||
let slowd = self.slowk_buf.iter().sum::<f64>() / self.slowd_period as f64;
|
||||
(slowk, slowd)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.high_buf.clear();
|
||||
self.low_buf.clear();
|
||||
self.fastk_buf.clear();
|
||||
self.slowk_buf.clear();
|
||||
}
|
||||
|
||||
pub fn period(&self) -> usize {
|
||||
self.fastk_period
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingVWAP
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Cumulative Volume Weighted Average Price.
|
||||
///
|
||||
/// Resets automatically when `reset()` is called (e.g. at session open).
|
||||
/// Accepts (high, low, close, volume) per bar.
|
||||
#[derive(Default)]
|
||||
pub struct StreamingVWAP {
|
||||
cum_tpv: f64,
|
||||
cum_vol: f64,
|
||||
}
|
||||
|
||||
impl StreamingVWAP {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cum_tpv: 0.0,
|
||||
cum_vol: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new bar (high, low, close, volume) and return cumulative VWAP.
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64, volume: f64) -> f64 {
|
||||
let tp = (high + low + close) / 3.0;
|
||||
self.cum_tpv += tp * volume;
|
||||
self.cum_vol += volume;
|
||||
if self.cum_vol == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
self.cum_tpv / self.cum_vol
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset for a new session.
|
||||
pub fn reset(&mut self) {
|
||||
self.cum_tpv = 0.0;
|
||||
self.cum_vol = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StreamingSupertrend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// ATR-based Supertrend — streaming variant.
|
||||
///
|
||||
/// Accepts (high, low, close) per bar.
|
||||
/// Returns (supertrend_line, direction).
|
||||
/// direction: 1 = uptrend, -1 = downtrend, 0 = warmup.
|
||||
pub struct StreamingSupertrend {
|
||||
period: usize,
|
||||
multiplier: f64,
|
||||
atr: AtrState,
|
||||
upper_band: f64,
|
||||
lower_band: f64,
|
||||
has_bands: bool,
|
||||
direction: i8,
|
||||
prev_close: f64,
|
||||
has_prev: bool,
|
||||
}
|
||||
|
||||
impl StreamingSupertrend {
|
||||
pub fn new(period: usize, multiplier: f64) -> Result<Self, StreamingError> {
|
||||
validate_timeperiod(period, "period", 1)?;
|
||||
Ok(Self {
|
||||
period,
|
||||
multiplier,
|
||||
atr: AtrState::new(period),
|
||||
upper_band: 0.0,
|
||||
lower_band: 0.0,
|
||||
has_bands: false,
|
||||
direction: 0,
|
||||
prev_close: 0.0,
|
||||
has_prev: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a new bar (high, low, close); return (supertrend_line, direction).
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> (f64, i8) {
|
||||
let atr = self.atr.update(high, low, close);
|
||||
if atr.is_nan() {
|
||||
self.prev_close = close;
|
||||
self.has_prev = true;
|
||||
return (f64::NAN, 0);
|
||||
}
|
||||
|
||||
let hl2 = (high + low) / 2.0;
|
||||
let upper_basic = hl2 + self.multiplier * atr;
|
||||
let lower_basic = hl2 - self.multiplier * atr;
|
||||
|
||||
if !self.has_bands {
|
||||
self.upper_band = upper_basic;
|
||||
self.lower_band = lower_basic;
|
||||
self.has_bands = true;
|
||||
self.direction = -1;
|
||||
self.prev_close = close;
|
||||
self.has_prev = true;
|
||||
return (self.upper_band, self.direction);
|
||||
}
|
||||
|
||||
let prev_close = self.prev_close;
|
||||
|
||||
let new_lower = if lower_basic > self.lower_band || prev_close < self.lower_band {
|
||||
lower_basic
|
||||
} else {
|
||||
self.lower_band
|
||||
};
|
||||
let new_upper = if upper_basic < self.upper_band || prev_close > self.upper_band {
|
||||
upper_basic
|
||||
} else {
|
||||
self.upper_band
|
||||
};
|
||||
|
||||
self.lower_band = new_lower;
|
||||
self.upper_band = new_upper;
|
||||
|
||||
self.direction = if self.direction == -1 {
|
||||
if close > new_upper {
|
||||
1
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
} else if close < new_lower {
|
||||
-1
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
self.prev_close = close;
|
||||
let line = if self.direction == 1 {
|
||||
new_lower
|
||||
} else {
|
||||
new_upper
|
||||
};
|
||||
(line, self.direction)
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.atr.reset();
|
||||
self.upper_band = 0.0;
|
||||
self.lower_band = 0.0;
|
||||
self.has_bands = false;
|
||||
self.direction = 0;
|
||||
self.prev_close = 0.0;
|
||||
self.has_prev = false;
|
||||
}
|
||||
|
||||
pub fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper: compare two f64 values, treating NaN == NaN as true.
|
||||
fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
|
||||
if a.is_nan() && b.is_nan() {
|
||||
return true;
|
||||
}
|
||||
(a - b).abs() < tol
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sma_basic() {
|
||||
let mut sma = StreamingSMA::new(3).unwrap();
|
||||
assert!(sma.update(1.0).is_nan());
|
||||
assert!(sma.update(2.0).is_nan());
|
||||
let v = sma.update(3.0);
|
||||
assert!(approx_eq(v, 2.0, 1e-10));
|
||||
let v = sma.update(4.0);
|
||||
assert!(approx_eq(v, 3.0, 1e-10));
|
||||
let v = sma.update(5.0);
|
||||
assert!(approx_eq(v, 4.0, 1e-10));
|
||||
assert_eq!(sma.period(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sma_reset() {
|
||||
let mut sma = StreamingSMA::new(2).unwrap();
|
||||
sma.update(10.0);
|
||||
sma.update(20.0);
|
||||
sma.reset();
|
||||
assert!(sma.update(5.0).is_nan());
|
||||
let v = sma.update(7.0);
|
||||
assert!(approx_eq(v, 6.0, 1e-10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ema_warmup_and_decay() {
|
||||
let mut ema = StreamingEMA::new(3).unwrap();
|
||||
assert!(ema.update(2.0).is_nan());
|
||||
assert!(ema.update(4.0).is_nan());
|
||||
// Third bar: SMA seed = (2+4+6)/3 = 4.0
|
||||
let v = ema.update(6.0);
|
||||
assert!(approx_eq(v, 4.0, 1e-10));
|
||||
// Fourth bar: alpha = 0.5, ema = 4.0 + 0.5*(8.0-4.0) = 6.0
|
||||
let v = ema.update(8.0);
|
||||
assert!(approx_eq(v, 6.0, 1e-10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rsi_warmup() {
|
||||
let mut rsi = StreamingRSI::new(3).unwrap();
|
||||
// First bar: no prev
|
||||
assert!(rsi.update(44.0).is_nan());
|
||||
// Bars 2-4: collecting gains/losses
|
||||
assert!(rsi.update(44.5).is_nan());
|
||||
assert!(rsi.update(43.5).is_nan());
|
||||
// Bar 5: seeded
|
||||
let v = rsi.update(44.5);
|
||||
assert!(!v.is_nan());
|
||||
assert!(v >= 0.0 && v <= 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_atr_warmup() {
|
||||
let mut atr = StreamingATR::new(3).unwrap();
|
||||
// First 3 bars return NaN (period = 3, seed happens on bar 3 but still NaN)
|
||||
assert!(atr.update(10.0, 9.0, 9.5).is_nan());
|
||||
assert!(atr.update(11.0, 9.5, 10.5).is_nan());
|
||||
assert!(atr.update(10.5, 9.0, 9.5).is_nan());
|
||||
// Bar 4: first real value
|
||||
let v = atr.update(11.0, 10.0, 10.5);
|
||||
assert!(!v.is_nan());
|
||||
assert!(v > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bbands_warmup() {
|
||||
let mut bb = StreamingBBands::new(3, 2.0, 2.0).unwrap();
|
||||
let (u, m, l) = bb.update(10.0);
|
||||
assert!(u.is_nan() && m.is_nan() && l.is_nan());
|
||||
let (u, m, l) = bb.update(11.0);
|
||||
assert!(u.is_nan() && m.is_nan() && l.is_nan());
|
||||
let (u, m, l) = bb.update(12.0);
|
||||
assert!(!u.is_nan() && !m.is_nan() && !l.is_nan());
|
||||
assert!(approx_eq(m, 11.0, 1e-10));
|
||||
assert!(u > m && l < m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macd_basic() {
|
||||
let mut macd = StreamingMACD::new(3, 5, 2).unwrap();
|
||||
// Feed enough bars for the slow (5) to seed
|
||||
for i in 0..4 {
|
||||
let (m, s, h) = macd.update(100.0 + i as f64);
|
||||
assert!(m.is_nan());
|
||||
}
|
||||
// Bar 5: slow seeds
|
||||
let (m, s, _h) = macd.update(104.0);
|
||||
assert!(!m.is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macd_fast_ge_slow_rejected() {
|
||||
assert!(StreamingMACD::new(5, 3, 2).is_err());
|
||||
assert!(StreamingMACD::new(5, 5, 2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stoch_basic() {
|
||||
let mut stoch = StreamingStoch::new(3, 2, 2).unwrap();
|
||||
// Need fastk_period bars, then slowk_period, then slowd_period
|
||||
let (k, d) = stoch.update(10.0, 8.0, 9.0);
|
||||
assert!(k.is_nan() && d.is_nan());
|
||||
let (k, d) = stoch.update(11.0, 9.0, 10.0);
|
||||
assert!(k.is_nan() && d.is_nan());
|
||||
// Bar 3: fastk ready, collecting slowk
|
||||
let (k, d) = stoch.update(12.0, 10.0, 11.0);
|
||||
assert!(k.is_nan());
|
||||
// Bar 4
|
||||
let (k, d) = stoch.update(13.0, 11.0, 12.0);
|
||||
assert!(!k.is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vwap_basic() {
|
||||
let mut vwap = StreamingVWAP::new();
|
||||
let v = vwap.update(10.0, 8.0, 9.0, 100.0);
|
||||
// tp = (10+8+9)/3 = 9.0, vwap = 9.0*100/100 = 9.0
|
||||
assert!(approx_eq(v, 9.0, 1e-10));
|
||||
let v = vwap.update(12.0, 10.0, 11.0, 200.0);
|
||||
// tp2 = 11.0, cum_tpv = 900+2200=3100, cum_vol=300, vwap=10.333..
|
||||
assert!(approx_eq(v, 3100.0 / 300.0, 1e-10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vwap_zero_volume() {
|
||||
let mut vwap = StreamingVWAP::new();
|
||||
let v = vwap.update(10.0, 8.0, 9.0, 0.0);
|
||||
assert!(v.is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supertrend_warmup() {
|
||||
let mut st = StreamingSupertrend::new(3, 2.0).unwrap();
|
||||
let (line, dir) = st.update(10.0, 9.0, 9.5);
|
||||
assert!(line.is_nan() && dir == 0);
|
||||
let (line, dir) = st.update(11.0, 9.5, 10.5);
|
||||
assert!(line.is_nan() && dir == 0);
|
||||
let (line, dir) = st.update(10.5, 9.0, 9.5);
|
||||
assert!(line.is_nan() && dir == 0);
|
||||
// Bar 4: first real value
|
||||
let (line, dir) = st.update(11.0, 10.0, 10.5);
|
||||
assert!(!line.is_nan());
|
||||
assert!(dir == 1 || dir == -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_streaming_sma_matches_batch() {
|
||||
// Compare streaming SMA against a simple batch computation
|
||||
let data = vec![1.0, 3.0, 5.0, 7.0, 9.0, 11.0, 13.0];
|
||||
let period = 3;
|
||||
let mut sma = StreamingSMA::new(period).unwrap();
|
||||
let streaming: Vec<f64> = data.iter().map(|&v| sma.update(v)).collect();
|
||||
|
||||
// Batch SMA
|
||||
for i in 0..data.len() {
|
||||
if i + 1 < period {
|
||||
assert!(streaming[i].is_nan(), "bar {} should be NaN", i);
|
||||
} else {
|
||||
let batch: f64 = data[i + 1 - period..=i].iter().sum::<f64>() / period as f64;
|
||||
assert!(
|
||||
approx_eq(streaming[i], batch, 1e-10),
|
||||
"bar {}: streaming={} batch={}",
|
||||
i,
|
||||
streaming[i],
|
||||
batch
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
//! Volatility indicators.
|
||||
|
||||
/// Average True Range — Wilder smoothed (TA-Lib compatible).
|
||||
/// Compute the Average True Range (ATR), Wilder smoothed (TA-Lib compatible).
|
||||
///
|
||||
/// Seeds ATR with SMA of TR[1..=timeperiod] (bar 0 is skipped, matching TA-Lib).
|
||||
/// First valid output is at index `timeperiod`; indices 0..timeperiod are NaN.
|
||||
/// TR is computed on-the-fly (no separate tr Vec allocation).
|
||||
/// ATR measures market volatility by smoothing the True Range with Wilder's
|
||||
/// method. Seeded with the SMA of `TR[1..=timeperiod]` (bar 0 is skipped,
|
||||
/// matching TA-Lib). Returns non-negative values; the first `timeperiod`
|
||||
/// indices are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` / `close` - OHLC price series (same length).
|
||||
/// * `timeperiod` - Smoothing period (typically 14).
|
||||
pub fn atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
@@ -33,7 +38,14 @@ pub fn atr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f
|
||||
result
|
||||
}
|
||||
|
||||
/// True Range — max(H-L, |H-Cprev|, |L-Cprev|).
|
||||
/// Compute the True Range for each bar.
|
||||
///
|
||||
/// `TR = max(H - L, |H - C_prev|, |L - C_prev|)`. For bar 0, TR is
|
||||
/// simply `H - L` (no previous close available). Returns non-negative
|
||||
/// values for every bar (no `NaN` warmup).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` / `close` - OHLC price series (same length).
|
||||
pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![f64::NAN; n];
|
||||
@@ -50,6 +62,22 @@ pub fn trange(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Normalized Average True Range: `ATR / close * 100`.
|
||||
pub fn natr(high: &[f64], low: &[f64], close: &[f64], timeperiod: usize) -> Vec<f64> {
|
||||
let atr_vals = atr(high, low, close, timeperiod);
|
||||
atr_vals
|
||||
.iter()
|
||||
.zip(close.iter())
|
||||
.map(|(&a, &c)| {
|
||||
if a.is_nan() || c == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
a / c * 100.0
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
//! Volume indicators.
|
||||
|
||||
/// On-Balance Volume.
|
||||
/// Compute On-Balance Volume (OBV).
|
||||
///
|
||||
/// OBV is a cumulative indicator that adds volume on up-close bars and
|
||||
/// subtracts volume on down-close bars. Unchanged closes contribute zero.
|
||||
/// Returns a `Vec<f64>` of length `n` with no `NaN` values.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `close` - Price series.
|
||||
/// * `volume` - Volume series (same length as `close`).
|
||||
pub fn obv(close: &[f64], volume: &[f64]) -> Vec<f64> {
|
||||
let n = close.len();
|
||||
let mut result = vec![0.0_f64; n];
|
||||
if n == 0 {
|
||||
return result;
|
||||
}
|
||||
result[0] = volume[0];
|
||||
// result[0] stays 0; accumulation starts from bar 1
|
||||
for i in 1..n {
|
||||
result[i] = result[i - 1]
|
||||
+ if close[i] > close[i - 1] {
|
||||
@@ -21,11 +29,17 @@ pub fn obv(close: &[f64], volume: &[f64]) -> Vec<f64> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Money Flow Index — O(n) sliding-window implementation without per-bar allocation.
|
||||
/// Compute the Money Flow Index (MFI).
|
||||
///
|
||||
/// MFI = 100 - 100 / (1 + positive_flow / negative_flow) over `timeperiod` bars.
|
||||
/// typical_price = (high + low + close) / 3; raw_money_flow = typical_price * volume.
|
||||
/// Leading `timeperiod` values are NaN.
|
||||
/// MFI is a volume-weighted RSI, returning values in `[0, 100]`.
|
||||
/// `typical_price = (H + L + C) / 3`; money flow is positive when
|
||||
/// typical price rises, negative when it falls. The first `timeperiod`
|
||||
/// values are `NaN`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `high` / `low` / `close` - OHLC price series (same length).
|
||||
/// * `volume` - Volume series (same length).
|
||||
/// * `timeperiod` - Lookback window (typically 14).
|
||||
pub fn mfi(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
@@ -78,6 +92,51 @@ pub fn mfi(
|
||||
result
|
||||
}
|
||||
|
||||
/// Chaikin Accumulation/Distribution Line.
|
||||
///
|
||||
/// Cumulates `(close - low - (high - close)) / (high - low) * volume`.
|
||||
pub fn ad(high: &[f64], low: &[f64], close: &[f64], volume: &[f64]) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let mut result = vec![0.0_f64; n];
|
||||
let mut ad_val = 0.0_f64;
|
||||
for i in 0..n {
|
||||
let hl = high[i] - low[i];
|
||||
let clv = if hl != 0.0 {
|
||||
((close[i] - low[i]) - (high[i] - close[i])) / hl
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
ad_val += clv * volume[i];
|
||||
result[i] = ad_val;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Chaikin A/D Oscillator: fast EMA of AD minus slow EMA of AD.
|
||||
///
|
||||
/// Uses the core EMA implementation from `overlap::ema`.
|
||||
pub fn adosc(
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
fastperiod: usize,
|
||||
slowperiod: usize,
|
||||
) -> Vec<f64> {
|
||||
let n = high.len();
|
||||
let ad_vals = ad(high, low, close, volume);
|
||||
let fast_ema = crate::overlap::ema(&ad_vals, fastperiod);
|
||||
let slow_ema = crate::overlap::ema(&ad_vals, slowperiod);
|
||||
let warmup = slowperiod - 1;
|
||||
let mut result = vec![f64::NAN; n];
|
||||
for i in warmup..n {
|
||||
if !fast_ema[i].is_nan() && !slow_ema[i].is_nan() {
|
||||
result[i] = fast_ema[i] - slow_ema[i];
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -87,9 +146,36 @@ mod tests {
|
||||
let c = vec![1.0, 2.0, 3.0];
|
||||
let v = vec![100.0, 200.0, 300.0];
|
||||
let result = obv(&c, &v);
|
||||
assert!((result[0] - 100.0).abs() < 1e-10);
|
||||
assert!((result[1] - 300.0).abs() < 1e-10);
|
||||
assert!((result[2] - 600.0).abs() < 1e-10);
|
||||
assert!((result[0] - 0.0).abs() < 1e-10);
|
||||
assert!((result[1] - 200.0).abs() < 1e-10);
|
||||
assert!((result[2] - 500.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ad_basic() {
|
||||
let h = vec![10.0, 12.0, 11.0];
|
||||
let l = vec![8.0, 9.0, 9.0];
|
||||
let c = vec![9.0, 11.0, 10.0];
|
||||
let v = vec![1000.0, 2000.0, 1500.0];
|
||||
let result = ad(&h, &l, &c, &v);
|
||||
assert_eq!(result.len(), 3);
|
||||
// CLV[0] = ((9-8) - (10-9)) / (10-8) = (1 - 1) / 2 = 0
|
||||
assert!((result[0] - 0.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adosc_basic() {
|
||||
let n = 30;
|
||||
let h: Vec<f64> = (1..=n).map(|i| i as f64 + 1.0).collect();
|
||||
let l: Vec<f64> = (1..=n).map(|i| i as f64 - 1.0).collect();
|
||||
let c: Vec<f64> = (1..=n).map(|i| i as f64).collect();
|
||||
let v: Vec<f64> = vec![1000.0; n];
|
||||
let result = adosc(&h, &l, &c, &v, 3, 10);
|
||||
assert_eq!(result.len(), n);
|
||||
// Warmup period should be NaN
|
||||
for i in 0..9 {
|
||||
assert!(result[i].is_nan());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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
|
||||
* - Backtesting engine
|
||||
- Adjacent
|
||||
- Vectorized Rust backtester: OHLCV fill, stop-loss/TP, 23 performance
|
||||
metrics, trade extraction, parallel Monte Carlo, walk-forward analysis,
|
||||
and multi-asset portfolio simulation. See :ref:`backtesting-engine`.
|
||||
* - 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`.
|
||||
|
||||
.. _backtesting-engine:
|
||||
|
||||
Backtesting Engine
|
||||
------------------
|
||||
|
||||
``ferro_ta.analysis.backtest`` ships a production-grade backtesting engine
|
||||
backed entirely by Rust hot-path functions.
|
||||
|
||||
**Core API:**
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ferro_ta.analysis.backtest import BacktestEngine, monte_carlo, walk_forward
|
||||
|
||||
result = (
|
||||
BacktestEngine()
|
||||
.with_commission(0.001)
|
||||
.with_slippage(5.0) # basis points
|
||||
.with_ohlcv(high=high, low=low, open_=open_)
|
||||
.with_stop_loss(0.02)
|
||||
.with_take_profit(0.04)
|
||||
.run(close, "sma_crossover")
|
||||
)
|
||||
|
||||
print(result.metrics["sharpe"]) # one of 23 metrics
|
||||
print(result.trades) # pandas DataFrame
|
||||
print(result.drawdown_series.min()) # max drawdown
|
||||
|
||||
mc = monte_carlo(result, n_sims=1000) # parallel bootstrap
|
||||
wf = walk_forward(close, "rsi", param_grid=[{"timeperiod": t} for t in [10,14,20]],
|
||||
train_bars=500, test_bars=100)
|
||||
|
||||
**Available Rust primitives** (``ferro_ta._ferro_ta``):
|
||||
|
||||
- ``backtest_core`` — close-only, vectorized, commission + slippage
|
||||
- ``backtest_ohlcv_core`` — fill at open, intrabar stop-loss / take-profit
|
||||
- ``compute_performance_metrics`` — 23 metrics in one pass (Sharpe, Sortino,
|
||||
Calmar, CAGR, Omega, Ulcer, win rate, profit factor, tail ratio, etc.)
|
||||
- ``extract_trades_ohlcv`` — 9 parallel arrays (entry/exit bar, MAE, MFE, …)
|
||||
- ``backtest_multi_asset_core`` — N-asset parallel backtest via Rayon
|
||||
- ``monte_carlo_bootstrap`` — parallel block bootstrap, returns (n_sims, n_bars)
|
||||
- ``walk_forward_indices`` — anchored/rolling fold index generator
|
||||
- ``kelly_fraction`` / ``half_kelly_fraction``
|
||||
|
||||
**Speed vs competitors** (100k bars, SMA crossover, Apple M-series):
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
|
||||
* - Library
|
||||
- Time
|
||||
- vs ferro-ta
|
||||
* - ferro-ta ``backtest_core``
|
||||
- 0.29 ms
|
||||
- —
|
||||
* - NumPy vectorized
|
||||
- 0.46 ms
|
||||
- 1.6× slower
|
||||
* - vectorbt
|
||||
- 2.9 ms
|
||||
- 10× slower
|
||||
* - backtesting.py
|
||||
- 320 ms
|
||||
- 1,100× slower
|
||||
* - backtrader
|
||||
- ~520 ms (10k bars)
|
||||
- >15,000× slower
|
||||
|
||||
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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user