Compare commits

..

41 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: update ferro-ta version to 1.1.4

- Bumped version number of ferro-ta to 1.1.4 in uv.lock and Cargo.lock files.
- Ensured consistency across package dependencies for the updated version.
2026-04-07 23:38:36 +05:30
Pratik Bhadane 388dc05c89 ci: remove cargo-audit step from CI workflows
- Eliminated the cargo-audit job from the CI configuration to streamline the workflow, as caching for the RustSec advisory database is no longer included.
2026-04-02 17:11:48 +05:30
Pratik Bhadane 0ee5f246ed ci: add RUSTFLAGS environment variable to CI workflows
- Introduced RUSTFLAGS environment variable in Python, Rust, and WASM CI workflows to override target-cpu settings from .cargo/config.toml, ensuring consistent build configurations across different environments.
2026-04-02 17:05:14 +05:30
Pratik Bhadane 500716177e ci: optimize CI workflows for Rust and pre-push checks
- Enhanced Rust CI by adding caching for the RustSec advisory database, significantly reducing audit run times.
- Updated fuzz testing commands to specify the target architecture for improved compatibility.
- Refactored pre-push checks script to streamline available checks and improve parallel execution, ensuring faster feedback during development.
2026-04-02 17:00:37 +05:30
Pratik Bhadane 06c536bcb7 ci: enhance CI workflows for Python, Rust, and WASM
- Updated Python CI to streamline linting, type checking, and testing processes, including the addition of a wheel build job.
- Refactored Rust CI to utilize pre-built actions for cargo-deny and cargo-audit, improving dependency checks.
- Optimized WASM CI by consolidating build and test steps, and ensuring proper artifact uploads for both Node.js and web packages.
- Added token authentication for GitHub actions in the release workflow to enhance security.
2026-04-02 16:54:35 +05:30
Pratik Bhadane 3e0f289d51 chore: update ferro-ta version to 1.1.3 (#8)
- Bumped version numbers across Cargo.toml, Cargo.lock, pyproject.toml, and conda/meta.yaml to 1.1.3.
- Added new features including American option pricing, digital options, extended Greeks, and historical volatility estimators.
- Enhanced documentation and tests for new functionalities.
- Updated CHANGELOG.md to reflect changes for version 1.1.3.
2026-04-02 16:38:32 +05:30
Pratik Bhadane 125eb32d9f chore: update npm publish command to remove provenance flag and add NODE_AUTH_TOKEN environment variable 2026-04-02 00:20:18 +05:30
Pratik Bhadane 3b6c7a15a3 chore: update npm publish command to include provenance flag 2026-04-02 00:07:36 +05:30
Pratik Bhadane cc584dbff9 chore: update ferro-ta version to 1.1.2 in Cargo.lock 2026-04-01 23:50:42 +05:30
Pratik Bhadane 58516672d7 chore: bump ferro-ta version to 1.1.2 in uv.lock 2026-04-01 23:50:10 +05:30
Pratik Bhadane 7b560463f6 chore: update version to 1.1.2 and enhance WASM package
- Bumped version numbers across Cargo.toml, Cargo.lock, pyproject.toml, and conda/meta.yaml to 1.1.2.
- Updated .gitignore to include new WASM build directories for Node.js and web.
- Enhanced WASM npm package to support both Node.js and browser builds with conditional exports.
- Improved CI workflows for WASM publishing and testing.
- Updated documentation to reflect new features and full indicator parity in the WASM package.
2026-04-01 23:47:46 +05:30
Pratik Bhadane 6cd1714a9f Merge pull request #7 from pratikbhadane24/wasm-core
Wasm core
2026-04-01 23:08:40 +05:30
Pratik Bhadane 6e45da2636 chore: update ferro-ta version to 1.1.1 in uv.lock 2026-04-01 23:04:07 +05:30
Pratik Bhadane cf5d7764ba chore: bump version to 1.1.1 and update changelog
- Updated version numbers across Cargo.toml, Cargo.lock, pyproject.toml, and conda/meta.yaml to 1.1.1.
- Added new features and improvements in CHANGELOG.md for version 1.1.1, including full feature parity across Rust, Python, and WASM targets, and numerous new indicator functions in ferro_ta_core.
2026-04-01 23:03:05 +05:30
Pratik Bhadane e370120f4e ci: add workflow_dispatch event to WASM publish workflow for manual triggering 2026-04-01 21:43:46 +05:30
Pratik Bhadane f9575df3f6 fix: use correct cargo-cyclonedx flag for SBOM output 2026-04-01 21:29:15 +05:30
Pratik Bhadane 139f7f26e0 ci: add workflow_dispatch to enable manual release publishing 2026-04-01 21:16:00 +05:30
Pratik Bhadane 45ee06f4fc chore: update dependencies and CI configuration
Add constraints for 'pygments' and 'requests' in pyproject.toml and uv.lock, updating their versions to 2.20.0 and 2.33.1 respectively. Modify CI workflow to run pip-audit with the '--skip-editable' option for improved dependency auditing.
2026-04-01 20:50:24 +05:30
Pratik Bhadane 149c91d111 Merge pull request #6 from pratikbhadane24/v1.1.0
V1.1.0
2026-04-01 20:39:30 +05:30
Pratik Bhadane b06acb9654 fix: update WASM OBV test to match zero-start behavior
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:34:59 +05:30
Pratik Bhadane 7a589c725f fix: OBV starts at zero and validate OHLC lengths in CDL pattern functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:33:33 +05:30
Pratik Bhadane 262f0a2916 fix: resolve pyright type errors in Python analysis modules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:19:47 +05:30
Pratik Bhadane 5015716692 fix: relax bbands numerical stability tolerance to match f64 precision at 1e12 scale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:17:49 +05:30
Pratik Bhadane ec9bf0410f fix: resolve all clippy warnings blocking push
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:16:25 +05:30
Pratik Bhadane 70b99ad870 style: apply cargo fmt formatting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:12:19 +05:30
Pratik Bhadane 3ab6daa853 chore: correct version to 1.1.0 across all manifests and docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:11:42 +05:30
Pratik Bhadane 436954138f chore: prepare v1.1.0 release
Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
2026-03-30 12:45:52 +05:30
Pratik Bhadane 2d776b6f90 chore: prepare v1.0.6 release
Align the Rust, Python, WASM, Conda, docs, and lockfile version markers to 1.0.6 so the published artifacts and release automation agree on a single release number.

Add a proper 1.0.6 changelog entry in both CHANGELOG.md and the docs release notes, covering the pre-push gate, expanded Rust and WASM surface, benchmark refresh, and CI hardening work shipped since v1.0.4.

Validate the release preparation with the repo-managed pre-push suite so version, changelog, Rust, Python, docs, WASM, and API-manifest checks all pass before tagging.
2026-03-24 15:03:00 +05:30
Pratik Bhadane 71b6343e92 feat: refresh benchmark coverage and harden CI tooling
Refresh the benchmark and performance surface across the repo. This updates the benchmark wrappers and helper scripts, regenerates the checked-in benchmark and perf-contract artifacts, and folds in the related roadmap, compatibility, and example notebook changes that belong with this performance-focused pass.

Harden the Python CI and local pre-push flow so the same checks pass reliably in both places. The workflow and pre-push script now use module-safe uv typecheck invocations, the Python test environment installs the optional MCP dependency needed by the MCP server tests, and one-off root benchmark outputs are ignored to keep the repo clean.

Align local tooling with the current project configuration by updating the Ruff pre-commit hook, tightening the API typing and MCP server helpers, and refreshing the lockfile to pick up the audited PyJWT fix while preserving the rest of the staged source changes.
2026-03-24 14:52:20 +05:30
Pratik Bhadane 53566b9d82 feat: expand rust parity, wasm exports, and api conformance
Move several hot Python analysis paths to Rust-backed helpers. This adds Rust implementations for backtest strategy signal generation and the core portfolio loop, options and futures payoff aggregation, Greeks aggregation, ratio calculation, trade extraction, chunked close-only indicator runs, and forward-fill helpers. Wire the Python analysis and data modules to prefer these paths, and add coverage for the new batch fast path.

Expand the WASM package to export WMA, ADX, and MFI from ferro_ta_core, refresh the Node examples, benchmarks, and README, and add a Node-vs-Python conformance test so the browser and node surface stays aligned with the main Python package.

Introduce a generated cross-surface API manifest in docs/, along with scripts to rebuild and verify it from source exports. Enforce manifest freshness in the Python and WASM CI workflows so release candidates catch surface drift before push.
2026-03-24 14:28:51 +05:30
Pratik Bhadane ba77fbd418 devx: add a repo-managed pre-push CI gate
Add a versioned pre-push runner that mirrors the basic required CI suites we can execute locally, including version/changelog checks, Rust fmt/clippy/core checks, Python lint/typecheck/tests, docs, and the WASM gate.

Wire the runner into pre-commit at the pre-push stage, add make prepush and make hooks for manual use and hook installation, and document the workflow in the contribution guides.

Also add pre-commit to the development dependency set and refresh uv.lock so a synced dev environment can install and run the hook reproducibly.
2026-03-24 14:24:44 +05:30
Pratik Bhadane 29c6f5cf84 chore: release v1.0.4 2026-03-24 12:54:41 +05:30
Pratik Bhadane 58a1dc2308 chore: remove .coverage file and update .gitignore to exclude coverage files
- Deleted the .coverage file to clean up the repository.
- Updated .gitignore to ensure .coverage and .coverage.* files are ignored in future commits.
- Revised README.md to enhance clarity and conciseness regarding the library's capabilities and performance.
- Improved documentation for the MCP server, emphasizing its expanded functionality and integration with clients.
2026-03-24 12:49:17 +05:30
Pratik Bhadane b66b05682e docs: finalize 1.0.3 release notes 2026-03-24 11:24:34 +05:30
Pratik Bhadane e4ac7dd4d3 fix: repair release workflow 2026-03-24 11:23:41 +05:30
Pratik Bhadane 1f053c1001 fix: unblock python ci 2026-03-24 11:19:48 +05:30
Pratik Bhadane 13cb0dc95a chore: release v1.0.3 2026-03-24 11:09:48 +05:30
Pratik Bhadane 0382c4e302 ci: add release.yml to auto-publish on version tag push
Pushing v* tag now triggers:
  1. release.yml — creates a GitHub Release (published, not draft),
     pulling the release notes from CHANGELOG.md automatically.
  2. CI.yml (release: published event) — builds wheels for Linux /
     macOS / Windows × Python 3.10-3.13, sdist, publishes to PyPI
     via trusted publisher, publishes ferro_ta_core to crates.io,
     and generates SBOMs.

Pre-release tags (e.g. v1.1.0-rc.1) are marked as pre-release on
GitHub automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 03:00:06 +05:30
Pratik Bhadane 7736effc27 fix(bench): adjust feature_matrix speedup floor to 0.40 for cross-architecture compatibility 2026-03-24 02:49:29 +05:30
284 changed files with 53131 additions and 9093 deletions
+17
View File
@@ -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"]
BIN
View File
Binary file not shown.
+11
View File
@@ -34,3 +34,14 @@ updates:
labels:
- "dependencies"
- "github-actions"
# WASM JavaScript package
- package-ecosystem: "npm"
directory: "/wasm"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "javascript"
+126 -50
View File
@@ -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
@@ -81,7 +87,7 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
# -------------------------------------------------------------------------
# CI gate — all required jobs must pass before this job succeeds.
@@ -130,94 +136,129 @@ jobs:
# Keep release jobs here because trusted-publisher configuration points to
# CI.yml specifically.
# -------------------------------------------------------------------------
# One abi3 wheel per (platform, arch). abi3-py310 means a single wheel
# covers CPython 3.10+ on each target, so there is no python-version axis.
# extension-module + abi3 link no libpython, which is what lets the linux
# jobs cross-compile aarch64/musl from an x86_64 runner via maturin-action.
build-wheels-linux:
name: Build wheels (linux / py${{ matrix.python-version }})
name: Build wheels (linux-gnu / ${{ matrix.target }})
runs-on: ubuntu-latest
if: github.event_name == 'release' && github.event.action == 'published'
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
target: [x86_64, aarch64]
steps:
- uses: actions/checkout@v6
- name: Build wheel
- name: Build abi3 manylinux wheel
uses: PyO3/maturin-action@v1
with:
command: build
args: --release --out dist --compatibility pypi -i python${{ matrix.python-version }}
target: ${{ matrix.target }}
args: --release --out dist
manylinux: "2_17"
- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: wheels-linux-py${{ matrix.python-version }}
name: wheels-linux-gnu-${{ matrix.target }}
path: dist/*.whl
build-wheels-musllinux:
name: Build wheels (linux-musl / ${{ matrix.target }})
runs-on: ubuntu-latest
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
strategy:
fail-fast: false
matrix:
target: [x86_64, aarch64]
steps:
- uses: actions/checkout@v6
- name: Build abi3 musllinux wheel
uses: PyO3/maturin-action@v1
with:
command: build
target: ${{ matrix.target }}
args: --release --out dist
manylinux: musllinux_1_2
- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: wheels-linux-musl-${{ matrix.target }}
path: dist/*.whl
build-wheels-macos:
name: Build wheels (macos / py${{ matrix.python-version }})
name: Build wheels (macos / universal2)
runs-on: macos-latest
if: github.event_name == 'release' && github.event.action == 'published'
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
python-version: "3.10"
- name: Build universal2 wheel
- name: Build universal2 abi3 wheel
uses: PyO3/maturin-action@v1
with:
command: build
args: --release --out dist --compatibility pypi -i python
target: universal2-apple-darwin
args: --release --out dist
- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: wheels-macos-py${{ matrix.python-version }}
name: wheels-macos-universal2
path: dist/*.whl
build-wheels-windows:
name: Build wheels (windows / py${{ matrix.python-version }})
runs-on: windows-latest
if: github.event_name == 'release' && github.event.action == 'published'
name: Build wheels (windows / ${{ matrix.platform.arch }})
runs-on: ${{ matrix.platform.runner }}
if: (github.event_name == 'release' && github.event.action == 'published') || (github.event_name == 'workflow_dispatch' && inputs.release)
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
platform:
- runner: windows-latest
arch: x64
target: x64
- runner: windows-11-arm
arch: arm64
target: aarch64
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
python-version: "3.11"
architecture: ${{ matrix.platform.arch }}
- name: Build wheel
- name: Build abi3 wheel
uses: PyO3/maturin-action@v1
with:
command: build
args: --release --out dist --compatibility pypi -i python
target: ${{ matrix.platform.target }}
args: --release --out dist
- name: Upload wheels as artifact
uses: actions/upload-artifact@v7
with:
name: wheels-windows-py${{ matrix.python-version }}
name: wheels-windows-${{ matrix.platform.arch }}
path: dist/*.whl
build-sdist:
name: Build source distribution
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
@@ -241,15 +282,18 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-wheels-linux
- build-wheels-musllinux
- build-wheels-macos
- build-wheels-windows
- build-sdist
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)
environment:
name: pypi
url: https://pypi.org/p/ferro-ta
permissions:
id-token: write
attestations: write
contents: read
steps:
- name: Download all wheels
uses: actions/download-artifact@v8
@@ -264,6 +308,11 @@ jobs:
name: sdist
path: dist
- name: Generate SLSA build provenance attestations
uses: actions/attest-build-provenance@v2
with:
subject-path: "dist/*"
- name: Verify distribution coverage
run: |
python3 - <<'PY'
@@ -277,19 +326,15 @@ jobs:
for name in files:
print(f" - {name}")
# One abi3 wheel (cp310-abi3) per platform/arch — covers CPython 3.10+.
expected = [
"ferro_ta-*-cp310-cp310-manylinux*_x86_64.whl",
"ferro_ta-*-cp311-cp311-manylinux*_x86_64.whl",
"ferro_ta-*-cp312-cp312-manylinux*_x86_64.whl",
"ferro_ta-*-cp313-cp313-manylinux*_x86_64.whl",
"ferro_ta-*-cp310-cp310-win_amd64.whl",
"ferro_ta-*-cp311-cp311-win_amd64.whl",
"ferro_ta-*-cp312-cp312-win_amd64.whl",
"ferro_ta-*-cp313-cp313-win_amd64.whl",
"ferro_ta-*-cp310-cp310-macosx*_universal2.whl",
"ferro_ta-*-cp311-cp311-macosx*_universal2.whl",
"ferro_ta-*-cp312-cp312-macosx*_universal2.whl",
"ferro_ta-*-cp313-cp313-macosx*_universal2.whl",
"ferro_ta-*-cp310-abi3-manylinux*_x86_64.whl",
"ferro_ta-*-cp310-abi3-manylinux*_aarch64.whl",
"ferro_ta-*-cp310-abi3-musllinux*_x86_64.whl",
"ferro_ta-*-cp310-abi3-musllinux*_aarch64.whl",
"ferro_ta-*-cp310-abi3-macosx*_universal2.whl",
"ferro_ta-*-cp310-abi3-win_amd64.whl",
"ferro_ta-*-cp310-abi3-win_arm64.whl",
"ferro_ta-*.tar.gz",
]
@@ -313,7 +358,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
@@ -333,11 +378,14 @@ jobs:
sbom:
name: Generate SBOM (Python + Rust)
runs-on: ubuntu-latest
needs: publish
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
attestations: write
steps:
- uses: actions/checkout@v6
@@ -370,14 +418,42 @@ 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: Validate SBOMs (schema check)
run: |
python3 -c "import json, sys; json.load(open('ferro-ta-python-sbom.spdx.json')); print('Python SBOM valid JSON')"
python3 -c "import json, sys; data=json.load(open('ferro-ta-rust-sbom.cdx.json')); assert data.get('bomFormat')=='CycloneDX', 'not CycloneDX'; print('Rust SBOM valid CycloneDX')"
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign SBOMs with cosign (keyless)
env:
COSIGN_EXPERIMENTAL: "1"
run: |
cosign sign-blob --yes ferro-ta-python-sbom.spdx.json --output-signature ferro-ta-python-sbom.spdx.json.sig --output-certificate ferro-ta-python-sbom.spdx.json.pem
cosign sign-blob --yes ferro-ta-rust-sbom.cdx.json --output-signature ferro-ta-rust-sbom.cdx.json.sig --output-certificate ferro-ta-rust-sbom.cdx.json.pem
- name: Attest SBOM provenance
uses: actions/attest-build-provenance@v2
with:
subject-path: |
ferro-ta-python-sbom.spdx.json
ferro-ta-rust-sbom.cdx.json
- name: Upload Python SBOM to release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: ferro-ta-python-sbom.spdx.json
files: |
ferro-ta-python-sbom.spdx.json
ferro-ta-python-sbom.spdx.json.sig
ferro-ta-python-sbom.spdx.json.pem
- name: Upload Rust SBOM to release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: ferro-ta-rust-sbom.cdx.json
files: |
ferro-ta-rust-sbom.cdx.json
ferro-ta-rust-sbom.cdx.json.sig
ferro-ta-rust-sbom.cdx.json.pem
+1 -1
View File
@@ -44,6 +44,6 @@ jobs:
- name: Upload GitHub Pages artifact
if: ${{ inputs.upload-pages-artifact }}
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
path: docs/_build/
+97 -91
View File
@@ -7,147 +7,157 @@ permissions:
contents: read
jobs:
# -------------------------------------------------------------------------
# Lint — no Rust, no wheel build, very fast
# -------------------------------------------------------------------------
lint:
name: Lint (ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install uv
- run: uv run --with ruff ruff check python/ tests/
- run: uv run --with ruff ruff format --check python/ tests/
- name: Install uv
run: pip install uv
- name: Run ruff check via uv
run: uv run --with ruff ruff check python/ tests/
- name: Run ruff format check via uv
run: uv run --with ruff ruff format --check python/ tests/
# -------------------------------------------------------------------------
# Type checking — needs wheel; build once with cache
# -------------------------------------------------------------------------
typecheck:
name: Type checking (mypy + pyright)
runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- name: Set up Python 3.12
uses: actions/setup-python@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
- run: pip install maturin numpy
- run: maturin build --release --out dist && pip install dist/*.whl
- run: pip install uv
- run: uv run --with mypy --with numpy python -m mypy python/ferro_ta --ignore-missing-imports --no-error-summary
- run: uv run --with pyright python -m pyright python/ferro_ta
- name: Install uv
run: pip install uv
- name: Run mypy on ferro_ta via uv
run: uv run --with mypy --with numpy 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
- name: Set up Python 3.12
uses: actions/setup-python@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install maturin and perf dependencies
run: |
pip install maturin numpy pytest
- name: Build and install ferro_ta
run: |
maturin build --release --out dist
pip install dist/*.whl
- name: Generate reproducible perf artifacts
run: |
- uses: actions/download-artifact@v8
with:
name: wheel-3.12
path: dist
- run: pip install dist/*.whl numpy pytest
- run: |
python benchmarks/run_perf_contract.py \
--output-dir perf-contract \
--skip-talib \
@@ -157,12 +167,8 @@ jobs:
--streaming-bars 20000 \
--price-bars 20000 \
--iv-bars 50000
- name: Enforce hotspot regression policy
run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
- name: Upload perf artifacts
uses: actions/upload-artifact@v7
- run: python benchmarks/check_hotspot_regression.py --input perf-contract/runtime_hotspots.json
- uses: actions/upload-artifact@v7
with:
name: perf-contract
path: perf-contract/
+62 -45
View File
@@ -7,102 +7,119 @@ permissions:
contents: read
jobs:
audit:
name: Dependency audit (cargo + pip)
# -------------------------------------------------------------------------
# cargo-deny — uses the official pre-built action (no cargo install needed)
# -------------------------------------------------------------------------
cargo-deny:
name: cargo deny check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: EmbarkStudios/cargo-deny-action@v2
- name: Install cargo-audit and run cargo audit
run: |
cargo install cargo-audit
cargo audit
- name: Install cargo-deny and run cargo deny check
run: |
cargo install cargo-deny --locked
cargo deny check
- name: Set up Python 3.12
uses: actions/setup-python@v6
# -------------------------------------------------------------------------
# pip-audit + uv.lock freshness check (lightweight, no Rust needed)
# -------------------------------------------------------------------------
pip-audit:
name: pip-audit + uv.lock check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install uv
- run: uv run --with pip-audit pip-audit --skip-editable
- run: uv lock --check
- name: Install uv
run: pip install uv
- name: Install pip-audit via uv and run pip-audit
run: uv run --with pip-audit pip-audit
- 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
+23 -25
View File
@@ -8,45 +8,43 @@ permissions:
jobs:
wasm:
name: WASM binding (wasm-pack test --node)
name: WASM (test + build + bench)
runs-on: ubuntu-latest
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: "20"
- name: Install Rust (stable)
uses: dtolnay/rust-toolchain@v1
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
targets: wasm32-unknown-unknown
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Build and test WASM binding
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: wasm-pack
- name: Test WASM binding
working-directory: wasm
run: wasm-pack test --node
- name: Build WASM package (nodejs target)
- name: Build WASM packages (node + web)
working-directory: wasm
run: wasm-pack build --target nodejs --out-dir pkg
- name: Benchmark WASM package
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
- name: Upload WASM package artifact
uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7
with:
name: wasm-pkg
path: wasm/pkg/
- name: Upload WASM benchmark artifact
uses: actions/upload-artifact@v7
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
+100
View File
@@ -0,0 +1,100 @@
name: Nightly benchmarks
on:
schedule:
# 03:15 UTC every day — off peak, avoids colliding with the regular CI surge.
- cron: "15 3 * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
bench:
name: Run perf contract and regression checks
runs-on: ubuntu-latest
timeout-minutes: 60
env:
RUSTFLAGS: "" # override target-cpu=native from .cargo/config.toml
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install uv
run: pip install uv
- name: Build ferro-ta wheel
run: |
uv sync --extra dev
uv run maturin build --release --out dist
uv run pip install --force-reinstall dist/*.whl
- name: Run vs-TA-Lib benchmark
run: uv run python benchmarks/bench_vs_talib.py --output benchmarks/artifacts/nightly_vs_talib.json
- name: Run SIMD benchmark matrix
run: uv run python benchmarks/bench_simd.py --output benchmarks/artifacts/nightly_simd.json
- name: Check hotspot regression
id: hotspot
continue-on-error: true
run: uv run python benchmarks/check_hotspot_regression.py --tolerance 0.05
- name: Check vs-TA-Lib regression
id: vs_talib
continue-on-error: true
run: uv run python benchmarks/check_vs_talib_regression.py --tolerance 0.05
- name: Run criterion bench (build only)
run: cargo bench -p ferro_ta_core --no-run
- name: Upload nightly artifacts
uses: actions/upload-artifact@v7
if: always()
with:
name: nightly-bench-${{ github.run_id }}
path: benchmarks/artifacts/
retention-days: 30
- name: Open issue on regression
if: steps.hotspot.outcome == 'failure' || steps.vs_talib.outcome == 'failure'
uses: actions/github-script@v8
env:
HOTSPOT_OUTCOME: ${{ steps.hotspot.outcome }}
VS_TALIB_OUTCOME: ${{ steps.vs_talib.outcome }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const hotspot = process.env.HOTSPOT_OUTCOME;
const vsTalib = process.env.VS_TALIB_OUTCOME;
const runUrl = process.env.RUN_URL;
const failed = [];
if (hotspot === 'failure') failed.push('hotspot');
if (vsTalib === 'failure') failed.push('vs-TA-Lib');
const title = `Nightly benchmark regression: ${failed.join(' + ')}`;
const body = [
`The nightly benchmark workflow reported a >5% regression in: **${failed.join(', ')}**.`,
'',
`Run: ${runUrl}`,
'',
'Artifacts with the raw JSON are attached to the run above.',
'If this is a known regression, update the baseline JSON and close.',
'If it is unexpected, investigate the last commit on `main` before the nightly ran.',
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['performance', 'regression', 'nightly'],
});
+62
View File
@@ -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@v3
with:
tag_name: ${{ github.ref_name }}
name: v${{ steps.version.outputs.version }}
body: ${{ steps.changelog.outputs.body }}
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
token: ${{ secrets.GH_PAT }}
+15 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+255 -1
View File
@@ -9,6 +9,257 @@ and the project uses [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.2.0] — 2026-06-29
### Added
- **Runtime CPU-feature dispatch** for SIMD hot paths via the `multiversion`
crate (`ferro_ta_core/src/simd.rs`). One binary now selects baseline /
AVX2-FMA / AVX-512 / NEON kernels at load time via CPUID instead of being
pinned at build time, so it runs on any CPU of the target architecture with
no illegal-instruction crashes on older chips. The `simd` feature is on by
default; `--no-default-features` gives a pure-scalar build.
- **Broader wheel coverage**: release builds now also produce Linux `aarch64`
(manylinux) and `musllinux` (x86_64 + aarch64) wheels and a Windows `arm64`
wheel, alongside the existing Linux x86_64, macOS universal2, and Windows
x64 wheels.
- **abi3 wheels** (`cp310-abi3`): a single stable-ABI wheel per platform now
covers CPython 3.10+ (including future 3.14+), replacing the per-version
wheel matrix.
- **Dynamic Time Warping** (`DTW`, `DTW_DISTANCE`, `BATCH_DTW`): Euclidean-cost
DTW with optional Sakoe-Chiba band (`window=` parameter). `DTW()` returns
`(distance, path)` with the optimal warping path as an `(N, 2)` index array;
`DTW_DISTANCE()` is the faster distance-only variant; `BATCH_DTW()` computes
distances from each row of a 2-D matrix to a reference series in parallel
via rayon. Distance convention matches `dtaidistance.dtw.distance()`.
- **Indicator-specific exception types** (`FerroTaError`, `InvalidPeriodError`,
`InsufficientDataError`, `LengthMismatchError`, `NumericConvergenceError`,
`InvalidInputError`): finer-grained errors for catching specific failure
modes. All subclass `ValueError` so existing `except ValueError` code keeps
working (backward compatible).
### Changed
- **SIMD is now enabled by default and runtime-dispatched.** Replaced the
compile-time `wide` crate (which was never actually enabled in published
wheels) with `multiversion`. The `wide` Cargo feature is removed; use the
default `simd` feature instead.
- **Dependency bumps.** Rust: `log` 0.4.32, `serde_json` 1.0.150,
`rayon` 1.12.0. API service (`api/requirements.txt`): `uvicorn>=0.49.0`,
`pydantic>=2.13.4`, `ferro-ta>=1.1.4`. CI actions: `actions/deploy-pages` v5,
`actions/upload-pages-artifact` v5, `softprops/action-gh-release` v3.
- Python coverage threshold raised from 65% to 80% and enforced in CI.
### Fixed
- **aarch64 Linux containers can now install ferro-ta.** Previously no Linux
`aarch64` wheel was published, so arm64 images (e.g. AWS Graviton) fell back
to an sdist build that failed without a Rust toolchain. A manylinux/musllinux
aarch64 wheel is now published.
- Published wheels now actually ship SIMD-accelerated kernels; the prior build
enabled no SIMD feature at all.
### Security
- **SLSA build provenance** attestations are now generated for every PyPI
wheel and sdist via `actions/attest-build-provenance`. Verify with
`gh attestation verify <wheel>`.
- **Sigstore keyless signatures** are now published alongside both
CycloneDX/SPDX SBOMs on every GitHub Release (`.sig` + `.pem` files).
- `ferro_ta_core` crate now declares `#![forbid(unsafe_code)]` to prevent
regression — the pure-logic layer has no unsafe code and never will.
- Dependabot now covers the WASM npm package in addition to pip, cargo,
and GitHub Actions.
- **pip-audit fixes**: bumped dev lockfile deps `idna` 3.18, `pytest` 9.1.1,
and `urllib3` 2.7.0 to clear PYSEC-2026-215, CVE-2025-71176, and
PYSEC-2026-141/142.
- **pyo3 advisories triaged**: RUSTSEC-2026-0176 and RUSTSEC-2026-0177 are
ignored in `deny.toml` with rationale — ferro-ta uses neither affected code
path (PyList/PyTuple `nth` iterators; `PyCFunction::new_closure`). The
upstream fix requires pyo3 >=0.29 (a large API migration), tracked for a
follow-up.
## [1.1.3] — 2026-04-02
### Added
- **Stock instrument** (`instrument="stock"`) in `PayoffLeg` and `StrategyLeg`
for modelling equity-holding strategies (Covered Call, Protective Put, Collar,
Covered Strangle, Stock + Spread). Linear payoff identical to futures.
Exposed in all three layers: Rust core, Python, and WASM.
- **Extended Greeks** (`extended_greeks`): closed-form vanna (∂Δ/∂σ), volga
(∂²V/∂σ²), charm (∂Δ/∂t), speed (∂Γ/∂S), and color (∂Γ/∂t) for BSM.
Batch vectorisation supported.
- **Digital options** (`digital_option_price`, `digital_option_greeks`):
cash-or-nothing and asset-or-nothing pricing (BSM closed-form) plus
numerical delta / gamma / vega. Scalar and batch variants.
- **American options** (`american_option_price`, `early_exercise_premium`):
Barone-Adesi-Whaley (1987) quadratic approximation — O(1) per evaluation.
Scalar and batch variants.
- **Historical volatility estimators** (all rolling, annualised): close-to-close,
Parkinson, Garman-Klass, Rogers-Satchell, Yang-Zhang. Yang-Zhang is
~14× more efficient than close-to-close and handles overnight gaps.
- **Volatility cone** (`vol_cone`): min / p25 / median / p75 / max distribution
of realised vol across user-specified window lengths — contextualises current
IV against historical norms.
- **`strategy_value`**: pre-expiry BSM mid-price value of a multi-leg strategy
over a spot grid (time value included), complementing `strategy_payoff`
(expiry intrinsic).
- **`expected_move`**: log-normal ±1σ expected price range over N days.
- **`put_call_parity_deviation`**: detects stale quotes or data errors by
computing C P (S·e^{qT} K·e^{rT}).
- All new analytics exposed to **WASM** (`wasm/src/lib.rs`):
`extended_greeks`, `digital_price`, `digital_greeks`, `american_price`,
`early_exercise_premium`, `close_to_close_vol`, `parkinson_vol`,
`garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol`, `vol_cone`,
`expected_move`, `put_call_parity_deviation`, `strategy_payoff_dense`,
`aggregate_greeks_dense`, `strategy_value_grid`.
- `aggregate_greeks_dense` added to `ferro_ta_core::options::payoff` (pure
Rust, no PyO3/numpy dependency) enabling WASM reuse.
- Comprehensive docstrings (NumPy style with Parameters / Returns / Notes /
Examples) on all new Python functions.
- Accuracy test suite `tests/unit/test_derivatives_accuracy.py` validates
digital options, extended Greeks, American options, and vol estimators
against scipy and analytical reference formulas.
- scipy added to `dev` optional dependencies for reference testing.
### Changed
- `StrategyLeg.expiry_selector`, `StrategyLeg.strike_selector`, and
`StrategyLeg.option_type` are now `Optional` (None allowed for stock legs).
Existing option legs are unaffected.
- `docs/derivatives-analytics.md` rewritten to cover all new features with
runnable examples and an efficiency comparison table for vol estimators.
## [1.1.2] — 2026-04-01
### Changed
- 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
@@ -293,7 +544,10 @@ and the project uses [Semantic Versioning](https://semver.org/).
---
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.2...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
+28
View File
@@ -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
+63 -58
View File
@@ -34,9 +34,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "arc-swap"
version = "1.9.0"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
@@ -53,12 +53,6 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "cast"
version = "0.3.0"
@@ -67,9 +61,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.57"
version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -207,7 +201,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "ferro_ta"
version = "1.0.2"
version = "1.2.0"
dependencies = [
"criterion",
"ferro_ta_core",
@@ -222,10 +216,12 @@ dependencies = [
[[package]]
name = "ferro_ta_core"
version = "1.0.2"
version = "1.2.0"
dependencies = [
"criterion",
"wide",
"multiversion",
"serde",
"serde_json",
]
[[package]]
@@ -277,9 +273,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.91"
version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -287,15 +283,15 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.183"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "matrixmultiply"
@@ -322,6 +318,28 @@ dependencies = [
"autocfg",
]
[[package]]
name = "multiversion"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201"
dependencies = [
"multiversion-macros",
"target-features",
]
[[package]]
name = "multiversion-macros"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0"
dependencies = [
"proc-macro2",
"quote",
"syn",
"target-features",
]
[[package]]
name = "ndarray"
version = "0.16.1"
@@ -544,9 +562,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.11.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
@@ -593,9 +611,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rustc-hash"
version = "2.1.1"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustversion"
@@ -603,15 +621,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "safe_arch"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed"
dependencies = [
"bytemuck",
]
[[package]]
name = "same-file"
version = "1.0.6"
@@ -653,9 +662,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
@@ -687,6 +696,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "609409d472a0a7d8d4dd9e19891bbdef546b9dce670c3057d0e02192dc541226"
[[package]]
name = "target-features"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
[[package]]
name = "target-lexicon"
version = "0.13.5"
@@ -727,9 +742,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
dependencies = [
"cfg-if",
"once_cell",
@@ -740,9 +755,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -750,9 +765,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -763,33 +778,23 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.91"
version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wide"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "198f6abc41fab83526d10880fa5c17e2b4ee44e763949b4bb34e2fd1e8ca48e4"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -838,18 +843,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.47"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.47"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
+19 -4
View File
@@ -5,9 +5,16 @@ resolver = "2"
[package]
name = "ferro_ta"
version = "1.0.2"
version = "1.2.0"
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]
@@ -15,7 +22,9 @@ name = "ferro_ta"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.25", features = ["extension-module"] }
# abi3-py310: build a single stable-ABI wheel that runs on CPython 3.10+
# (including future 3.14+), instead of one wheel per minor version.
pyo3 = { version = "0.25", features = ["extension-module", "abi3-py310"] }
ta = "0.5.0"
numpy = "0.25"
# Must be < 0.17 while numpy 0.25 is used (numpy's IntoPyArray is for its own ndarray only).
@@ -23,7 +32,11 @@ ndarray = "0.16"
rayon = "1.10"
log = "0.4"
pyo3-log = "0.12"
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.2" }
# default-features = false so the `simd` toggle is forwarded explicitly via
# this crate's own `simd` feature (below). Without this, core's default `simd`
# would always be on and `--no-default-features` could never produce a true
# pure-scalar build (used by the SIMD benchmark baseline).
ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.2.0", default-features = false, features = ["serde"] }
[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
@@ -33,5 +46,7 @@ lto = true
codegen-units = 1
[features]
default = []
# SIMD runtime dispatch ON by default → published wheels ship the adaptive
# fast path with no extra flags. `--no-default-features` yields pure scalar.
default = ["simd"]
simd = ["ferro_ta_core/simd"]
+15 -2
View File
@@ -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 -5
View File
@@ -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)
+1 -1
View File
@@ -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.
+71 -1045
View File
File diff suppressed because it is too large Load Diff
+28 -7
View File
@@ -42,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.
---
@@ -62,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.
@@ -91,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
+261
View File
@@ -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
View File
@@ -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.
+22 -7
View File
@@ -8,21 +8,36 @@
#
# Environment variables (override at runtime):
# MAX_SERIES_LENGTH=100000 # maximum data-point count per request
#
# CPU portability
# ---------------
# This image installs the PRE-BUILT ferro-ta wheel from PyPI — we do NOT
# recompile from sdist with `RUSTFLAGS=-C target-cpu=...`. The wheel is built
# at the manylinux baseline (x86-64-v1) and selects AVX2/AVX-512/NEON kernels
# at RUNTIME via CPU dispatch. One image therefore runs on any node — old or
# new CPU, x86_64 or arm64 — with no illegal-instruction (SIGILL) crashes.
# Pinning a target-cpu would be faster on a uniform fleet but would crash on
# any older/heterogeneous node, which is the opposite of broad coverage.
#
# Build this image for whichever arch your nodes use:
# docker build --platform linux/amd64 -t ferro-ta-api .
# docker build --platform linux/arm64 -t ferro-ta-api . # Graviton/Ampere
# Both resolve a matching manylinux wheel — no Rust toolchain needed here.
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies required to build ferro_ta (Rust is pre-compiled
# into the wheel, so only pip + wheel tooling is needed at runtime).
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy and install dependencies first (cache layer)
# Copy and install dependencies first (cache layer). No compiler is needed:
# ferro-ta, numpy, and pydantic-core all ship prebuilt wheels for linux
# x86_64 and aarch64.
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Fail the build immediately if the wheel did not resolve for this arch
# (e.g. an exotic platform that fell back to an sdist build without Rust).
RUN python -c "import ferro_ta, numpy as np; ferro_ta.SMA(np.arange(10.0), 3); print('ferro_ta', ferro_ta.__version__, 'import OK')"
# Copy API source
COPY main.py ./
+19 -19
View File
@@ -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``.
+3 -3
View File
@@ -1,6 +1,6 @@
# Runtime dependencies for ferro-ta API
ferro_ta>=1.0.0
ferro_ta>=1.1.4
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
pydantic>=2.0.0
uvicorn[standard]>=0.49.0
pydantic>=2.13.4
numpy>=1.20
+51 -6
View File
@@ -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,23 @@ 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
@@ -140,7 +161,7 @@ The speed table includes **all 62 indicators**. **Number** = median µs; **N/A**
**Takeaways:**
- **`ta`** is 20350× slower on ATR, CCI, ADX, MFI (O(n²) Python loops).
- **ferro-ta** is typically 24× 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.
---
@@ -160,9 +181,14 @@ 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
@@ -184,6 +210,25 @@ uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/
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:
+1 -1
View File
@@ -68,4 +68,4 @@
"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
@@ -160,4 +160,4 @@
"elapsed_ms": 0.0026
}
]
}
}
+6 -1
View File
@@ -57,6 +57,11 @@
"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"
}
}
}
}
@@ -93,4 +93,4 @@
"share_of_suite_pct": 0.09
}
]
}
}
+1 -1
View File
@@ -282,4 +282,4 @@
]
}
}
}
}
+1 -1
View File
@@ -70,4 +70,4 @@
"stream_over_batch_ratio": 121.7603
}
]
}
}
+425
View File
@@ -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())
+9 -3
View File
@@ -48,13 +48,17 @@ def run_batch_benchmark(
"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)],
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)],
lambda: [
ferro_ta.RSI(close2d[:, j], timeperiod=14) for j in range(n_series)
],
),
(
"ATR",
@@ -201,7 +205,9 @@ def main() -> int:
if payload["grouped_results"]:
print("\nGrouped Multi-Indicator Calls")
print("-" * 64)
print(f"{'Case':<18} {'Grouped (ms)':>14} {'Separate (ms)':>16} {'Speedup':>12}")
print(
f"{'Case':<18} {'Grouped (ms)':>14} {'Separate (ms)':>16} {'Speedup':>12}"
)
print("-" * 64)
for row in payload["grouped_results"]:
print(
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -55,8 +55,11 @@ def run_simd_benchmark(
iv_bars: int = 50_000,
window: int = 252,
) -> dict[str, Any]:
# `simd` is a default feature, so a pure-scalar baseline must explicitly
# opt out via --no-default-features; otherwise both builds would be
# identical and every reported speedup would collapse to 1.0.
variants = [
("portable_release", []),
("portable_release", ["--no-default-features"]),
("simd_release", ["--features", "simd"]),
]
reports = {
@@ -70,9 +73,7 @@ def run_simd_benchmark(
for label, args in variants
}
portable_rows = {
row["name"]: row for row in reports["portable_release"]["results"]
}
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]] = []
@@ -136,9 +137,7 @@ def main() -> int:
window=args.window,
)
print(
f"{'Case':<20} {'Portable (ms)':>14} {'SIMD (ms)':>12} {'SIMD speedup':>14}"
)
print(f"{'Case':<20} {'Portable (ms)':>14} {'SIMD (ms)':>12} {'SIMD speedup':>14}")
print("-" * 64)
for row in payload["results"]:
print(
+6 -2
View File
@@ -57,7 +57,9 @@ def _stream_hlcv(
) -> float:
streamer = factory()
last = np.nan
for high_value, low_value, close_value, volume_value in zip(high, low, close, volume):
for high_value, low_value, close_value, volume_value in zip(
high, low, close, volume
):
last = streamer.update(
float(high_value),
float(low_value),
@@ -154,7 +156,9 @@ def run_streaming_benchmark(
def main() -> int:
parser = argparse.ArgumentParser(description="Benchmark streaming indicator execution.")
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")
+223 -113
View File
@@ -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
+13 -3
View File
@@ -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)
+4 -4
View File
@@ -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}"
+135 -22
View File
@@ -1,56 +1,167 @@
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
def git_info() -> dict[str, Any]:
"""Best-effort git metadata for reproducible benchmark artifacts."""
try:
import tomllib
except ImportError: # pragma: no cover
try:
commit = subprocess.check_output(
["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL
).strip()
except Exception:
commit = None
import tomli as tomllib # type: ignore[no-redef]
except ImportError: # pragma: no cover
tomllib = None # type: ignore[assignment]
try:
dirty = bool(
subprocess.check_output(
["git", "status", "--porcelain"],
text=True,
stderr=subprocess.DEVNULL,
).strip()
)
except Exception:
dirty = None
_ROOT = Path(__file__).resolve().parent.parent
def _run_cmd(command: list[str]) -> str | None:
try:
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
return subprocess.check_output(
command,
text=True,
stderr=subprocess.DEVNULL,
).strip()
except Exception:
branch = None
return None
return {"commit": commit, "dirty": dirty, "branch": branch}
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()
@@ -71,6 +182,8 @@ def benchmark_metadata(
"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]
+20 -5
View File
@@ -50,11 +50,17 @@ def _naive_beta(x: np.ndarray, y: np.ndarray, window: int) -> np.ndarray:
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)],
[
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)],
[
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
@@ -120,7 +126,12 @@ def build_hotspot_report(
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)}
ohlcv = {
"close": close,
"high": high,
"low": low,
"volume": np.full(price_bars, 1000.0),
}
rows = [
(
@@ -218,7 +229,9 @@ def build_hotspot_report(
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)
row["share_of_suite_pct"] = round(
float(row["fast_ms"]) / total_fast_ms * 100.0, 2
)
return {
"metadata": benchmark_metadata(
@@ -249,7 +262,9 @@ def main() -> int:
window=args.window,
)
print(f"{'Category':<16} {'Case':<18} {'Fast (ms)':>10} {'Ref (ms)':>10} {'Speedup':>10}")
print(
f"{'Category':<16} {'Case':<18} {'Fast (ms)':>10} {'Ref (ms)':>10} {'Speedup':>10}"
)
print("-" * 70)
for row in payload["results"]:
print(
+1 -1
View File
@@ -16094,4 +16094,4 @@
],
"datetime": "2026-03-23T17:14:05.427766+00:00",
"version": "5.2.3"
}
}
+4 -5
View File
@@ -52,7 +52,9 @@ def build_indicator_latency_report(*, rounds: int = 5) -> dict[str, Any]:
rows: list[dict[str, Any]] = []
for entry in INDICATOR_SUITE:
elapsed_ms = _time_min(lambda entry=entry: _run_indicator(entry, ohlcv), rounds=rounds)
elapsed_ms = _time_min(
lambda entry=entry: _run_indicator(entry, ohlcv), rounds=rounds
)
rows.append(
{
"name": entry["name"],
@@ -192,10 +194,7 @@ def main() -> int:
fixtures=[FIXTURE_PATH],
extra={"output_dir": str(output_dir)},
),
"artifacts": {
name: file_info(path)
for name, path in artifacts.items()
},
"artifacts": {name: file_info(path) for name, path in artifacts.items()},
}
manifest_path = output_dir / "manifest.json"
_write_json(manifest_path, manifest)
+60 -53
View File
@@ -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):
+17 -1
View File
@@ -10,11 +10,27 @@ Run with:
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import numpy as np
import pytest
from ferro_ta.analysis.options import implied_volatility, option_price
# 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, ...]:
+31 -18
View File
@@ -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):
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,5 +1,5 @@
{% set name = "ferro-ta" %}
{% set version = "1.0.0" %}
{% set version = "1.2.0" %}
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
+13 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "ferro_ta_core"
version = "1.0.2"
version = "1.2.0"
edition = "2021"
description = "Pure Rust core indicator library — no PyO3, no numpy dependency"
license = "MIT"
@@ -16,7 +16,9 @@ name = "ferro_ta_core"
crate-type = ["lib"]
[dependencies]
wide = { version = "1.1.1", optional = true }
multiversion = { version = "0.8", optional = true }
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true }
[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }
@@ -26,5 +28,12 @@ name = "indicators"
harness = false
[features]
wide = ["dep:wide"]
simd = ["wide"]
# Runtime CPU-feature dispatch (multiversion). Default ON so `cargo add
# ferro_ta_core` and the published wheels get SIMD-accelerated reductions
# that adapt to the running CPU (baseline .. AVX-512 / NEON) WITHOUT pinning
# a target-cpu — one binary runs on any CPU of the target arch, with no
# illegal-instruction crashes on older chips. Disable with
# `--no-default-features` for a pure-scalar build.
default = ["simd"]
simd = ["dep:multiversion"]
serde = ["dep:serde", "dep:serde_json"]
+28 -20
View File
@@ -7,13 +7,13 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for:
- Rust-native technical analysis workloads
- 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.2"
ferro_ta_core = "1.2.0"
```
## Design
@@ -25,12 +25,31 @@ ferro_ta_core = "1.0.2"
## 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.
+340
View File
@@ -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(&[], &[], &[]);
}
}
+126
View File
@@ -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
}
}
+333
View File
@@ -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
+641
View File
@@ -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);
}
}
+123
View File
@@ -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());
}
}
+295
View File
@@ -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)
}
}
+91
View File
@@ -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());
}
}
+173
View File
@@ -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);
}
}
+370
View File
@@ -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
);
}
}
}
+962
View File
@@ -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());
}
}
+23
View File
@@ -1,3 +1,5 @@
#![forbid(unsafe_code)]
/*!
ferro_ta_core Pure Rust indicator library.
@@ -26,11 +28,32 @@ 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;
/// Runtime-dispatched SIMD reduction primitives (internal).
pub(crate) mod simd;
pub mod statistic;
pub mod streaming;
pub mod volatility;
pub mod volume;
+93 -9
View File
@@ -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::*;
+154
View File
@@ -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()));
}
}
+608 -35
View File
@@ -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());
}
}
+382
View File
@@ -0,0 +1,382 @@
//! Digital (binary) option pricing.
use super::normal::cdf;
use super::OptionKind;
/// Type of digital option payoff.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DigitalKind {
/// Pays 1 unit of cash if option expires in the money.
CashOrNothing,
/// Pays the underlying asset if option expires in the money.
AssetOrNothing,
}
fn invalid_inputs(spot: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
!spot.is_finite()
|| !strike.is_finite()
|| !time_to_expiry.is_finite()
|| !volatility.is_finite()
|| spot <= 0.0
|| strike <= 0.0
|| time_to_expiry < 0.0
|| volatility < 0.0
}
/// Price a digital (binary) option under BSM.
///
/// # Parameters
/// - `spot`: current underlying price
/// - `strike`: option strike price
/// - `rate`: risk-free rate (annualized, decimal)
/// - `carry`: continuous dividend yield / carry rate
/// - `time_to_expiry`: time to expiry in years
/// - `volatility`: implied vol (annualized, decimal)
/// - `option_kind`: call or put
/// - `digital_kind`: cash-or-nothing or asset-or-nothing
#[allow(clippy::too_many_arguments)]
pub fn digital_price(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
option_kind: OptionKind,
digital_kind: DigitalKind,
) -> f64 {
if invalid_inputs(spot, strike, time_to_expiry, volatility)
|| !rate.is_finite()
|| !carry.is_finite()
{
return f64::NAN;
}
// At expiry: pay intrinsic based on ITM status
if time_to_expiry == 0.0 {
let itm = match option_kind {
OptionKind::Call => spot > strike,
OptionKind::Put => spot < strike,
};
return if itm {
match digital_kind {
DigitalKind::CashOrNothing => 1.0,
DigitalKind::AssetOrNothing => spot,
}
} else {
0.0
};
}
let discount = (-rate * time_to_expiry).exp();
let carry_discount = (-carry * time_to_expiry).exp();
// At zero vol: deterministic payoff
if volatility == 0.0 {
let forward = spot * (carry_discount / discount); // S * e^{(r-q)*T} equivalent: S*e^{-q*T}/e^{-r*T}
// forward = S * e^{(r-q)*T}; ITM if forward > K for call
let itm = match option_kind {
OptionKind::Call => spot * carry_discount > strike * discount,
OptionKind::Put => spot * carry_discount < strike * discount,
};
let _ = forward; // suppress unused warning
return if itm {
match digital_kind {
DigitalKind::CashOrNothing => discount,
DigitalKind::AssetOrNothing => spot * carry_discount,
}
} else {
0.0
};
}
let sqrt_t = time_to_expiry.sqrt();
let sigma_sqrt_t = volatility * sqrt_t;
let d1 = ((spot / strike).ln()
+ (rate - carry + 0.5 * volatility * volatility) * time_to_expiry)
/ sigma_sqrt_t;
let d2 = d1 - sigma_sqrt_t;
match digital_kind {
DigitalKind::CashOrNothing => match option_kind {
OptionKind::Call => discount * cdf(d2),
OptionKind::Put => discount * cdf(-d2),
},
DigitalKind::AssetOrNothing => match option_kind {
OptionKind::Call => spot * carry_discount * cdf(d1),
OptionKind::Put => spot * carry_discount * cdf(-d1),
},
}
}
/// Compute numerical delta, gamma, and vega for a digital option.
///
/// Uses central finite differences:
/// - delta/gamma: bump spot by ε = spot * 1e-3
/// - vega: bump volatility by 1e-3
///
/// Returns `(delta, gamma, vega)`.
#[allow(clippy::too_many_arguments)]
pub fn digital_greeks(
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
volatility: f64,
option_kind: OptionKind,
digital_kind: DigitalKind,
) -> (f64, f64, f64) {
let eps = spot * 1e-3;
if eps <= 0.0 {
return (f64::NAN, f64::NAN, f64::NAN);
}
let price_mid = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility,
option_kind,
digital_kind,
);
let price_up = digital_price(
spot + eps,
strike,
rate,
carry,
time_to_expiry,
volatility,
option_kind,
digital_kind,
);
let price_dn = digital_price(
spot - eps,
strike,
rate,
carry,
time_to_expiry,
volatility,
option_kind,
digital_kind,
);
let delta = (price_up - price_dn) / (2.0 * eps);
let gamma = (price_up - 2.0 * price_mid + price_dn) / (eps * eps);
let vol_bump = 1e-3;
let vega = if volatility + vol_bump > 0.0 && volatility - vol_bump > 0.0 {
let price_vup = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility + vol_bump,
option_kind,
digital_kind,
);
let price_vdn = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility - vol_bump,
option_kind,
digital_kind,
);
(price_vup - price_vdn) / (2.0 * vol_bump)
} else {
// vol too close to zero; one-sided bump
let price_vup = digital_price(
spot,
strike,
rate,
carry,
time_to_expiry,
volatility + vol_bump,
option_kind,
digital_kind,
);
(price_vup - price_mid) / vol_bump
};
(delta, gamma, vega)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::OptionKind;
#[test]
fn cash_or_nothing_call_atm() {
// ATM cash-or-nothing call: price = e^{-rT} * N(d2)
// At S=K=100, r=0.05, q=0, T=1, σ=0.2:
// d1 = (0 + 0.07) / 0.2 = 0.35, d2 = 0.15 → N(0.15) ≈ 0.5596
// price ≈ e^{-0.05} * 0.5596 ≈ 0.532
let price = digital_price(
100.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!(
price > 0.0 && price < 1.0,
"price should be between 0 and 1"
);
assert!((price - 0.532).abs() < 0.01, "price ≈ 0.532, got {price}");
}
#[test]
fn asset_or_nothing_call_at_zero_vol() {
// At zero vol, ITM asset-or-nothing call should equal S * e^{-q*T}
let price = digital_price(
110.0,
100.0,
0.05,
0.0,
1.0,
0.0,
OptionKind::Call,
DigitalKind::AssetOrNothing,
);
assert!((price - 110.0).abs() < 1e-6);
}
#[test]
fn digital_price_returns_nan_for_invalid() {
let price = digital_price(
-1.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!(price.is_nan());
}
#[test]
fn cash_or_nothing_put_call_parity() {
// Cash-or-nothing call + cash-or-nothing put = e^{-rT}
let call = digital_price(
100.0,
100.0,
0.05,
0.02,
1.0,
0.25,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
let put = digital_price(
100.0,
100.0,
0.05,
0.02,
1.0,
0.25,
OptionKind::Put,
DigitalKind::CashOrNothing,
);
let discount = (-0.05_f64).exp();
assert!((call + put - discount).abs() < 1e-10);
}
#[test]
fn asset_or_nothing_put_call_parity() {
// Asset-or-nothing call + asset-or-nothing put = S * e^{-q*T}
let s = 100.0_f64;
let q = 0.02_f64;
let call = digital_price(
s,
100.0,
0.05,
q,
1.0,
0.25,
OptionKind::Call,
DigitalKind::AssetOrNothing,
);
let put = digital_price(
s,
100.0,
0.05,
q,
1.0,
0.25,
OptionKind::Put,
DigitalKind::AssetOrNothing,
);
let expected = s * (-q).exp();
assert!((call + put - expected).abs() < 1e-10);
}
#[test]
fn digital_greeks_are_finite_for_valid_inputs() {
let (delta, gamma, vega) = digital_greeks(
100.0,
100.0,
0.05,
0.0,
1.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!(delta.is_finite());
assert!(gamma.is_finite());
assert!(vega.is_finite());
}
#[test]
fn digital_at_expiry_itm_returns_intrinsic() {
let price = digital_price(
110.0,
100.0,
0.05,
0.0,
0.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!((price - 1.0).abs() < 1e-10);
let price2 = digital_price(
110.0,
100.0,
0.05,
0.0,
0.0,
0.2,
OptionKind::Call,
DigitalKind::AssetOrNothing,
);
assert!((price2 - 110.0).abs() < 1e-10);
}
#[test]
fn digital_at_expiry_otm_returns_zero() {
let price = digital_price(
90.0,
100.0,
0.05,
0.0,
0.0,
0.2,
OptionKind::Call,
DigitalKind::CashOrNothing,
);
assert!((price - 0.0).abs() < 1e-10);
}
}
+99 -2
View File
@@ -2,7 +2,7 @@
use super::normal::{cdf, pdf};
use super::pricing::{black_76_price, black_scholes_price};
use super::{Greeks, OptionEvaluation, OptionKind, PricingModel};
use super::{ExtendedGreeks, Greeks, OptionEvaluation, OptionKind, PricingModel};
fn bs_inputs_valid(
underlying: f64,
@@ -203,9 +203,94 @@ pub fn model_theta(input: OptionEvaluation) -> f64 {
})
}
/// Extended Greeks under Black-Scholes-Merton (closed-form).
///
/// All inputs must be positive finite; returns NaN fields for invalid inputs.
pub fn black_scholes_extended_greeks(
spot: f64,
strike: f64,
rate: f64,
dividend_yield: f64,
time_to_expiry: f64,
volatility: f64,
_kind: OptionKind,
) -> ExtendedGreeks {
if !bs_inputs_valid(
spot,
strike,
rate,
dividend_yield,
time_to_expiry,
volatility,
) {
return ExtendedGreeks {
vanna: f64::NAN,
volga: f64::NAN,
charm: f64::NAN,
speed: f64::NAN,
color: f64::NAN,
};
}
let sqrt_t = time_to_expiry.sqrt();
let sigma_sqrt_t = volatility * sqrt_t;
let carry_discount = (-dividend_yield * time_to_expiry).exp();
let d1 = ((spot / strike).ln()
+ (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry)
/ sigma_sqrt_t;
let d2 = d1 - sigma_sqrt_t;
let pdf_d1 = pdf(d1);
let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t);
let vanna = -carry_discount * pdf_d1 * d2 / volatility;
let volga = spot * carry_discount * pdf_d1 * sqrt_t * d1 * d2 / volatility;
let charm = -carry_discount
* pdf_d1
* (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t)
/ (2.0 * time_to_expiry * sigma_sqrt_t);
let speed = -gamma / spot * (d1 / sigma_sqrt_t + 1.0);
let color = -carry_discount * pdf_d1 / (2.0 * spot * time_to_expiry * sigma_sqrt_t)
* (2.0 * (rate - dividend_yield) * time_to_expiry + 1.0
- d1 * (2.0 * (rate - dividend_yield) * time_to_expiry - d2 * sigma_sqrt_t)
/ sigma_sqrt_t);
ExtendedGreeks {
vanna,
volga,
charm,
speed,
color,
}
}
/// Model-dispatched extended Greeks.
/// Only BSM is supported with closed-form; Black-76 is not yet supported (returns NaN).
pub fn model_extended_greeks(input: OptionEvaluation) -> ExtendedGreeks {
let contract = input.contract;
match contract.model {
PricingModel::BlackScholes => black_scholes_extended_greeks(
contract.underlying,
contract.strike,
contract.rate,
contract.carry,
contract.time_to_expiry,
input.volatility,
contract.kind,
),
PricingModel::Black76 => ExtendedGreeks {
vanna: f64::NAN,
volga: f64::NAN,
charm: f64::NAN,
speed: f64::NAN,
color: f64::NAN,
},
}
}
#[cfg(test)]
mod tests {
use super::{black_76_greeks, black_scholes_greeks};
use super::{black_76_greeks, black_scholes_extended_greeks, black_scholes_greeks};
use crate::options::OptionKind;
#[test]
@@ -227,4 +312,16 @@ mod tests {
assert!(g.theta.is_finite());
assert!(g.rho.is_finite());
}
#[test]
fn extended_greeks_finite_for_valid_inputs() {
let eg = black_scholes_extended_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
assert!(eg.vanna.is_finite());
assert!(eg.volga.is_finite());
assert!(eg.charm.is_finite());
assert!(eg.speed.is_finite());
assert!(eg.color.is_finite());
// Volga must be positive (convex in vol)
assert!(eg.volga >= 0.0);
}
}
+14
View File
@@ -4,11 +4,15 @@
//! IV-series helpers, and smile/chain utilities. The public API is scalar-first
//! and is used by the PyO3 bridge to build vectorized batch functions.
pub mod american;
pub mod chain;
pub mod digital;
pub mod greeks;
pub mod iv;
pub mod normal;
pub mod payoff;
pub mod pricing;
pub mod realized_vol;
pub mod surface;
/// Option side.
@@ -49,6 +53,16 @@ pub struct Greeks {
pub rho: f64,
}
/// Second-order and cross Greeks.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ExtendedGreeks {
pub vanna: f64, // ∂Δ/∂σ
pub volga: f64, // ∂²V/∂σ² (vomma)
pub charm: f64, // ∂Δ/∂t
pub speed: f64, // ∂Γ/∂S
pub color: f64, // ∂Γ/∂t
}
/// Shared contract fields for model-based option analytics.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OptionContract {
+392
View File
@@ -0,0 +1,392 @@
//! Pure-Rust (no PyO3, no numpy) strategy payoff and value functions.
//!
//! NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;`
//! for this module to be reachable from the rest of the crate and from the PyO3 bridge.
use super::pricing::black_scholes_price;
use super::OptionKind;
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Instrument codes: 0=option, 1=future, 2=stock.
const INSTRUMENT_OPTION: i64 = 0;
const INSTRUMENT_FUTURE: i64 = 1;
const INSTRUMENT_STOCK: i64 = 2;
/// Side sign from encoded value: 1=long (+1.0), -1=short (-1.0).
#[inline]
fn side_sign(v: i64) -> f64 {
if v == 1 {
1.0
} else if v == -1 {
-1.0
} else {
f64::NAN
}
}
/// Option kind from encoded value: 1=call, -1=put.
#[inline]
fn option_kind(v: i64) -> Option<OptionKind> {
match v {
1 => Some(OptionKind::Call),
-1 => Some(OptionKind::Put),
_ => None,
}
}
// ---------------------------------------------------------------------------
// strategy_payoff_dense
// ---------------------------------------------------------------------------
/// Aggregate strategy payoff over a spot grid.
///
/// Parameters (all slices of length n_legs):
/// - `instruments`: 0=option, 1=future, 2=stock
/// - `sides`: 1=long, -1=short
/// - `option_types`: 1=call, -1=put (ignored for futures/stocks)
/// - `strikes`: strike for options
/// - `premiums`: premium for options
/// - `entry_prices`: entry price for futures/stocks
/// - `quantities`, `multipliers`: applied to all instruments
///
/// Returns a Vec<f64> of length spot_grid.len() with aggregate P&L per spot point.
#[allow(clippy::too_many_arguments)]
pub fn strategy_payoff_dense(
spot_grid: &[f64],
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
premiums: &[f64],
entry_prices: &[f64],
quantities: &[f64],
multipliers: &[f64],
) -> Vec<f64> {
let n_legs = instruments.len();
// Validate that all leg slices are the same length; return zeros if not.
if sides.len() != n_legs
|| option_types.len() != n_legs
|| strikes.len() != n_legs
|| premiums.len() != n_legs
|| entry_prices.len() != n_legs
|| quantities.len() != n_legs
|| multipliers.len() != n_legs
{
return vec![0.0; spot_grid.len()];
}
let mut total = vec![0.0_f64; spot_grid.len()];
for leg_idx in 0..n_legs {
let inst = instruments[leg_idx];
let sign = side_sign(sides[leg_idx]);
if sign.is_nan() {
// Invalid side — skip leg (treat as zero contribution).
continue;
}
let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx];
match inst {
INSTRUMENT_OPTION => {
let kind = match option_kind(option_types[leg_idx]) {
Some(k) => k,
None => continue, // Invalid option type — skip.
};
let k = strikes[leg_idx];
let p = premiums[leg_idx];
for (i, &s) in spot_grid.iter().enumerate() {
let intrinsic = match kind {
OptionKind::Call => (s - k).max(0.0),
OptionKind::Put => (k - s).max(0.0),
};
total[i] += leg_scale * (intrinsic - p);
}
}
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
let e = entry_prices[leg_idx];
for (i, &s) in spot_grid.iter().enumerate() {
total[i] += leg_scale * (s - e);
}
}
_ => {
// Unknown instrument code — skip leg (NaN would propagate; zeros are safer).
}
}
}
total
}
// ---------------------------------------------------------------------------
// strategy_value_dense / strategy_value_grid
// ---------------------------------------------------------------------------
/// Current BSM value of a strategy at a single spot (pre-expiry).
///
/// Unlike `strategy_payoff_dense`, this uses BSM pricing for option legs rather
/// than intrinsic value.
///
/// Parameters: same as `strategy_payoff_dense` plus per-leg BSM inputs:
/// - `time_to_expiries`: TTE for each option leg (ignored for futures/stocks)
/// - `volatilities`: vol for each option leg (ignored for futures/stocks)
/// - `rates`: risk-free rate for each leg
/// - `carries`: carry/dividend yield for each option leg
///
/// Returns a scalar f64 (strategy P&L at the given spot).
#[allow(clippy::too_many_arguments)]
pub fn strategy_value_dense(
spot: f64,
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
premiums: &[f64],
entry_prices: &[f64],
quantities: &[f64],
multipliers: &[f64],
time_to_expiries: &[f64],
volatilities: &[f64],
rates: &[f64],
carries: &[f64],
) -> f64 {
let n_legs = instruments.len();
// Validate that all leg slices are the same length; return NaN if not.
if sides.len() != n_legs
|| option_types.len() != n_legs
|| strikes.len() != n_legs
|| premiums.len() != n_legs
|| entry_prices.len() != n_legs
|| quantities.len() != n_legs
|| multipliers.len() != n_legs
|| time_to_expiries.len() != n_legs
|| volatilities.len() != n_legs
|| rates.len() != n_legs
|| carries.len() != n_legs
{
return f64::NAN;
}
let mut total = 0.0_f64;
for leg_idx in 0..n_legs {
let inst = instruments[leg_idx];
let sign = side_sign(sides[leg_idx]);
if sign.is_nan() {
continue;
}
let leg_scale = sign * quantities[leg_idx] * multipliers[leg_idx];
match inst {
INSTRUMENT_OPTION => {
let kind = match option_kind(option_types[leg_idx]) {
Some(k) => k,
None => continue,
};
let bsm = black_scholes_price(
spot,
strikes[leg_idx],
rates[leg_idx],
carries[leg_idx],
time_to_expiries[leg_idx],
volatilities[leg_idx],
kind,
);
total += leg_scale * (bsm - premiums[leg_idx]);
}
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
total += leg_scale * (spot - entry_prices[leg_idx]);
}
_ => {}
}
}
total
}
// ---------------------------------------------------------------------------
// aggregate_greeks_dense
// ---------------------------------------------------------------------------
/// Aggregate BSM Greeks for a multi-leg strategy at a single spot.
///
/// Parameters (all slices of length n_legs):
/// - `instruments`: 0=option, 1=future, 2=stock
/// - `sides`: 1=long, -1=short
/// - `option_types`: 1=call, -1=put (ignored for futures/stocks)
/// - `strikes`: strike price for option legs
/// - `volatilities`: implied vol for option legs
/// - `time_to_expiries`: TTE in years for option legs
/// - `rates`: risk-free rate for each leg
/// - `carries`: carry/dividend yield for option legs
/// - `quantities`, `multipliers`: applied to all instruments
///
/// Returns `(delta, gamma, vega, theta, rho)` aggregate across all legs.
/// Future/stock legs contribute `leg_scale` to delta only (all other Greeks = 0).
#[allow(clippy::too_many_arguments)]
pub fn aggregate_greeks_dense(
spot: f64,
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
volatilities: &[f64],
time_to_expiries: &[f64],
rates: &[f64],
carries: &[f64],
quantities: &[f64],
multipliers: &[f64],
) -> (f64, f64, f64, f64, f64) {
use super::greeks::model_greeks;
use super::{OptionContract, OptionEvaluation, PricingModel};
let n_legs = instruments.len();
if sides.len() != n_legs
|| option_types.len() != n_legs
|| strikes.len() != n_legs
|| volatilities.len() != n_legs
|| time_to_expiries.len() != n_legs
|| rates.len() != n_legs
|| carries.len() != n_legs
|| quantities.len() != n_legs
|| multipliers.len() != n_legs
{
return (f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN);
}
let mut delta = 0.0_f64;
let mut gamma = 0.0_f64;
let mut vega = 0.0_f64;
let mut theta = 0.0_f64;
let mut rho = 0.0_f64;
for i in 0..n_legs {
let sign = side_sign(sides[i]);
if sign.is_nan() {
continue;
}
let leg_scale = sign * quantities[i] * multipliers[i];
match instruments[i] {
INSTRUMENT_FUTURE | INSTRUMENT_STOCK => {
delta += leg_scale;
}
INSTRUMENT_OPTION => {
let kind = match option_kind(option_types[i]) {
Some(k) => k,
None => continue,
};
let greeks = model_greeks(OptionEvaluation {
contract: OptionContract {
model: PricingModel::BlackScholes,
underlying: spot,
strike: strikes[i],
rate: rates[i],
carry: carries[i],
time_to_expiry: time_to_expiries[i],
kind,
},
volatility: volatilities[i],
});
delta += leg_scale * greeks.delta;
gamma += leg_scale * greeks.gamma;
vega += leg_scale * greeks.vega;
theta += leg_scale * greeks.theta;
rho += leg_scale * greeks.rho;
}
_ => {}
}
}
(delta, gamma, vega, theta, rho)
}
/// Evaluate `strategy_value_dense` for each point in `spot_grid`.
///
/// Returns a `Vec<f64>` of length `spot_grid.len()`.
#[allow(clippy::too_many_arguments)]
pub fn strategy_value_grid(
spot_grid: &[f64],
instruments: &[i64],
sides: &[i64],
option_types: &[i64],
strikes: &[f64],
premiums: &[f64],
entry_prices: &[f64],
quantities: &[f64],
multipliers: &[f64],
time_to_expiries: &[f64],
volatilities: &[f64],
rates: &[f64],
carries: &[f64],
) -> Vec<f64> {
spot_grid
.iter()
.map(|&s| {
strategy_value_dense(
s,
instruments,
sides,
option_types,
strikes,
premiums,
entry_prices,
quantities,
multipliers,
time_to_expiries,
volatilities,
rates,
carries,
)
})
.collect()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payoff_single_call() {
let grid = vec![90.0, 100.0, 110.0, 120.0];
let out = strategy_payoff_dense(
&grid,
&[0],
&[1],
&[1],
&[100.0],
&[5.0],
&[0.0],
&[1.0],
&[1.0],
);
assert!(out[0] < 0.0); // below strike, loss = premium
assert!((out[0] - (-5.0)).abs() < 1e-10);
assert!((out[2] - 5.0).abs() < 1e-10); // at 110, intrinsic=10, net=10-5=5
}
#[test]
fn stock_leg_linear() {
let grid = vec![90.0, 100.0, 110.0];
let out = strategy_payoff_dense(
&grid,
&[2],
&[1],
&[0],
&[0.0],
&[0.0],
&[100.0],
&[1.0],
&[1.0],
);
assert!((out[0] - (-10.0)).abs() < 1e-10);
assert!((out[1] - 0.0).abs() < 1e-10);
assert!((out[2] - 10.0).abs() < 1e-10);
}
}
@@ -118,6 +118,37 @@ pub fn model_price(input: OptionEvaluation) -> f64 {
}
}
/// Put-call parity deviation: `C - P - (S·e^{-q·T} - K·e^{-r·T})`.
///
/// Returns 0.0 when no arbitrage exists. A non-zero value indicates the
/// magnitude of mispricing or data error.
pub fn put_call_parity_deviation(
call_price: f64,
put_price: f64,
spot: f64,
strike: f64,
rate: f64,
carry: f64,
time_to_expiry: f64,
) -> f64 {
if !call_price.is_finite()
|| !put_price.is_finite()
|| !spot.is_finite()
|| !strike.is_finite()
|| !rate.is_finite()
|| !carry.is_finite()
|| !time_to_expiry.is_finite()
|| spot <= 0.0
|| strike <= 0.0
|| time_to_expiry < 0.0
{
return f64::NAN;
}
let pv_forward = spot * (-carry * time_to_expiry).exp();
let pv_strike = strike * (-rate * time_to_expiry).exp();
call_price - put_price - (pv_forward - pv_strike)
}
/// Lower no-arbitrage bound for the option price.
pub fn price_lower_bound(contract: OptionContract) -> f64 {
match contract.model {
@@ -0,0 +1,445 @@
//! Historical (realized) volatility estimators and volatility cone.
/// Rolling close-to-close realized volatility.
///
/// Returns a `Vec<f64>` of the same length as `close`. The first `window` values
/// are NaN (we need `window` log-returns, which require `window+1` prices, so the
/// first valid output sits at index `window`).
///
/// Annualization: `sqrt(sum(r²) / window * trading_days)`.
pub fn close_to_close_vol(close: &[f64], window: usize, trading_days: f64) -> Vec<f64> {
let n = close.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n <= window {
return out;
}
// Precompute log-returns; returns[i] = ln(close[i+1] / close[i])
let mut returns = vec![f64::NAN; n - 1];
for i in 0..(n - 1) {
if close[i] > 0.0 && close[i + 1] > 0.0 {
returns[i] = (close[i + 1] / close[i]).ln();
}
}
// Rolling sum of squared returns over `window` bars.
// The output at position `end` (in the original close array) uses
// returns[end-window .. end-1], i.e. `window` returns.
for end in window..n {
let slice = &returns[(end - window)..end];
let sum_sq: f64 = slice.iter().map(|&r| r * r).sum();
let var = sum_sq / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
out
}
/// Rolling Parkinson high-low realized volatility estimator.
///
/// Returns a `Vec<f64>` of the same length as `high`. The first `window-1` values
/// are NaN.
#[allow(clippy::needless_range_loop)]
pub fn parkinson_vol(high: &[f64], low: &[f64], window: usize, trading_days: f64) -> Vec<f64> {
let n = high.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n < window || low.len() != n {
return out;
}
let factor = 1.0 / (4.0 * 2_f64.ln());
for end in (window - 1)..n {
let start = end + 1 - window;
let mut sum_sq = 0.0;
let mut valid = true;
for i in start..=end {
if high[i] <= 0.0 || low[i] <= 0.0 || !high[i].is_finite() || !low[i].is_finite() {
valid = false;
break;
}
let u = (high[i] / low[i]).ln();
sum_sq += u * u;
}
if valid {
let var = factor * sum_sq / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
}
out
}
/// Rolling Garman-Klass OHLC realized volatility estimator.
///
/// Returns a `Vec<f64>` of the same length as the inputs. The first `window-1`
/// values are NaN. All four slices must have the same length.
pub fn garman_klass_vol(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
window: usize,
trading_days: f64,
) -> Vec<f64> {
let n = open.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n {
return out;
}
let ln2 = 2_f64.ln();
// Precompute per-bar GK contributions.
let mut gk = vec![f64::NAN; n];
for i in 0..n {
let o = open[i];
let h = high[i];
let l = low[i];
let c = close[i];
if o > 0.0
&& h > 0.0
&& l > 0.0
&& c > 0.0
&& o.is_finite()
&& h.is_finite()
&& l.is_finite()
&& c.is_finite()
{
let u = (h / o).ln();
let d = (l / o).ln();
let ci = (c / o).ln();
gk[i] = 0.5 * (u - d).powi(2) - (2.0 * ln2 - 1.0) * ci * ci;
}
}
for end in (window - 1)..n {
let start = end + 1 - window;
let slice = &gk[start..=end];
if slice.iter().all(|v| v.is_finite()) {
let sum: f64 = slice.iter().sum();
let var = sum / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
}
out
}
/// Compute the Rogers-Satchell per-bar variance contribution.
fn rs_bar(open: f64, high: f64, low: f64, close: f64) -> f64 {
let u = (high / close).ln();
let d = (low / close).ln();
let uo = (high / open).ln();
let do_ = (low / open).ln();
u * uo + d * do_
}
/// Rolling Rogers-Satchell OHLC realized volatility estimator.
///
/// Returns a `Vec<f64>` of the same length as the inputs. The first `window-1`
/// values are NaN. All four slices must have the same length.
pub fn rogers_satchell_vol(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
window: usize,
trading_days: f64,
) -> Vec<f64> {
let n = open.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n < window || high.len() != n || low.len() != n || close.len() != n {
return out;
}
// Precompute per-bar RS contributions.
let mut rs = vec![f64::NAN; n];
for i in 0..n {
let o = open[i];
let h = high[i];
let l = low[i];
let c = close[i];
if o > 0.0
&& h > 0.0
&& l > 0.0
&& c > 0.0
&& o.is_finite()
&& h.is_finite()
&& l.is_finite()
&& c.is_finite()
{
rs[i] = rs_bar(o, h, l, c);
}
}
for end in (window - 1)..n {
let start = end + 1 - window;
let slice = &rs[start..=end];
if slice.iter().all(|v| v.is_finite()) {
let sum: f64 = slice.iter().sum();
let var = sum / window as f64 * trading_days;
out[end] = if var >= 0.0 { var.sqrt() } else { f64::NAN };
}
}
out
}
/// Rolling Yang-Zhang OHLC realized volatility estimator.
///
/// Handles overnight gaps. Returns a `Vec<f64>` of the same length as the inputs.
/// The first `window` values are NaN (we need `window` bars plus the prior close
/// for overnight returns, so valid output starts at index `window`).
/// All four slices must have the same length.
pub fn yang_zhang_vol(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
window: usize,
trading_days: f64,
) -> Vec<f64> {
let n = open.len();
let mut out = vec![f64::NAN; n];
if window == 0 || n <= window || high.len() != n || low.len() != n || close.len() != n {
return out;
}
let k = 0.34 / (1.34 + (window as f64 + 1.0) / (window as f64 - 1.0).max(1e-10));
// Precompute per-bar components; index 0 has no overnight return.
// overnight[i] = ln(O_i / C_{i-1}), valid for i >= 1
// openclose[i] = ln(C_i / O_i)
// rs[i] = Rogers-Satchell for bar i
let mut overnight = vec![f64::NAN; n];
let mut openclose = vec![f64::NAN; n];
let mut rs = vec![f64::NAN; n];
for i in 0..n {
let o = open[i];
let h = high[i];
let l = low[i];
let c = close[i];
if o > 0.0
&& h > 0.0
&& l > 0.0
&& c > 0.0
&& o.is_finite()
&& h.is_finite()
&& l.is_finite()
&& c.is_finite()
{
openclose[i] = (c / o).ln();
rs[i] = rs_bar(o, h, l, c);
if i > 0 {
let prev_c = close[i - 1];
if prev_c > 0.0 && prev_c.is_finite() {
overnight[i] = (o / prev_c).ln();
}
}
}
}
// Valid windows start at index `window` (using bars [end-window+1 .. end],
// all of which have valid overnight returns since they start at index >= 1).
for end in window..n {
let start = end + 1 - window; // start >= 1 because end >= window
let o_slice = &overnight[start..=end];
let c_slice = &openclose[start..=end];
let r_slice = &rs[start..=end];
if !o_slice.iter().all(|v| v.is_finite())
|| !c_slice.iter().all(|v| v.is_finite())
|| !r_slice.iter().all(|v| v.is_finite())
{
continue;
}
let w = window as f64;
let o_sum: f64 = o_slice.iter().sum();
let o_sum_sq: f64 = o_slice.iter().map(|&x| x * x).sum();
let overnight_var = o_sum_sq / (w - 1.0) - (o_sum / w).powi(2) * w / (w - 1.0);
let c_sum: f64 = c_slice.iter().sum();
let c_sum_sq: f64 = c_slice.iter().map(|&x| x * x).sum();
let openclose_var = c_sum_sq / (w - 1.0) - (c_sum / w).powi(2) * w / (w - 1.0);
let rs_sum: f64 = r_slice.iter().sum();
let rs_var = rs_sum / w;
let yz_var = overnight_var + k * openclose_var + (1.0 - k) * rs_var;
let annualized = yz_var * trading_days;
out[end] = if annualized >= 0.0 {
annualized.sqrt()
} else {
f64::NAN
};
}
out
}
/// Summary statistics of realized vol distribution for one window length.
#[derive(Clone, Copy, Debug)]
pub struct VolConeSlice {
pub window: usize,
pub min: f64,
pub p25: f64,
pub median: f64,
pub p75: f64,
pub max: f64,
}
/// Compute a percentile via linear interpolation on a sorted slice.
///
/// `sorted` must be non-empty and already sorted ascending.
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let idx = (n - 1) as f64 * p;
let lo = idx.floor() as usize;
let hi = idx.ceil() as usize;
let frac = idx - lo as f64;
sorted[lo] + frac * (sorted[hi] - sorted[lo])
}
/// Compute vol cone: distribution of realized vols across multiple window lengths.
///
/// For each window in `windows`, the close-to-close rolling vol is computed,
/// NaN values are filtered out, and the distribution statistics (min, p25,
/// median, p75, max) are derived via linear interpolation.
pub fn vol_cone(close: &[f64], windows: &[usize], trading_days: f64) -> Vec<VolConeSlice> {
windows
.iter()
.map(|&w| {
let vols = close_to_close_vol(close, w, trading_days);
let mut valid: Vec<f64> = vols.into_iter().filter(|v| v.is_finite()).collect();
valid.sort_by(|a, b| a.partial_cmp(b).unwrap());
if valid.is_empty() {
return VolConeSlice {
window: w,
min: f64::NAN,
p25: f64::NAN,
median: f64::NAN,
p75: f64::NAN,
max: f64::NAN,
};
}
VolConeSlice {
window: w,
min: valid[0],
p25: percentile_sorted(&valid, 0.25),
median: percentile_sorted(&valid, 0.5),
p75: percentile_sorted(&valid, 0.75),
max: *valid.last().unwrap(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_prices(n: usize) -> Vec<f64> {
// simple synthetic price series
let mut prices = vec![100.0_f64; n];
for i in 1..n {
prices[i] = prices[i - 1] * (1.0 + 0.01 * (i as f64 % 7_f64 - 3.0) * 0.01);
}
prices
}
#[test]
fn close_to_close_returns_nans_for_warmup() {
let close = fake_prices(100);
let result = close_to_close_vol(&close, 20, 252.0);
assert_eq!(result.len(), 100);
// first 20 values should be NaN (window-1 of returns warmup + 1 for diff)
for i in 0..20 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[20].is_finite());
}
#[test]
fn parkinson_vol_is_positive() {
let close = fake_prices(100);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = parkinson_vol(&high, &low, 20, 252.0);
for v in result.iter().skip(19) {
assert!(v.is_finite() && *v >= 0.0);
}
}
#[test]
fn vol_cone_is_ordered() {
let close = fake_prices(300);
let cones = vol_cone(&close, &[20, 60], 252.0);
assert_eq!(cones.len(), 2);
for cone in &cones {
assert!(cone.min <= cone.p25);
assert!(cone.p25 <= cone.median);
assert!(cone.median <= cone.p75);
assert!(cone.p75 <= cone.max);
}
}
#[test]
fn garman_klass_returns_nans_for_warmup() {
let close = fake_prices(50);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = garman_klass_vol(&close, &high, &low, &close, 10, 252.0);
assert_eq!(result.len(), 50);
for i in 0..9 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[9].is_finite());
}
#[test]
fn rogers_satchell_returns_nans_for_warmup() {
let close = fake_prices(50);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = rogers_satchell_vol(&close, &high, &low, &close, 10, 252.0);
assert_eq!(result.len(), 50);
for i in 0..9 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[9].is_finite());
}
#[test]
fn yang_zhang_returns_nans_for_warmup() {
let close = fake_prices(50);
let high: Vec<f64> = close.iter().map(|&c| c * 1.01).collect();
let low: Vec<f64> = close.iter().map(|&c| c * 0.99).collect();
let result = yang_zhang_vol(&close, &high, &low, &close, 10, 252.0);
assert_eq!(result.len(), 50);
for i in 0..10 {
assert!(result[i].is_nan(), "result[{i}] should be NaN");
}
assert!(result[10].is_finite());
}
#[test]
fn mismatched_lengths_return_all_nan() {
let a = vec![100.0_f64; 20];
let b = vec![101.0_f64; 15]; // wrong length
let result = parkinson_vol(&a, &b, 5, 252.0);
assert!(result.iter().all(|v| v.is_nan()));
}
#[test]
fn window_larger_than_data_returns_all_nan() {
let close = fake_prices(10);
let result = close_to_close_vol(&close, 20, 252.0);
assert!(result.iter().all(|v| v.is_nan()));
}
}
@@ -202,6 +202,35 @@ pub fn term_structure_slope(tenors: &[f64], atm_ivs: &[f64]) -> f64 {
regression_slope(tenors, atm_ivs)
}
/// Expected ±1σ move over `days_to_expiry` calendar days.
///
/// Returns `(lower_move, upper_move)` as absolute changes from `spot`.
/// Example: if spot=100 and upper_move=5.0 then the 1σ upper bound is 105.
///
/// Uses the log-normal approximation: `spot × e^{±σ√(days/trading_days)} spot`.
pub fn expected_move(
spot: f64,
iv: f64,
days_to_expiry: f64,
trading_days_per_year: f64,
) -> (f64, f64) {
if !spot.is_finite()
|| !iv.is_finite()
|| !days_to_expiry.is_finite()
|| !trading_days_per_year.is_finite()
|| spot <= 0.0
|| iv < 0.0
|| days_to_expiry < 0.0
|| trading_days_per_year <= 0.0
{
return (f64::NAN, f64::NAN);
}
let sigma_sqrt_t = iv * (days_to_expiry / trading_days_per_year).sqrt();
let upper = spot * sigma_sqrt_t.exp() - spot;
let lower = spot * (-sigma_sqrt_t).exp() - spot;
(lower, upper)
}
#[cfg(test)]
mod tests {
use super::{atm_iv, smile_metrics, term_structure_slope};
+780 -138
View File
@@ -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,35 +21,27 @@ 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 {
return;
}
#[cfg(feature = "simd")]
let window_sum_init = {
use wide::f64x4;
let p_data = &src[..timeperiod];
let mut sum = f64x4::splat(0.0);
let mut chunks = p_data.chunks_exact(4);
for chunk in &mut chunks {
sum += f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
}
let arr = sum.to_array();
let mut total = arr[0] + arr[1] + arr[2] + arr[3];
for &v in chunks.remainder() {
total += v;
}
total
};
#[cfg(not(feature = "simd"))]
let window_sum_init: f64 = src[..timeperiod].iter().sum();
let mut window_sum = window_sum_init;
// Seed the rolling window with a runtime-dispatched reduction. The O(n)
// streaming recurrence below is inherently sequential, so SIMD only ever
// applies to this initial window sum.
let mut window_sum = crate::simd::sum(&src[..timeperiod]);
let tp_f64 = timeperiod as f64;
dest[dest_offset + timeperiod - 1] = window_sum / tp_f64;
@@ -66,7 +65,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 +89,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];
@@ -95,46 +107,9 @@ pub fn wma(close: &[f64], timeperiod: usize) -> Vec<f64> {
let denom: f64 = (timeperiod * (timeperiod + 1) / 2) as f64;
let p = timeperiod as f64;
// Seed: compute T and S for the first window.
#[cfg(feature = "simd")]
let (mut t, mut s) = {
use wide::f64x4;
let p_data = &close[..timeperiod];
let mut t_simd = f64x4::splat(0.0);
let mut s_simd = f64x4::splat(0.0);
let mut chunks = p_data.chunks_exact(4);
let mut idx = 1.0;
let step = f64x4::new([0.0, 1.0, 2.0, 3.0]);
for chunk in &mut chunks {
let vals = f64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
let mults = f64x4::splat(idx) + step;
t_simd += vals * mults;
s_simd += vals;
idx += 4.0;
}
let t_arr = t_simd.to_array();
let s_arr = s_simd.to_array();
let mut t = t_arr[0] + t_arr[1] + t_arr[2] + t_arr[3];
let mut s = s_arr[0] + s_arr[1] + s_arr[2] + s_arr[3];
for &v in chunks.remainder() {
t += v * idx;
s += v;
idx += 1.0;
}
(t, s)
};
#[cfg(not(feature = "simd"))]
let (mut t, mut s) = {
let t_val: f64 = close[..timeperiod]
.iter()
.enumerate()
.map(|(k, &v)| v * (k + 1) as f64)
.sum();
let s_val: f64 = close[..timeperiod].iter().sum();
(t_val, s_val)
};
// Seed: compute T and S for the first window via a runtime-dispatched
// reduction (the streaming recurrence below is sequential).
let (mut t, mut s) = crate::simd::wma_seed(&close[..timeperiod]);
result[timeperiod - 1] = t / denom;
@@ -157,10 +132,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 +185,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 +393,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 +956,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
+631
View File
@@ -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());
}
}
+166
View File
@@ -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());
}
}
+277
View File
@@ -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(&[], &[], &[], &[], &[], &[]);
}
}
+131
View File
@@ -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);
}
}
+161
View File
@@ -0,0 +1,161 @@
//! Runtime-dispatched SIMD primitives.
//!
//! Each public reduction here is compiled into several CPU-feature-specific
//! variants (baseline, SSE, AVX2/FMA, AVX-512 on x86_64; NEON on aarch64; …)
//! by [`multiversion`]. The fastest variant the *current* CPU supports is
//! chosen at runtime via CPUID. This gives one binary that:
//!
//! * runs on **any** CPU of the target architecture — no illegal-instruction
//! (SIGILL) crashes on pre-AVX2 chips, unlike a static `-C target-cpu=…`;
//! * still uses wide vector units where the hardware has them.
//!
//! The hot loops accumulate into **independent lanes** before a final
//! horizontal combine. That is what lets the optimizer auto-vectorize them:
//! a plain sequential `iter().sum()` is a dependency chain LLVM may not
//! reorder (doing so would change floating-point rounding). As a consequence
//! these results differ from a strict left-to-right sum by a few ULPs — well
//! inside every indicator's documented tolerance.
/// Number of independent accumulator lanes. Eight `f64` lanes cover the
/// widest target we dispatch to (AVX-512 = 8×f64); narrower targets (AVX2,
/// NEON) simply use a subset.
#[cfg(feature = "simd")]
const LANES: usize = 8;
/// Sum of a slice of `f64`, runtime-dispatched.
#[cfg(feature = "simd")]
#[multiversion::multiversion(targets = "simd")]
pub(crate) fn sum(data: &[f64]) -> f64 {
let mut acc = [0.0f64; LANES];
let mut chunks = data.chunks_exact(LANES);
for chunk in &mut chunks {
for (a, &v) in acc.iter_mut().zip(chunk) {
*a += v;
}
}
let remainder: f64 = chunks.remainder().iter().sum();
remainder + acc.iter().sum::<f64>()
}
/// Pure-scalar fallback when the `simd` feature is disabled.
#[cfg(not(feature = "simd"))]
pub(crate) fn sum(data: &[f64]) -> f64 {
data.iter().sum()
}
/// Weighted-moving-average seed for the first window.
///
/// Returns `(t, s)` where `t = Σ data[k] * (k + 1)` (1-based linear weights)
/// and `s = Σ data[k]`. Used to seed the O(n) WMA recurrence.
#[cfg(feature = "simd")]
#[multiversion::multiversion(targets = "simd")]
pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) {
// Lane-local accumulation (same idea as `sum`) so each CPU-feature clone
// can vectorize: `t` weights each value by its 1-based global index.
let mut t_acc = [0.0f64; LANES];
let mut s_acc = [0.0f64; LANES];
let mut chunks = data.chunks_exact(LANES);
let mut base = 0.0f64; // global index of this chunk's first element
for chunk in &mut chunks {
for (lane, ((t, s), &v)) in t_acc
.iter_mut()
.zip(s_acc.iter_mut())
.zip(chunk)
.enumerate()
{
*t += v * (base + lane as f64 + 1.0);
*s += v;
}
base += LANES as f64;
}
let mut t = 0.0;
let mut s = 0.0;
for (i, &v) in chunks.remainder().iter().enumerate() {
t += v * (base + i as f64 + 1.0);
s += v;
}
(t + t_acc.iter().sum::<f64>(), s + s_acc.iter().sum::<f64>())
}
/// Pure-scalar fallback when the `simd` feature is disabled.
#[cfg(not(feature = "simd"))]
pub(crate) fn wma_seed(data: &[f64]) -> (f64, f64) {
let mut t = 0.0;
let mut s = 0.0;
for (k, &v) in data.iter().enumerate() {
t += v * (k + 1) as f64;
s += v;
}
(t, s)
}
#[cfg(test)]
mod tests {
use super::*;
/// Strict sequential reference — the ground truth we compare against.
fn naive_sum(data: &[f64]) -> f64 {
data.iter().sum()
}
fn naive_wma_seed(data: &[f64]) -> (f64, f64) {
let t = data
.iter()
.enumerate()
.map(|(k, &v)| v * (k + 1) as f64)
.sum();
let s = data.iter().sum();
(t, s)
}
/// Deterministic test vectors spanning the lane boundaries: empty, a
/// partial chunk (< LANES), an exact multiple, and an exact-multiple +
/// remainder. This exercises every branch of the chunked reduction.
fn cases() -> Vec<Vec<f64>> {
let big: Vec<f64> = (0..1000).map(|i| (i as f64) * 0.5 - 123.0).collect();
vec![
vec![],
vec![42.0],
vec![1.0, 2.0, 3.0], // < LANES
(1..=8).map(|i| i as f64).collect(), // exactly LANES
(1..=17).map(|i| i as f64).collect(), // LANES*2 + 1
big,
]
}
#[test]
fn sum_matches_sequential_within_tolerance() {
for data in cases() {
let got = sum(&data);
let want = naive_sum(&data);
assert!(
(got - want).abs() <= 1e-9 * want.abs().max(1.0),
"sum mismatch: got {got}, want {want}, len {}",
data.len()
);
}
}
#[test]
fn wma_seed_matches_sequential_within_tolerance() {
for data in cases() {
let (t, s) = wma_seed(&data);
let (wt, ws) = naive_wma_seed(&data);
assert!(
(t - wt).abs() <= 1e-9 * wt.abs().max(1.0),
"wma t mismatch: got {t}, want {wt}, len {}",
data.len()
);
assert!(
(s - ws).abs() <= 1e-9 * ws.abs().max(1.0),
"wma s mismatch: got {s}, want {ws}, len {}",
data.len()
);
}
}
#[test]
fn sum_empty_is_zero() {
assert_eq!(sum(&[]), 0.0);
}
}
+462 -1
View File
@@ -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,122 @@ mod tests {
assert!(v.abs() < 1e-10);
}
}
#[test]
fn dtw_identical_series_is_zero() {
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(dtw_distance(&a, &a, None), 0.0);
}
#[test]
fn dtw_known_shifted_series() {
// [0,1,2] vs [1,2,3]: DTW uses squared Euclidean local cost + final sqrt.
// Optimal path (0,0)→(1,0)→(2,1)→(2,2), accumulated cost = 1+0+0+1 = 2, sqrt(2).
// Matches dtaidistance.dtw.distance([0,1,2],[1,2,3]) = 1.4142...
let a = vec![0.0, 1.0, 2.0];
let b = vec![1.0, 2.0, 3.0];
let expected = 2.0_f64.sqrt();
let result = dtw_distance(&a, &b, None);
assert!(
(result - expected).abs() < 1e-12,
"got {result}, expected {expected}"
);
}
#[test]
fn dtw_known_even_shift() {
// [0,2,4] vs [1,3,5]: diagonal path, squared costs 1+1+1=3, sqrt(3).
// Matches dtaidistance.dtw.distance([0,2,4],[1,3,5]) = 1.7320...
let a = vec![0.0, 2.0, 4.0];
let b = vec![1.0, 3.0, 5.0];
let expected = 3.0_f64.sqrt();
let result = dtw_distance(&a, &b, None);
assert!(
(result - expected).abs() < 1e-12,
"got {result}, expected {expected}"
);
}
#[test]
fn dtw_single_element() {
let a = vec![3.0];
let b = vec![7.0];
assert_eq!(dtw_distance(&a, &b, None), 4.0);
}
#[test]
fn dtw_empty_returns_nan() {
assert!(dtw_distance(&[], &[1.0, 2.0], None).is_nan());
assert!(dtw_distance(&[1.0, 2.0], &[], None).is_nan());
}
#[test]
fn dtw_path_endpoints() {
let a = vec![1.0, 2.0, 3.0, 4.0];
let b = vec![1.5, 2.5, 3.5, 4.5];
let (_, path) = dtw_path(&a, &b, None);
assert_eq!(path.first(), Some(&(0, 0)));
assert_eq!(path.last(), Some(&(3, 3)));
}
#[test]
fn dtw_path_is_monotone() {
let a = vec![1.0, 3.0, 2.0, 5.0, 4.0];
let b = vec![2.0, 1.0, 4.0, 3.0, 6.0];
let (_, path) = dtw_path(&a, &b, None);
for k in 1..path.len() {
assert!(path[k].0 >= path[k - 1].0);
assert!(path[k].1 >= path[k - 1].1);
}
}
#[test]
fn dtw_path_distance_matches_distance_only() {
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0];
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0];
let d1 = dtw_distance(&a, &b, None);
let (d2, _) = dtw_path(&a, &b, None);
assert!((d1 - d2).abs() < 1e-12);
}
#[test]
fn dtw_nan_in_input_propagates() {
// NaN in either input must propagate to the distance (IEEE 754 semantics).
let a = vec![1.0, 2.0, f64::NAN, 4.0];
let b = vec![1.0, 2.0, 3.0, 4.0];
assert!(dtw_distance(&a, &b, None).is_nan());
assert!(dtw_distance(&b, &a, None).is_nan());
}
#[test]
fn dtw_is_symmetric() {
let a = vec![1.0, 4.0, 2.0, 8.0, 3.0, 6.0, 5.0];
let b = vec![2.0, 3.0, 7.0, 4.0, 5.0, 1.0, 9.0];
let d_ab = dtw_distance(&a, &b, None);
let d_ba = dtw_distance(&b, &a, None);
assert!((d_ab - d_ba).abs() < 1e-12);
}
#[test]
fn dtw_path_length_bounded() {
// A valid warp path has length between max(n, m) and n + m - 1.
let a: Vec<f64> = (0..7).map(|x| x as f64).collect();
let b: Vec<f64> = (0..10).map(|x| (x as f64).sin()).collect();
let (_, path) = dtw_path(&a, &b, None);
let n = a.len();
let m = b.len();
assert!(path.len() >= n.max(m));
assert!(path.len() <= n + m - 1);
}
#[test]
fn dtw_window_constrained_ge_unconstrained() {
// window convention matches dtaidistance: Some(w) means |i-j| < w.
// A narrow window restricts warping, so constrained distance >= unconstrained.
let a: Vec<f64> = (0..20).map(|x| x as f64).collect();
let b: Vec<f64> = (0..20).map(|x| x as f64 + 3.0).collect();
let d_full = dtw_distance(&a, &b, None);
let d_narrow = dtw_distance(&a, &b, Some(3));
assert!(d_narrow >= d_full - 1e-12);
}
}
+946
View File
@@ -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
);
}
}
}
}
+33 -5
View File
@@ -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::*;
+95 -9
View File
@@ -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]
+13 -1
View File
@@ -53,7 +53,19 @@ skip = []
db-urls = ["https://github.com/rustsec/advisory-db"]
# Deny known security vulnerabilities
version = 2
ignore = []
ignore = [
# pyo3 0.25 advisories — fix is pyo3 >=0.29, which is a large API
# migration (IntoPy/ToPyObject were removed in 0.26). Ignored here
# because ferro-ta does NOT use either affected code path:
# * RUSTSEC-2026-0176 — OOB read in PyList/PyTuple nth/nth_back
# iterators: the crate has zero PyList/PyTuple iterator usage.
# * RUSTSEC-2026-0177 — missing Sync bound on
# PyCFunction::new_closure: the crate uses #[pyfunction], never
# new_closure.
# Tracked for removal once the pyo3 0.29 upgrade lands.
"RUSTSEC-2026-0176",
"RUSTSEC-2026-0177",
]
# ---------------------------------------------------------------------------
# Sources — only allow crates from crates.io and our own path deps
+121
View File
@@ -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
View File
@@ -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.
File diff suppressed because it is too large Load Diff
+205 -21
View File
@@ -1,20 +1,213 @@
Benchmarks
==========
The authoritative benchmark workflow is in ``benchmarks/``:
The benchmark suite is meant to support a narrow claim: ferro-ta is often
faster on selected indicators, and the evidence is published in a reproducible
form.
What is published
-----------------
The authoritative benchmark workflow lives in ``benchmarks/``:
- Cross-library speed suite: ``benchmarks/test_speed.py``
- Cross-library accuracy suite: ``benchmarks/test_accuracy.py``
- TA-Lib head-to-head speed script: ``benchmarks/bench_vs_talib.py``
- TA-Lib head-to-head script: ``benchmarks/bench_vs_talib.py``
- Backtesting engine benchmark: ``benchmarks/bench_backtest.py``
- Table generation from benchmark JSON: ``benchmarks/benchmark_table.py``
- Perf-contract artifact bundle: ``benchmarks/run_perf_contract.py``
Run the cross-library speed suite on 100,000 bars:
Backtesting engine — competitor comparison
------------------------------------------
Measured on Apple M-series, Python 3.13, Rust 1.91, using an SMA(20/50)
crossover strategy with 0.1% commission and 5 bps slippage. Median of 5 runs.
.. list-table:: Speed vs backtesting libraries (signal → equity curve)
:header-rows: 1
* - Library
- 1k bars
- 10k bars
- 100k bars
- vs ferro-ta core (100k)
* - **ferro-ta** ``backtest_core``
- 0.004 ms
- 0.033 ms
- 0.286 ms
- —
* - **ferro-ta** ``backtest_ohlcv_core``
- 0.004 ms
- 0.037 ms
- 0.332 ms
- ~same
* - NumPy vectorized (manual)
- 0.013 ms
- 0.042 ms
- 0.459 ms
- 1.6× slower
* - vectorbt 0.28
- 1.32 ms
- 1.31 ms
- 2.90 ms
- **10× slower**
* - backtesting.py
- 10.5 ms
- 42.3 ms
- 319.6 ms
- **1,117× slower**
* - backtrader 1.9
- 53.9 ms
- 518 ms
- n/a (skipped)
- **>15,000× slower**
Accuracy: ferro-ta positions and bar-returns are **bit-exact** against the NumPy
reference implementation (max per-bar equity diff = 0.00e+00 with zero
commission/slippage).
Additional ferro-ta capabilities not present in the libraries above:
.. list-table::
:header-rows: 1
* - Capability
- ferro-ta result
- NumPy baseline
- Speedup
* - Monte Carlo 1,000 sims (100k bars)
- 50 ms (parallel Rayon + LCG)
- 612 ms (Python loop)
- **12×**
* - 23 performance metrics, single call (100k bars)
- 2.8 ms
- 0.36 ms (2 metrics only)
- 0.12 ms / metric
* - Multi-asset 100 assets (100k bars)
- 43 ms parallel / 88 ms serial
- —
- 2× parallel speedup
* - Walk-forward fold indices (100k bars)
- 0.3 µs
- —
- —
Reproduce the backtest benchmark:
.. code-block:: bash
python benchmarks/bench_backtest.py --sizes 10000 100000 \
--json benchmarks/artifacts/latest/bench_backtest_results.json
Latest checked-in TA-Lib artifact
---------------------------------
The current checked-in TA-Lib comparison artifact benchmarks contiguous
``float64`` arrays at 10k and 100k bars on an ``Apple M3 Max`` with 14 logical
cores, about 38.7 GB RAM, ``CPython 3.13.5``, and ``Rust 1.91.1`` using the
default release profile (``lto = true``, ``codegen-units = 1``).
Summary from ``benchmarks/artifacts/latest/benchmark_vs_talib.json``:
.. list-table::
:header-rows: 1
* - Size
- Rows
- ferro-ta wins
- Median speedup
- TA-Lib wins or ties
* - ``10,000``
- 12
- 6
- ``1.0850x``
- ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV``
* - ``100,000``
- 12
- 6
- ``1.0784x``
- ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV``
Examples from the 100k-bar run:
.. list-table::
:header-rows: 1
* - Indicator
- ferro-ta
- TA-Lib
- Speedup
- Read
* - ``SMA``
- ``0.0985 ms``
- ``0.2241 ms``
- ``2.2751x``
- clear ferro-ta win
* - ``BBANDS``
- ``0.2122 ms``
- ``0.4966 ms``
- ``2.3402x``
- clear ferro-ta win
* - ``MACD``
- ``0.5152 ms``
- ``0.7111 ms``
- ``1.3801x``
- ferro-ta win
* - ``STOCH``
- ``1.7064 ms``
- ``0.7603 ms``
- ``0.4455x``
- TA-Lib win
* - ``ADX``
- ``0.7910 ms``
- ``0.5769 ms``
- ``0.7294x``
- TA-Lib win
* - ``ATR``
- ``0.5087 ms``
- ``0.5147 ms``
- ``1.0118x``
- tie on this machine
Methodology notes
-----------------
- The head-to-head script uses the same synthetic OHLCV generator, the same
parameters, and the same contiguous ``float64`` array layout for both
libraries.
- Reported speedup is ``TA-Lib median time / ferro-ta median time``.
- The script uses 1 warmup run and 7 measured runs per case, and now records
the full per-run timing samples, not just one selected number.
- Published JSON artifacts include machine/runtime metadata, git metadata, Rust
toolchain and build-profile metadata, per-run variance statistics, and
Python-tracked peak allocation snapshots.
- Allocation snapshots are based on ``tracemalloc`` and capture Python-tracked
allocations only; they are not full native RSS profiles.
- If your workload uses non-contiguous arrays, different dtypes, or different
batch sizes, benchmark that exact workload. Those factors can materially
change the result.
Reproduce the TA-Lib comparison
-------------------------------
.. code-block:: bash
pip install ta-lib
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
The JSON output is the main artifact to review when publishing performance
claims.
Cross-library suite
-------------------
Run the broader speed suite on 100,000 bars:
.. code-block:: bash
uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v
Selected results on a modern CPU (100,000 bars):
Selected throughput examples from the checked-in table:
.. list-table::
:header-rows: 1
@@ -38,25 +231,16 @@ Selected results on a modern CPU (100,000 bars):
* - ``STOCH``
- 33 M bars/s
Multi-size and JSON output
--------------------------
Perf-contract artifacts
-----------------------
To build the markdown comparison table from the JSON output:
Use the perf-contract runner when you want a compact, machine-readable artifact
bundle for single-series latency, batch throughput, streaming throughput, and
hotspot attribution:
.. code-block:: bash
uv run python benchmarks/benchmark_table.py
uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest
Comparison with TA-Lib
----------------------
To measure speedup vs TA-Lib on the same data and parameters, run:
.. code-block:: bash
pip install ta-lib
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
See the README “Performance vs TA-Lib” section for methodology and a
representative comparison table. The script prints a table of median times and
speedup (TA-Lib time / ferro_ta time); use ``--json out.json`` to save results.
See ``benchmarks/README.md`` for the detailed benchmark playbook and the
checked-in comparison tables.
+250 -34
View File
@@ -1,55 +1,271 @@
Changelog
=========
Release Notes
=============
1.0.0 (2026)
------------
These docs track package version ``1.2.0``.
**Candlestick Pattern Parity (61/61)**
1.1.0-audit (2026-03-28)
------------------------
- All 61 TA-Lib candlestick patterns implemented in Rust
- ``{-100, 0, 100}`` convention, consistent with TA-Lib
**Comprehensive audit: 90 findings addressed**
**Numerical Parity**
*Code quality & correctness*
- RSI, ATR/NATR, CCI, BETA, STOCH, STOCHRSI, ADX/DX/DI/DM all rewritten to match TA-Lib seeding
- Removed dependency on ``ta`` crate for these indicators
- **Welford's algorithm for BBANDS**: replaced naive ``sum_sq/N - mean^2`` variance
with numerically stable Welford's rolling algorithm in both batch and streaming BBANDS.
Fixes catastrophic cancellation for large-valued series (e.g., prices near 1e12).
- **FFI boundary safety**: ``transpose_to_series_major()`` in ``batch/mod.rs`` now
returns ``PyResult`` instead of using ``expect()``. Remaining ``as_slice().expect()``
calls in ``allow_threads`` closures are documented with SAFETY comments (structurally
infallible after C-contiguous transpose).
- **Clippy clean**: resolved all clippy warnings — complex type in ``adx_all`` extracted
to ``AdxAllResult`` type alias; ``welford_step`` helper annotated with
``#[allow(clippy::too_many_arguments)]``.
**Streaming / Incremental API**
*Performance*
- New :mod:`ferro_ta.streaming` module with bar-by-bar stateful classes
- ``StreamingSMA``, ``StreamingEMA``, ``StreamingRSI``, ``StreamingATR``, ``StreamingBBands``, ``StreamingMACD``, ``StreamingStoch``, ``StreamingVWAP``, ``StreamingSupertrend``
- **``target-cpu=native``**: new ``.cargo/config.toml`` enables native CPU instruction
set (AVX2, NEON, etc.) for all non-WASM targets. CI can override via ``RUSTFLAGS``.
**Pandas Integration**
*Testing*
- All indicators transparently accept ``pandas.Series`` and return ``Series`` with original index preserved
- Multi-output functions return tuples of ``Series``
- **Streaming unit tests**: 37 new tests in ``tests/unit/streaming/test_streaming.py``
covering ``StreamingSMA``, ``StreamingEMA``, ``StreamingRSI`` — batch parity, warmup
NaN behavior, reset, edge cases, and large dataset numerical stability.
- **Edge case tests**: 31 new tests in ``tests/unit/test_edge_cases.py`` — empty arrays,
single elements, all-NaN input, NaN propagation, extreme values (1e300, 1e-300),
constant series, period boundary conditions, OHLCV edge cases, and dtype coercion
(float32, int64).
- **Property-based tests**: expanded Hypothesis tests for EMA, BBANDS, MACD, ATR, WMA,
and OBV with algebraic invariants (upper >= middle >= lower, histogram == macd - signal,
ATR non-negative, etc.).
- **Pandas/polars integration tests**: new ``test_dataframe_integration.py`` verifying
transparent ``pd.Series`` and ``polars.Series`` support across SMA, EMA, RSI, BBANDS,
MACD, and end-to-end DataFrame workflows.
- **Fuzzing**: expanded from 2 to 9 fuzz targets — added EMA, BBANDS, MACD, ATR, STOCH,
MFI, and WMA with output invariant assertions.
- **Test helpers**: new ``tests/unit/helpers.py`` consolidating duplicated assertion
patterns (``nan_count``, ``finite``, ``assert_nan_warmup``, ``assert_output_length``,
``assert_range``, ``make_ohlcv``).
**Math Operators / Transforms**
*Documentation*
- 24 functions: arithmetic (ADD/SUB/MULT/DIV), rolling (SUM/MAX/MIN/MAXINDEX/MININDEX), element-wise math transforms
- SUM uses vectorized cumsum (220× faster than a naive loop)
- **README benchmarks**: updated to match actual artifact data — MFI 3.25x, WMA 2.20x,
BBANDS 1.97x, SMA 1.93x; corrected win count from 6 to 7 at 100k bars.
- **Rust doc comments**: added comprehensive ``///`` documentation to all public functions
in ``ferro_ta_core`` — overlap (SMA, EMA, WMA, BBANDS, MACD), momentum (RSI, STOCH,
ADX family), volatility (ATR, TRANGE), volume (OBV, MFI), statistic (STDDEV), and math
(sum, max, min, sliding_max, sliding_min).
**Documentation**
*Linting*
- Sphinx documentation setup with API reference, quickstart guide, and benchmarks page
- **Ruff clean**: fixed import sorting, unused imports, trailing whitespace, and
formatting across all Python files.
- **cargo fmt**: all Rust code formatted.
**Benchmarking Suite**
1.1.0 (2026-03-28)
------------------
- ``benchmarks/test_speed.py`` for authoritative ``pytest-benchmark`` speed runs
- ``benchmarks/bench_vs_talib.py`` for TA-Lib head-to-head comparisons
**Phase 1 — Simulation fidelity**
**Extended Indicators**
- **Bid-ask spread model**: new ``CommissionModel.spread_bps`` field (basis points).
Half-spread is deducted per leg (entry and exit), modelling real market microstructure costs.
- **Breakeven stop**: new ``backtest_ohlcv_core`` parameter ``breakeven_pct`` and
``BacktestEngine.with_breakeven_stop(pct)``. Once profit reaches ``pct``, the
effective stop-loss is moved to the entry price, guaranteeing at worst a breakeven exit.
- **Bracket order priority**: when both stop-loss and take-profit are breached on the
same bar, the level closer to the bar's open price fires first (previously SL always won).
- ``VWAP`` — cumulative or rolling window
- ``SUPERTREND`` — ATR-based trend signal
**Phase 2 — Portfolio & risk**
**Additional Extended Indicators**
- **Short borrow cost**: new ``CommissionModel.short_borrow_rate_annual`` field.
Accrued per bar for short positions at the specified annualised rate.
- **Leverage / margin modeling**: new ``BacktestEngine.with_leverage(margin_ratio, margin_call_pct)``.
Tracks margin usage and triggers a margin-call force-close when equity falls below
``margin_call_pct × initial_margin``.
- **Loss circuit breakers**: new ``BacktestEngine.with_loss_limits(daily, total)``.
Halts all trading when a per-bar loss or total drawdown threshold is breached.
- **Portfolio constraints**: new ``BacktestEngine.with_portfolio_constraints(max_asset_weight,
max_gross_exposure, max_net_exposure)`` for multi-asset backtests.
- ``ICHIMOKU`` — Ichimoku Cloud (Tenkan, Kijun, Senkou A/B, Chikou)
- ``DONCHIAN`` — Donchian Channels (upper, middle, lower)
- ``PIVOT_POINTS`` — Classic, Fibonacci, and Camarilla pivot points
**Phase 3 — Data & UX**
**Type Stubs & Packaging**
- **Bar aggregation** (``ferro_ta.analysis.resample``): ``resample_ohlcv()``, ``align_to_coarse()``,
``resample_ohlcv_labels()`` — pure-NumPy OHLCV resampling from any fine TF to any coarser TF.
- **Multi-timeframe engine** (``ferro_ta.analysis.multitf``): ``MultiTimeframeEngine`` — compute
strategy signals on coarser bars and execute on finer bars, with automatic signal alignment.
- **Dividend/split adjustment** (``ferro_ta.analysis.adjust``): ``adjust_ohlcv()``,
``adjust_for_splits()``, ``adjust_for_dividends()`` — backward-adjusted price series for
equity/index strategies.
- **Visualization** (``ferro_ta.analysis.plot``): ``plot_backtest()`` — interactive Plotly chart
with equity curve, drawdown panel, position panel, trade markers, and optional benchmark overlay.
- ``python/ferro_ta/__init__.pyi`` type stub for IDE auto-completion
- ``pyproject.toml``: added optional extras (benchmark, pandas, docs, all), project URLs, Python 3.103.13 classifiers
**Phase 4 — Differentiation**
- **Regime detection** (``ferro_ta.analysis.regime``): ``detect_volatility_regime()``,
``detect_trend_regime()``, ``detect_combined_regime()``, ``RegimeFilter`` — pure-NumPy
6-state market regime labeling and signal filtering; no external ML dependencies.
- **Portfolio optimization** (``ferro_ta.analysis.optimize``): ``PortfolioOptimizer``,
``mean_variance_optimize()``, ``risk_parity_optimize()``, ``max_sharpe_optimize()``
minimum-variance, risk-parity, and maximum-Sharpe portfolios via SLSQP (requires scipy).
- **Paper trading bridge** (``ferro_ta.analysis.live``): ``PaperTrader`` — event-driven
bar-by-bar simulator matching ``backtest_ohlcv_core`` logic exactly; supports streaming
data, live state inspection, and seamless strategy migration from backtesting to live.
1.1.0 (2026-03-27)
------------------
**Advanced commission and fee model (Indian market support)**
- New ``CommissionModel`` class (pure Rust in ``ferro_ta_core``, exposed via
PyO3 and WASM) replaces the broken flat ``commission_per_trade`` scalar. The
old code subtracted an absolute currency amount from a 1.0-normalised equity
curve — equivalent to a 2 000 % error on a ₹1 lakh account. The new model
correctly converts every charge to a fraction of ``initial_capital`` before
deducting it from the equity curve.
- ``CommissionModel`` supports: proportional brokerage (``rate_of_value``),
flat per-order fee (``flat_per_order``), per-lot fee (``per_lot``), brokerage
cap (``max_brokerage``), Securities Transaction Tax (``stt_rate`` with
configurable buy/sell sides), exchange transaction charges, SEBI regulatory
charges, 18 % GST on brokerage + exchange + regulatory levies, and stamp duty
on buy leg only.
- Built-in presets: ``CommissionModel.equity_delivery_india()``,
``CommissionModel.equity_intraday_india()``,
``CommissionModel.futures_india()``, ``CommissionModel.options_india()``,
``CommissionModel.proportional(rate)``, ``CommissionModel.zero()``.
- JSON persistence: ``model.to_json()`` / ``CommissionModel.from_json(s)``,
``model.save(path)`` / ``CommissionModel.load(path)``.
- ``BacktestEngine.with_commission_model(model)`` — pass a full
``CommissionModel``; old ``with_commission(rate)`` kept as a shim.
- New ``initial_capital`` parameter (default ₹1,00,000) on both
``backtest_core`` and ``backtest_ohlcv_core``; also exposed as
``BacktestEngine.with_initial_capital(capital)``.
**Currency system — INR default with lakh/crore formatting**
- New ``Currency`` immutable descriptor in the Python layer with constants
``INR``, ``USD``, ``EUR``, ``GBP``, ``JPY``, ``USDT``.
- ``INR`` is the default currency for ``BacktestEngine``; change via
``engine.with_currency("USD")`` or ``engine.with_currency(EUR)``.
- ``currency.format(amount)`` produces Indian lakh/crore grouping for INR
(e.g. ``₹1,23,45,678.00``) and standard Western grouping for other
currencies.
- Module-level helper ``format_currency(amount, currency=INR)``.
- ``AdvancedBacktestResult`` gains ``currency``, ``initial_capital``, and
``equity_abs`` (absolute currency equity curve) slots.
- ``summary()`` now includes ``initial_capital``, ``final_capital``,
``absolute_pnl``, and ``currency`` keys.
- ``AdvancedBacktestResult.__repr__`` shows the final capital in the correct
currency symbol (e.g. ``final=₹1,23,450.00``).
- Trade log gains a ``pnl_abs`` column (PnL in absolute currency units).
- ``to_equity_dataframe()`` now includes an ``equity_abs`` column.
**Trailing stop loss**
- ``backtest_ohlcv_core`` (and ``BacktestEngine.with_trailing_stop(pct)``)
now supports a trailing stop implemented intrabar in Rust: the high-water
mark is updated each bar; the position is exited at
``trail_high × (1 pct)`` when ``low[i]`` crosses below it (long trades),
or ``trail_low × (1 + pct)`` for short trades.
**Benchmark comparison metrics**
- ``compute_performance_metrics`` accepts an optional ``benchmark_returns``
array. When provided, ``summary()`` includes: ``benchmark_total_return``,
``benchmark_cagr``, ``benchmark_annualized_vol``, ``benchmark_sharpe``,
``alpha`` (active return), ``beta``, ``tracking_error``, and
``information_ratio``.
- ``BacktestEngine.with_benchmark(close_array)`` — pass benchmark close prices.
**Volatility-target position sizing**
- New ``"volatility_target"`` method for ``with_position_sizing()``:
``engine.with_position_sizing("volatility_target", target_vol=0.15, vol_window=20)``.
Signals are pre-scaled in Python by ``clip(target_vol / rolling_annualised_vol, 0, 3)``
before the Rust core call, keeping the hot loop unchanged.
**Backtesting engine v2 — full feature set**
- ``BacktestEngine`` now supports true two-pass Kelly / half-Kelly position
sizing: a unit-signal pass computes win statistics, then the core engine
re-runs with signals scaled by the Kelly fraction.
- Added ``fixed_fractional`` position sizing method:
``engine.with_position_sizing("fixed_fractional", fraction=0.5)``.
- New ``StreamingBacktest`` Rust class for bar-by-bar incremental backtesting
(no bulk arrays needed); exposes ``.on_bar()``, ``.summary()``, ``.reset()``.
- ``AdvancedBacktestResult.to_equity_dataframe(freq)`` — returns equity,
returns, and drawdown as a ``pd.DataFrame`` with a synthetic DatetimeIndex.
- ``AdvancedBacktestResult.summary()`` — concise dict of the 9 most commonly
cited metrics plus ``n_trades``.
**Core indicator speedup**
- ADX-family indicators (``adx_all`` public API): all six series (PDM, MDM,
+DI, -DI, DX, ADX) can now be computed from a single TR/PDM/MDM pass via
``ferro_ta.adx_all()``, eliminating the 6× redundant computation that
occurred when callers fetched each series independently.
- ``adxr`` now reuses a single ``adx_inner`` call internally (was calling
``adx()`` which re-ran the inner loop).
1.0.6 (2026-03-24)
------------------
- Added a repo-managed pre-push gate so the core Rust, Python, docs, and WASM
checks can be run locally before release.
- Expanded Rust-backed analysis/data helpers, broadened the WASM exports, and
added cross-surface API manifest verification plus Node conformance checks.
- Refreshed benchmark coverage and perf artifacts, aligned Python CI with the
local tooling flow, and updated the locked security fixes needed for a clean
release pass.
1.0.4 (2026-03-24)
------------------
- Expanded the optional MCP server from a small hand-written subset to the
broader public ferro-ta callable surface, including stateful class support
through stored-instance management tools.
- Split the root documentation so the full TA-Lib compatibility matrix lives in
``TA_LIB_COMPATIBILITY.md`` while the README stays product-first and shorter.
- Refreshed MCP docs/tests and updated locked low-risk Python dependencies as
part of the release cleanup pass.
- Stopped tracking the stray ``.coverage`` artifact and aligned ignore rules
for local coverage outputs.
1.0.3 (2026-03-24)
------------------
- Added top-level package metadata helpers such as ``ferro_ta.__version__``,
``ferro_ta.about()``, and ``ferro_ta.methods()``.
- Added a standalone derivatives benchmark artifact for selected options
pricing, IV, Greeks, and Black-76 comparisons.
- Simplified release version bumps with a single script and updated release
guidance.
- Fixed Python CI/type-stub gaps around the new metadata API and corrected the
tag-driven GitHub Release workflow trigger used for publish automation.
1.0.2 (2026-03-24)
------------------
- Improved rolling statistical kernels and several Python analysis hotspots.
- Added reproducible perf-contract artifacts, TA-Lib regression guards, and
updated benchmark tooling.
- Tightened the public benchmark documentation so claims, caveats, and evidence
live closer together.
1.0.1 (2026-03-24)
------------------
- Improved release automation for PyPI, crates.io, and npm.
- Fixed CI workflow issues that caused otherwise healthy release jobs to fail.
- Ensured the published WASM package includes its built ``pkg/`` artifacts.
1.0.0 (2026-03-23)
------------------
- First stable release of the Rust-backed Python technical analysis library.
- Shipped broad TA-Lib coverage, streaming APIs, extended indicators, and the
initial Sphinx documentation set.
- Added the benchmark suite, release playbook, and compatibility/testing
scaffolding for stable releases.
For the canonical project changelog, including the full per-version details,
see `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_.
+32 -2
View File
@@ -4,7 +4,17 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
import re
import sys
from pathlib import Path
try:
import tomllib
except ImportError: # pragma: no cover
try:
import tomli as tomllib # type: ignore[no-redef]
except ImportError: # pragma: no cover
tomllib = None # type: ignore[assignment]
# Add the python source directory so autodoc can import ferro_ta
# Only add if ferro_ta is not already installed (e.g. from a wheel in CI)
@@ -17,8 +27,28 @@ except ImportError:
project = "ferro-ta"
copyright = "2024, pratikbhadane24"
author = "pratikbhadane24"
# Version from env (e.g. set in CI from git tag) or default
release = os.environ.get("FERRO_TA_VERSION", "1.0.0")
def _default_release() -> str:
if tomllib is None:
return "0+unknown"
pyproject_toml = Path(__file__).resolve().parents[1] / "pyproject.toml"
try:
if tomllib is not None:
with pyproject_toml.open("rb") as handle:
data = tomllib.load(handle)
return data.get("project", {}).get("version", "0+unknown")
text = pyproject_toml.read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
if match:
return match.group(1)
return "0+unknown"
except Exception:
return "0+unknown"
# Version from env (e.g. set in CI from git tag) or default to pyproject.toml
release = os.environ.get("FERRO_TA_VERSION", _default_release())
version = release
# -- General configuration ----------------------------------------------------
+22
View File
@@ -26,6 +26,28 @@ Prerequisites: Rust stable toolchain, Python 3.10+, and ``maturin``.
pytest tests/
Git hooks and pre-push checks
-----------------------------
Install the repository-managed hooks after setting up the environment:
.. code-block:: bash
make hooks
Run the same push gate manually with:
.. code-block:: bash
make prepush
You can scope it to selected checks while iterating:
.. code-block:: bash
make prepush CHECKS="version changelog python_lint"
Adding a new indicator
-----------------------
+198 -42
View File
@@ -1,50 +1,187 @@
# Derivatives Analytics
`ferro-ta` now includes a Rust-backed derivatives analytics layer focused on
research, simulation, and risk analysis.
`ferro-ta` ships a Rust-backed derivatives analytics layer focused on
research, simulation, and risk analysis. All functions are implemented in
Rust core and exposed to Python (via PyO3) and WebAssembly (via wasm-bindgen).
---
## Modules
- `ferro_ta.analysis.options`
- Black-Scholes-Merton and Black-76 pricing
- Delta, gamma, vega, theta, rho
- Implied volatility inversion with guarded Newton + bisection fallback
- IV rank / percentile / z-score
- Smile metrics: ATM IV, 25-delta risk reversal, butterfly, skew slope, convexity
- Chain helpers: moneyness labels and strike selection by offset or delta
- `ferro_ta.analysis.futures`
- Synthetic forwards and parity diagnostics
- Basis, annualized basis, implied carry, carry spread
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted
- Curve analytics: calendar spreads, slope, contango summary
- `ferro_ta.analysis.options_strategy`
- Typed strategy schemas for expiry selectors, strike selectors, multi-leg presets,
risk controls, cost assumptions, and simulation limits
- `ferro_ta.analysis.derivatives_payoff`
- Multi-leg payoff aggregation
- Portfolio-level Greeks aggregation across option and futures legs
### `ferro_ta.analysis.options`
| Category | Functions |
|---|---|
| **Pricing** | `black_scholes_price`, `black_76_price`, `option_price` |
| **Greeks** | `greeks`, `extended_greeks` |
| **Implied vol** | `implied_volatility`, `iv_rank`, `iv_percentile`, `iv_zscore` |
| **Digital options** | `digital_option_price`, `digital_option_greeks` |
| **American options** | `american_option_price`, `early_exercise_premium` |
| **Smile / surface** | `smile_metrics`, `term_structure_slope`, `expected_move` |
| **Chain helpers** | `label_moneyness`, `select_strike` |
| **Realised vol** | `close_to_close_vol`, `parkinson_vol`, `garman_klass_vol`, `rogers_satchell_vol`, `yang_zhang_vol` |
| **Vol cone** | `vol_cone` |
| **Diagnostics** | `put_call_parity_deviation` |
### `ferro_ta.analysis.futures`
- Synthetic forwards and parity diagnostics
- Basis, annualized basis, implied carry, carry spread
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted
- Curve analytics: calendar spreads, slope, contango summary
### `ferro_ta.analysis.options_strategy`
Typed strategy schemas: expiry selectors, strike selectors, multi-leg presets
(`STRADDLE`, `STRANGLE`, `IRON_CONDOR`, `BULL_CALL_SPREAD`, `BEAR_PUT_SPREAD`),
risk controls, cost assumptions, and simulation limits.
### `ferro_ta.analysis.derivatives_payoff`
Multi-leg payoff and Greeks aggregation supporting **option**, **future**, and
**stock** instrument types.
| Function | Description |
|---|---|
| `option_leg_payoff` | Expiry P/L for a single option leg |
| `futures_leg_payoff` | Linear P/L for a futures leg |
| `stock_leg_payoff` | Linear P/L for a stock/equity leg |
| `strategy_payoff` | Aggregate expiry payoff across all legs |
| `strategy_value` | Pre-expiry BSM mid-price value of a multi-leg strategy |
| `aggregate_greeks` | Portfolio-level Greeks across option, futures, and stock legs |
---
## Model conventions
- `model="bsm"` expects the underlying input to be spot and `carry` to represent
a continuous dividend yield or generic carry term.
- `model="black76"` expects the underlying input to be the forward price.
- Volatility and rates use decimal units:
- `0.20` means 20% annualized volatility
- `0.05` means 5% annualized rate
- `time_to_expiry` is expressed in years.
| Parameter | Convention |
|---|---|
| `model="bsm"` | Underlying is spot; `carry` = continuous dividend yield |
| `model="black76"` | Underlying is the forward price |
| `volatility` / `rate` / `carry` | Decimal annual (e.g. `0.20` = 20 %, `0.05` = 5 %) |
| `time_to_expiry` | Years (e.g. `0.25` = 3 months) |
---
## Quick examples
### BSM pricing and Greeks
```python
from ferro_ta.analysis.options import greeks, implied_volatility, option_price
price = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call")
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
print(price, iv, g.delta)
iv = implied_volatility(price, 100.0, 100.0, 0.05, 1.0, option_type="call")
g = greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
print(price, iv, g.delta, g.gamma)
```
### Extended (second-order) Greeks
```python
from ferro_ta.analysis.options import extended_greeks
eg = extended_greeks(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
print(eg.vanna, eg.volga, eg.charm, eg.speed, eg.color)
```
### Digital options
```python
from ferro_ta.analysis.options import digital_option_price, digital_option_greeks
# Cash-or-nothing call at ATM ≈ e^{-rT} * N(d2) ≈ 0.53
price = digital_option_price(100.0, 100.0, 0.05, 1.0, 0.20,
option_type="call", digital_type="cash_or_nothing")
g = digital_option_greeks(100.0, 100.0, 0.05, 1.0, 0.20,
option_type="call", digital_type="cash_or_nothing")
print(price, g.delta, g.gamma, g.vega)
```
### American options (BAW approximation)
```python
from ferro_ta.analysis.options import american_option_price, early_exercise_premium
# American put — may have meaningful early exercise premium
american = american_option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
premium = early_exercise_premium(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
print(american, premium)
```
### Historical volatility estimators
```python
import numpy as np
from ferro_ta.analysis.options import (
close_to_close_vol, garman_klass_vol, parkinson_vol,
rogers_satchell_vol, yang_zhang_vol,
)
# Assume daily OHLC arrays of length N
open_p, high_p, low_p, close_p = ... # numpy arrays
ctc = close_to_close_vol(close_p, window=20) # close-only
park = parkinson_vol(high_p, low_p, window=20) # high-low
gk = garman_klass_vol(open_p, high_p, low_p, close_p, window=20)
rs = rogers_satchell_vol(open_p, high_p, low_p, close_p, window=20)
yz = yang_zhang_vol(open_p, high_p, low_p, close_p, window=20)
```
### Volatility cone
```python
from ferro_ta.analysis.options import vol_cone
cone = vol_cone(close_p, windows=(21, 42, 63, 126, 252))
# Overlay current IV against the cone to gauge richness/cheapness
for w, med in zip(cone.windows, cone.median):
print(f"window={int(w):3d} median_rv={med:.1%}")
```
### Put-call parity check
```python
from ferro_ta.analysis.options import option_price, put_call_parity_deviation
call = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="call")
put = option_price(100.0, 100.0, 0.05, 1.0, 0.20, option_type="put")
dev = put_call_parity_deviation(call, put, 100.0, 100.0, 0.05, 1.0)
# dev ≈ 0.0 for BSM-consistent prices; non-zero signals stale/mismatched quotes
```
### Expected move
```python
from ferro_ta.analysis.options import expected_move
lower, upper = expected_move(100.0, 0.20, days_to_expiry=30)
print(f"Expected ±1σ range: [{100+lower:.2f}, {100+upper:.2f}]")
```
### Multi-leg strategies with stock
```python
import numpy as np
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff, strategy_value
# Covered Call: long 100 shares + short 1 OTM call
spot_grid = np.linspace(80, 130, 100)
legs = [
PayoffLeg("stock", "long", entry_price=100.0),
PayoffLeg("option", "short", option_type="call",
strike=110.0, premium=3.0, volatility=0.20, time_to_expiry=0.25),
]
# Expiry P/L
payoff = strategy_payoff(spot_grid, legs=legs)
# Pre-expiry BSM value (T=3 months remaining)
value = strategy_value(spot_grid, legs=legs, time_to_expiry=0.25, volatility=0.20)
```
### Futures analytics
```python
from ferro_ta.analysis.futures import basis, curve_summary
@@ -52,19 +189,38 @@ print(basis(100.0, 103.0))
print(curve_summary(100.0, [0.1, 0.5, 1.0], [101.0, 102.0, 104.0]))
```
```python
from ferro_ta.analysis.derivatives_payoff import PayoffLeg, strategy_payoff
---
legs = [
PayoffLeg("option", "long", option_type="call", strike=100.0, premium=5.0),
PayoffLeg("future", "long", entry_price=100.0),
]
grid = [90.0, 100.0, 110.0]
print(strategy_payoff(grid, legs=legs))
```
## Instrument types in `PayoffLeg` / `StrategyLeg`
| `instrument` | Required fields | Payoff |
|---|---|---|
| `"option"` | `option_type`, `strike`, `expiry_selector`, `strike_selector` | `max(φ(SK), 0) premium` |
| `"future"` | `entry_price` | `S entry_price` |
| `"stock"` | `entry_price` | `S entry_price` (identical to future, no margin) |
---
## Volatility estimator efficiency comparison
| Estimator | Relative efficiency vs close-to-close | Handles overnight gaps |
|---|---|---|
| Close-to-close | 1× (baseline) | N/A (uses close only) |
| Parkinson | ~5× | No |
| Garman-Klass | ~7.4× | No |
| Rogers-Satchell | ~8× | No |
| Yang-Zhang | ~14× | Yes |
*Use Yang-Zhang when you have overnight gaps (futures, crypto). Use Parkinson
or Garman-Klass for continuous trading sessions.*
---
## Notes
- Existing `iv_rank`, `iv_percentile`, and `iv_zscore` names are preserved.
- The derivatives layer is analytics-only: there is no broker connectivity,
order routing, or execution workflow in this API.
- All existing function names (`iv_rank`, `iv_percentile`, `iv_zscore`, `greeks`,
`option_price`, etc.) are preserved — fully backward compatible.
- The derivatives layer is analytics-only: no broker connectivity, order routing,
or execution workflow.
- WASM: all functions in this layer are also exported as WebAssembly bindings
(see `wasm/src/lib.rs`).

Some files were not shown because too many files have changed in this diff Show More