From 13cb0dc95adff0d1a0a9ab858eccdaa64983bf1e Mon Sep 17 00:00:00 2001 From: Pratik Bhadane Date: Tue, 24 Mar 2026 11:09:48 +0530 Subject: [PATCH] chore: release v1.0.3 --- CHANGELOG.md | 26 +- Cargo.lock | 4 +- Cargo.toml | 4 +- Makefile | 7 +- README.md | 114 +- RELEASE.md | 35 +- VERSIONING.md | 29 +- api/main.py | 2 +- benchmarks/README.md | 57 +- .../latest/benchmark_derivatives_compare.json | 1162 ++++++++++++++ .../artifacts/latest/benchmark_vs_talib.json | 1388 +++++++++++++++-- benchmarks/bench_derivatives_compare.py | 1230 +++++++++++++++ benchmarks/bench_vs_talib.py | 322 ++-- benchmarks/metadata.py | 157 +- conda/meta.yaml | 11 +- crates/ferro_ta_core/Cargo.toml | 2 +- crates/ferro_ta_core/README.md | 2 +- docs/adjacent_tooling.rst | 47 + docs/benchmarks.rst | 143 +- docs/changelog.rst | 77 +- docs/conf.py | 34 +- docs/index.rst | 53 +- docs/support_matrix.rst | 117 ++ pyproject.toml | 4 +- python/ferro_ta/__init__.py | 52 +- python/ferro_ta/mcp/__init__.py | 3 +- python/ferro_ta/tools/api_info.py | 84 +- scripts/bump_version.py | 187 +++ tests/integration/test_integration.py | 19 + tests/unit/test_derivatives.py | 20 + tests/unit/test_infrastructure.py | 73 +- uv.lock | 2 +- wasm/Cargo.toml | 2 +- wasm/package.json | 2 +- 34 files changed, 5010 insertions(+), 461 deletions(-) create mode 100644 benchmarks/artifacts/latest/benchmark_derivatives_compare.json create mode 100644 benchmarks/bench_derivatives_compare.py create mode 100644 docs/adjacent_tooling.rst create mode 100644 docs/support_matrix.rst create mode 100644 scripts/bump_version.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 597a0c0..a2a88ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ and the project uses [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.0.3] — 2026-03-24 + +### Added + +- Public package metadata helpers: `ferro_ta.__version__`, `ferro_ta.about()`, + and `ferro_ta.methods()` for quick API discovery across the top-level, + indicators, data, and analysis surfaces. +- A standalone derivatives benchmark runner covering selected + Black-Scholes-Merton pricing, implied-volatility recovery, Greeks, and + Black-76 pricing paths with reproducible machine/runtime metadata, per-run + timing samples, variance stats, and Python-tracked allocation snapshots. +- A one-command version bump helper, `scripts/bump_version.py`, plus `make version + VERSION=X.Y.Z` for aligned Cargo, Python, WASM, Conda, and docs release + surfaces. + +### Changed + +- Tightened the homepage and docs product narrative so the core Rust-backed + Python TA library leads, while adjacent tooling is called out separately. +- Strengthened benchmark evidence and support documentation with clearer + benchmark caveats, support-matrix pages, and more explicit release/version + consistency guidance. + ## [1.0.2] — 2026-03-24 ### Performance @@ -293,7 +316,8 @@ 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.3...HEAD +[1.0.3]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/pratikbhadane24/ferro-ta/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/pratikbhadane24/ferro-ta/releases/tag/v1.0.0 diff --git a/Cargo.lock b/Cargo.lock index 5340f3d..9cc2fb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,7 +207,7 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "ferro_ta" -version = "1.0.2" +version = "1.0.3" dependencies = [ "criterion", "ferro_ta_core", @@ -222,7 +222,7 @@ dependencies = [ [[package]] name = "ferro_ta_core" -version = "1.0.2" +version = "1.0.3" dependencies = [ "criterion", "wide", diff --git a/Cargo.toml b/Cargo.toml index 83e017d..8e9399f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [package] name = "ferro_ta" -version = "1.0.2" +version = "1.0.3" edition = "2021" license = "MIT" publish = false @@ -23,7 +23,7 @@ ndarray = "0.16" rayon = "1.10" log = "0.4" pyo3-log = "0.12" -ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.2" } +ferro_ta_core = { path = "crates/ferro_ta_core", version = "1.0.3" } [dev-dependencies] criterion = { version = "0.8", features = ["html_reports"] } diff --git a/Makefile b/Makefile index 9fc9b27..966a9b8 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # ferro-ta development Makefile # Usage: make -.PHONY: help dev build test lint typecheck fmt docs clean bench +.PHONY: help dev build test lint typecheck fmt docs clean bench version # Default target help: @@ -15,6 +15,7 @@ help: @echo " make typecheck Run mypy + pyright type checkers" @echo " make docs Build the Sphinx documentation" @echo " make bench Run Rust criterion benchmarks (ferro_ta_core)" + @echo " make version Bump tracked version strings (set VERSION=X.Y.Z)" @echo " make audit Run cargo-audit + pip-audit" @echo " make clean Remove build artefacts" @@ -48,6 +49,10 @@ docs: bench: cargo bench -p ferro_ta_core +version: + @test -n "$(VERSION)" || (echo "Usage: make version VERSION=X.Y.Z" && exit 1) + python3 scripts/bump_version.py "$(VERSION)" + audit: cargo audit uv run --with pip-audit pip-audit diff --git a/README.md b/README.md index 4fb1680..b325ac6 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ # ⚡ ferro-ta -### The Python Technical Analysis Library That Beats TA-Lib — Everywhere +### Rust-powered Python technical analysis with a TA-Lib-compatible API -**Powered by Rust. Driven by O(n) algorithms. Designed for the speed that modern quantitative trading demands.** +**Focused on one primary job: fast, reproducible technical analysis for Python users who want TA-Lib-style ergonomics without native build friction.** [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/pratikbhadane24/ferro-ta/HEAD?labpath=examples%2Fquickstart.ipynb) [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/pratikbhadane24/ferro-ta/blob/main/examples/quickstart.ipynb) @@ -14,95 +14,79 @@ --- -> **"Same API as TA-Lib. 3–5× faster. No C compiler needed. Drop it in today."** - -ferro-ta is a **Rust-powered, PyO3-compiled** technical analysis library that replaces TA-Lib with a pure-Rust core that runs **3× to 5× faster** on every major indicator. It runs as a pre-compiled Python wheel — no C toolchain, no system dependencies, no compilation headaches. +> `ferro-ta` is a Rust-backed Python technical analysis library with a TA-Lib-compatible API for NumPy-first workloads. +> +> Performance varies by indicator, array layout, warmup, build flags, and machine. Public checked-in runs show `ferro-ta` is often faster on selected indicators, while TA-Lib still wins or ties on others. The benchmark workflow, artifacts, and caveats are published in [`benchmarks/README.md`](benchmarks/README.md). --- -## 🚀 Why ferro-ta? +## 🚀 What ferro-ta is | | TA-Lib | ferro-ta | |---|---|---| -| **Speed** | C extension, O(n×period) for STOCH/etc. | Rust + O(n) algorithms for most indicators | -| **Installation** | Requires C compiler + system libs | `pip install ferro-ta` — zero deps | -| **Platforms** | Linux-only on many CI systems | Windows / macOS (Intel + M-series) / Linux | -| **API** | `talib.SMA(close, 20)` | `ferro_ta.SMA(close, 20)` — identical | -| **Extra indicators** | — | VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, and 10 more | -| **Streaming API** | — | Bar-by-bar stateful classes | -| **GPU acceleration** | — | Optional PyTorch backend (CUDA / MPS) | -| **WebAssembly** | — | Node.js / Browser via WASM | -| **Type stubs** | — | Full `.pyi` + `py.typed` (PEP 561) | +| **Primary product** | C-backed Python TA library | Rust-backed Python TA library | +| **API shape** | `talib.SMA(close, 20)` | `ferro_ta.SMA(close, 20)` | +| **Installation** | Often requires native/system setup | Pre-built wheels on supported targets | +| **Performance claim** | Established baseline | Often faster on selected indicators; see reproducible benchmarks | +| **Scope** | Technical indicators | Technical indicators first; other tooling is optional and secondary | --- -## ⚡ Performance vs TA-Lib +## ⚡ Benchmark evidence -ferro-ta is optimized for high throughput and often competitive with TA-Lib, thanks to: -- **O(n) sliding max/min** (monotonic deque) for STOCH — was O(n×period) in TA-Lib -- **Fused TR loop** for ATR — no intermediate allocation, single pass -- **Branchless gain/loss** for RSI — `diff.max(0.0)` instead of `if/else` -- **O(n) rolling operators** for SMA/WMA/BBANDS — sliding window accumulators -- **Fused fast+slow EMA loop** for MACD — single pass for both EMAs -- **Zero-copy NumPy bridging** — input arrays read directly from buffer without copying +The latest checked-in TA-Lib comparison artifact uses contiguous `float64` +arrays at 10k and 100k bars on an `Apple M3 Max`, `CPython 3.13.5`, `Rust +1.91.1`, default release profile (`lto = true`, `codegen-units = 1`), with no +extra `RUSTFLAGS`: -### 🏆 Reproducible benchmark workflow +- `ferro-ta` is ahead outside the tie band on 6 of 12 indicators at 10k bars and 6 of 12 at 100k bars. +- At 100k bars, the stronger public wins are `SMA` (`2.28x`), `BBANDS` (`2.34x`), `MACD` (`1.38x`), `MFI` (`3.04x`), and `WMA` (`2.39x`). +- TA-Lib still wins on `STOCH` and `ADX` in the current checked-in 10k and 100k runs, and still wins or ties on `EMA`, `RSI`, `ATR`, and `OBV`. +- The published JSON now includes per-run samples, variance stats, and Python-tracked allocation snapshots, not just a single median. -We publish benchmark methodology and generated tables in [`benchmarks/README.md`](benchmarks/README.md). +The point of the benchmark suite is not to claim universal wins. It is to let readers reproduce the results, inspect the raw artifact, and see where each library is stronger. -- Cross-library speed suite (62 indicators × available libraries): `benchmarks/test_speed.py` -- Head-to-head TA-Lib comparison: `benchmarks/bench_vs_talib.py` -- Table generation from `results.json`: `benchmarks/benchmark_table.py` +### 🏆 Reproduce the public comparison + +- Methodology, artifact format, and result tables: [`benchmarks/README.md`](benchmarks/README.md) +- Latest checked-in artifact bundle: [`benchmarks/artifacts/latest/`](benchmarks/artifacts/latest/) +- TA-Lib head-to-head script: `benchmarks/bench_vs_talib.py` +- Cross-library suite: `benchmarks/test_speed.py` ```bash -# Reproduce these numbers yourself +# Reproduce the TA-Lib comparison yourself pip install ferro-ta ta-lib python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json -# or with uv: + +# or with uv uv run python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json uv run python benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json -# full cross-library speed suite (100k bars): +# full cross-library speed suite (100k bars) uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v -# generate markdown table from results: + +# generate the comparison table from results.json uv run python benchmarks/benchmark_table.py ``` --- -## 🎯 Features +## 🎯 Core capabilities -- **No C-compiler required** — pre-compiled wheels for Windows, macOS (Intel & Apple Silicon), and Linux -- **Drop-in API** compatible with TA-Lib (`SMA`, `EMA`, `RSI`, `MACD`, `BBANDS`, and 155+ more) -- **Extended Indicators** beyond TA-Lib: `VWAP`, `SUPERTREND`, `ICHIMOKU`, `DONCHIAN`, `PIVOT_POINTS`, `KELTNER_CHANNELS`, `HULL_MA`, `CHANDELIER_EXIT`, `VWMA`, `CHOPPINESS_INDEX` -- **Streaming / Live-Trading API** — bar-by-bar stateful classes (`StreamingSMA`, `StreamingRSI`, etc.) -- **NumPy integration** — accepts and returns NumPy arrays; reads input buffers without copying data -- **Pandas integration** — transparently accepts `pandas.Series` / `DataFrame` and returns `Series` with original index preserved -- **Polars integration** — transparently accepts `polars.Series` and returns `polars.Series`; install with `pip install "ferro-ta[polars]"` -- **Indicator pipeline** — compose multiple indicators into a reusable pipeline (`ferro_ta.pipeline.Pipeline`) -- **Configuration defaults** — set global parameter defaults, per-indicator overrides, and temporary scopes (`ferro_ta.config`) -- **Optional GPU backend** — pass a PyTorch tensor to `ferro_ta.gpu.sma/ema/rsi` and get a tensor back (CUDA or MPS); install with `pip install "ferro-ta[gpu]"` -- **Type stubs** (`.pyi`) + `py.typed` (PEP 561) for IDE auto-completion and `mypy`/`pyright` support -- **WebAssembly binding** — use ferro-ta in Node.js or the browser via `wasm/` (SMA, EMA, BBANDS, RSI, ATR, OBV, MACD, MOM, STOCHF) -- **Backtesting utilities** — minimal vectorized backtester (`ferro_ta.backtest`) with RSI, SMA crossover, and MACD crossover strategies; optional commission and slippage -- **Plugin registry** — register and run custom or built-in indicators by name (`ferro_ta.registry`) -- **Error model** — custom exception hierarchy (`FerroTAError`, `FerroTAValueError`, `FerroTAInputError`) with input validation helpers -- **Sphinx documentation** in `docs/` and Jupyter notebook examples in `examples/` -- **OHLCV resampling** — time-based and volume-bar resampling, multi-timeframe API (`ferro_ta.resampling`) -- **Tick aggregation** — tick/volume/time bar builders from raw trades (`ferro_ta.aggregation`) -- **Strategy DSL** — expression-based strategy evaluation (`ferro_ta.dsl`) -- **Signal composition** — weighted/rank composite scores and screening (`ferro_ta.signals`) -- **Portfolio analytics** — correlation, volatility, beta, drawdown (`ferro_ta.portfolio`) -- **Cross-asset analytics** — relative strength, spread, Z-score, rolling beta (`ferro_ta.cross_asset`) -- **Feature matrix** — multi-indicator DataFrame for ML pipelines (`ferro_ta.features`) -- **Charting API** — matplotlib and plotly charts with indicator subplots (`ferro_ta.viz`) -- **Data adapters** — pluggable adapter interface with CSV and in-memory implementations (`ferro_ta.adapters`) -- **Derivatives analytics** — IV rank/percentile/z-score, options pricing/Greeks/IV, futures basis/curve/roll, strategy schemas, and multi-leg payoff helpers (`ferro_ta.analysis.*`) -- **Agentic tools** — stable LangChain/agent tool wrappers (`ferro_ta.tools`), end-to-end workflow orchestrator (`ferro_ta.workflow`) -- **MCP server** — Model Context Protocol server for Cursor/Claude integration; run with `python -m ferro_ta.mcp` -- **Observability / Logging** — `ferro_ta.enable_debug()`, `ferro_ta.log_call()`, `ferro_ta.benchmark()` and `ferro_ta.traced()` decorator for instrumentation -- **API discovery** — `ferro_ta.indicators(category=None)` lists all 160+ indicators with metadata; `ferro_ta.info(func)` returns full parameter docs -- **Structured error codes** — every `FerroTAError` exception now carries a code (`FTERR001`–`FTERR006`) and an actionable `suggestion` hint +- **TA-Lib-style API** for 160+ indicators, including the common `SMA`, `EMA`, `RSI`, `MACD`, and `BBANDS` entry points. +- **Pre-built wheels** for supported Python and OS targets, so the common install path stays `pip install ferro-ta`. +- **NumPy-first execution** with pandas and polars adapters, plus explicit guidance on contiguous-array fast paths. +- **Batch and streaming APIs** for multi-series and bar-by-bar workloads. +- **Compatibility and support docs** covering parity status, supported wheels, supported Python versions, and experimental modules. +- **Type stubs, error model, API discovery, and examples** for day-to-day library use. + +## 🧪 Adjacent and experimental modules + +These ship in the repo, but they are not the primary product story: + +- **Adjacent analytics:** derivatives helpers, backtesting utilities, portfolio and cross-asset analysis, feature generation, and charting. +- **Experimental or optional tooling:** GPU backend, plugin system, WASM package, agent/tool wrappers, and the MCP server. +- **Docs posture:** these modules are now called out separately in the docs nav and support matrix so the core TA library remains the main narrative. --- @@ -180,7 +164,7 @@ import talib sma = talib.SMA(close, timeperiod=20) rsi = talib.RSI(close, timeperiod=14) -# After (ferro-ta — same call signature, faster result) +# After (ferro-ta — same call signature) import ferro_ta sma = ferro_ta.SMA(close, timeperiod=20) rsi = ferro_ta.RSI(close, timeperiod=14) diff --git a/RELEASE.md b/RELEASE.md index ee551ab..52e2a5d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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 diff --git a/VERSIONING.md b/VERSIONING.md index b56aa0a..2568dc2 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -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. diff --git a/api/main.py b/api/main.py index 40fec75..a39f881 100644 --- a/api/main.py +++ b/api/main.py @@ -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", ) diff --git a/benchmarks/README.md b/benchmarks/README.md index c534d7e..eb890d7 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -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 20–350× slower on ATR, CCI, ADX, MFI (O(n²) Python loops). -- **ferro-ta** is typically 2–4× faster than **pandas-ta** across indicators. +- **ferro-ta** is often materially faster than **pandas-ta** on the checked-in 100k-bar table. - **TA-Lib** and **Tulipy** (C extensions) are strong; ferro-ta is competitive and avoids native dependencies. --- @@ -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: diff --git a/benchmarks/artifacts/latest/benchmark_derivatives_compare.json b/benchmarks/artifacts/latest/benchmark_derivatives_compare.json new file mode 100644 index 0000000..4997192 --- /dev/null +++ b/benchmarks/artifacts/latest/benchmark_derivatives_compare.json @@ -0,0 +1,1162 @@ +{ + "schema_version": 1, + "command": "python benchmarks/bench_derivatives_compare.py --sizes 1000 10000 --accuracy-size 512 --json benchmarks/artifacts/latest/benchmark_derivatives_compare.json", + "n_warmup": 1, + "n_runs": 7, + "accuracy_size": 512, + "sizes": [ + 1000, + 10000 + ], + "metadata": { + "suite": "benchmark_derivatives_compare", + "runtime": { + "generated_at_utc": "2026-03-24T05:39:25.642936+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": "0382c4e3024c96809ffd89dd76d820efcd56479d", + "dirty": true, + "branch": "main" + }, + "build": { + "rustc": "rustc 1.91.1 (ed61e7d7e 2025-11-07) (Homebrew)\nbinary: rustc\ncommit-hash: ed61e7d7e242494fb7057f2657300d9e77bb4fcb\ncommit-date: 2025-11-07\nhost: aarch64-apple-darwin\nrelease: 1.91.1\nLLVM version: 21.1.5", + "cargo": "cargo 1.91.1 (Homebrew)", + "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.3", + "py_vollib": null + }, + "dataset": { + "generator": "synthetic_option_chain", + "speed_sizes": [ + 1000, + 10000 + ], + "accuracy_size": 512, + "dtype": "float64", + "array_layout": "C-contiguous", + "seed": 42, + "ranges": { + "spot": [ + 80.0, + 120.0 + ], + "strike": [ + 70.0, + 130.0 + ], + "rate": [ + 0.0, + 0.07 + ], + "carry": [ + 0.0, + 0.03 + ], + "time_to_expiry_years": [ + 0.019178082191780823, + 2.0 + ], + "volatility": [ + 0.08, + 0.65 + ] + } + }, + "methodology": { + "warmup_runs": 1, + "measured_runs": 7, + "reported_metric": "median_ms", + "speed_metric": "contracts_per_second", + "accuracy_reference": "Scalar analytical Black-Scholes-Merton and Black-76 formulas using math.erf; IV accuracy is measured as repriced error from the recovered volatility because direct volatility differences can be unstable on low-vega contracts.", + "input_layout_notes": "Benchmarks use contiguous float64 arrays. If your workload passes non-contiguous arrays or mixed dtypes, benchmark that path separately.", + "allocation_notes": "python_peak_allocation_bytes is a tracemalloc snapshot of Python-tracked allocations only; it does not measure native RSS.", + "provider_notes": "reference_python_loop and py_vollib are scalar baselines and are size-capped in the speed table to keep runtime reasonable." + }, + "providers": [ + { + "name": "ferro_ta", + "kind": "project", + "note": "Rust-backed vectorized implementation.", + "max_speed_size": null, + "supported_cases": [ + "bsm_call_price", + "bsm_call_iv", + "bsm_call_greeks", + "black76_call_price" + ] + }, + { + "name": "reference_numpy", + "kind": "reference", + "note": "Pure NumPy analytical formulas with vectorized IV bisection.", + "max_speed_size": null, + "supported_cases": [ + "bsm_call_price", + "bsm_call_iv", + "bsm_call_greeks", + "black76_call_price" + ] + }, + { + "name": "reference_python_loop", + "kind": "reference", + "note": "Scalar math-loop analytical baseline; useful for accuracy sanity checks.", + "max_speed_size": 1000, + "supported_cases": [ + "bsm_call_price", + "bsm_call_iv", + "bsm_call_greeks", + "black76_call_price" + ] + } + ] + }, + "accuracy": { + "summary": [ + { + "case": "bsm_call_price", + "best_provider": "reference_python_loop", + "best_max_abs_error": 0.0, + "worst_provider": "ferro_ta", + "worst_max_abs_error": 1.6274806e-05 + }, + { + "case": "bsm_call_iv", + "best_provider": "reference_python_loop", + "best_max_abs_error": 1e-10, + "worst_provider": "reference_numpy", + "worst_max_abs_error": 1.6276264e-05 + }, + { + "case": "bsm_call_greeks", + "best_provider": "reference_python_loop", + "best_max_abs_error": 0.0, + "worst_provider": "ferro_ta", + "worst_max_abs_error": 1.7335858e-05 + }, + { + "case": "black76_call_price", + "best_provider": "reference_python_loop", + "best_max_abs_error": 0.0, + "worst_provider": "ferro_ta", + "worst_max_abs_error": 1.6274806e-05 + } + ], + "results": [ + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 0.0, + "mean_abs_error": 0.0, + "rmse": 0.0, + "max_rel_error": 0.0 + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "reconstructed_price", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6275836e-05, + "mean_abs_error": 5.249474e-06, + "rmse": 6.489527e-06, + "max_rel_error": 0.475843909884 + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "reconstructed_price", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6276264e-05, + "mean_abs_error": 5.249487e-06, + "rmse": 6.489634e-06, + "max_rel_error": 0.009628987742 + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "reconstructed_price", + "output_shape": [ + 512 + ], + "max_abs_error": 1e-10, + "mean_abs_error": 4.5e-11, + "rmse": 5.3e-11, + "max_rel_error": 0.00547208151 + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512, + 5 + ], + "max_abs_error": 1.7335858e-05, + "mean_abs_error": 8.64081e-07, + "rmse": 2.444399e-06, + "max_rel_error": 0.002644156321, + "component_names": [ + "delta", + "gamma", + "vega", + "theta", + "rho" + ] + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512, + 5 + ], + "max_abs_error": 1.7335858e-05, + "mean_abs_error": 8.64081e-07, + "rmse": 2.444399e-06, + "max_rel_error": 0.002644156321, + "component_names": [ + "delta", + "gamma", + "vega", + "theta", + "rho" + ] + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512, + 5 + ], + "max_abs_error": 0.0, + "mean_abs_error": 0.0, + "rmse": 0.0, + "max_rel_error": 0.0, + "component_names": [ + "delta", + "gamma", + "vega", + "theta", + "rho" + ] + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "provider": "ferro_ta", + "provider_kind": "project", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "provider": "reference_numpy", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 1.6274806e-05, + "mean_abs_error": 5.249347e-06, + "rmse": 6.48942e-06, + "max_rel_error": 0.009546518154 + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "provider": "reference_python_loop", + "provider_kind": "reference", + "sample_size": 512, + "accuracy_target": "expected_output", + "output_shape": [ + 512 + ], + "max_abs_error": 0.0, + "mean_abs_error": 0.0, + "rmse": 0.0, + "max_rel_error": 0.0 + } + ] + }, + "speed": { + "summary": [ + { + "case": "bsm_call_price", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.0336, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.0336, + "contracts_per_s": 29761904.76 + }, + { + "provider": "reference_numpy", + "median_ms": 0.0456, + "contracts_per_s": 21929824.56 + }, + { + "provider": "reference_python_loop", + "median_ms": 0.8157, + "contracts_per_s": 1225940.91 + } + ] + }, + { + "case": "bsm_call_price", + "size": 10000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.2752, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.2752, + "contracts_per_s": 36337209.3 + }, + { + "provider": "reference_numpy", + "median_ms": 0.3109, + "contracts_per_s": 32164683.18 + } + ] + }, + { + "case": "bsm_call_iv", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.4121, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.4121, + "contracts_per_s": 2426595.49 + }, + { + "provider": "reference_numpy", + "median_ms": 1.8036, + "contracts_per_s": 554446.66 + }, + { + "provider": "reference_python_loop", + "median_ms": 13.4274, + "contracts_per_s": 74474.58 + } + ] + }, + { + "case": "bsm_call_iv", + "size": 10000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 4.0739, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 4.0739, + "contracts_per_s": 2454650.34 + }, + { + "provider": "reference_numpy", + "median_ms": 11.7949, + "contracts_per_s": 847824.06 + } + ] + }, + { + "case": "bsm_call_greeks", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.0497, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.0497, + "contracts_per_s": 20120724.35 + }, + { + "provider": "reference_numpy", + "median_ms": 0.0871, + "contracts_per_s": 11481056.26 + }, + { + "provider": "reference_python_loop", + "median_ms": 1.189, + "contracts_per_s": 841042.89 + } + ] + }, + { + "case": "bsm_call_greeks", + "size": 10000, + "fastest_provider": "reference_numpy", + "fastest_median_ms": 0.572, + "ranking": [ + { + "provider": "reference_numpy", + "median_ms": 0.572, + "contracts_per_s": 17482517.48 + }, + { + "provider": "ferro_ta", + "median_ms": 0.7486, + "contracts_per_s": 13358268.77 + } + ] + }, + { + "case": "black76_call_price", + "size": 1000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.0254, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.0254, + "contracts_per_s": 39370078.74 + }, + { + "provider": "reference_numpy", + "median_ms": 0.0384, + "contracts_per_s": 26041666.67 + }, + { + "provider": "reference_python_loop", + "median_ms": 0.7358, + "contracts_per_s": 1359064.96 + } + ] + }, + { + "case": "black76_call_price", + "size": 10000, + "fastest_provider": "ferro_ta", + "fastest_median_ms": 0.2184, + "ranking": [ + { + "provider": "ferro_ta", + "median_ms": 0.2184, + "contracts_per_s": 45787545.79 + }, + { + "provider": "reference_numpy", + "median_ms": 0.2823, + "contracts_per_s": 35423308.54 + } + ] + } + ], + "results": [ + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.0336, + "contracts_per_s": 29761904.76, + "runs_ms": [ + 0.0337, + 0.0404, + 0.0336, + 0.033, + 0.0332, + 0.0329, + 0.0361 + ], + "stats": { + "median_ms": 0.0336, + "mean_ms": 0.0347, + "min_ms": 0.0329, + "max_ms": 0.0404, + "stddev_ms": 0.0027, + "cv_pct": 7.901 + }, + "python_peak_allocation_bytes": 17861, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.0456, + "contracts_per_s": 21929824.56, + "runs_ms": [ + 0.0468, + 0.0456, + 0.0483, + 0.045, + 0.0548, + 0.0452, + 0.0449 + ], + "stats": { + "median_ms": 0.0456, + "mean_ms": 0.0472, + "min_ms": 0.0449, + "max_ms": 0.0548, + "stddev_ms": 0.0036, + "cv_pct": 7.526 + }, + "python_peak_allocation_bytes": 115672, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 0.8157, + "contracts_per_s": 1225940.91, + "runs_ms": [ + 0.8154, + 0.8078, + 0.8184, + 0.8265, + 0.8157, + 0.8155, + 0.8267 + ], + "stats": { + "median_ms": 0.8157, + "mean_ms": 0.818, + "min_ms": 0.8078, + "max_ms": 0.8267, + "stddev_ms": 0.0067, + "cv_pct": 0.82 + }, + "python_peak_allocation_bytes": 39464, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.2752, + "contracts_per_s": 36337209.3, + "runs_ms": [ + 0.2632, + 0.2812, + 0.2666, + 0.2666, + 0.281, + 0.2752, + 0.276 + ], + "stats": { + "median_ms": 0.2752, + "mean_ms": 0.2728, + "min_ms": 0.2632, + "max_ms": 0.2812, + "stddev_ms": 0.0073, + "cv_pct": 2.684 + }, + "python_peak_allocation_bytes": 17861, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_price", + "label": "BSM Call Price", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.3109, + "contracts_per_s": 32164683.18, + "runs_ms": [ + 0.304, + 0.315, + 0.3261, + 0.309, + 0.3218, + 0.3109, + 0.3084 + ], + "stats": { + "median_ms": 0.3109, + "mean_ms": 0.3136, + "min_ms": 0.304, + "max_ms": 0.3261, + "stddev_ms": 0.0079, + "cv_pct": 2.515 + }, + "python_peak_allocation_bytes": 1132672, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.4121, + "contracts_per_s": 2426595.49, + "runs_ms": [ + 0.4309, + 0.4121, + 0.4068, + 0.418, + 0.4082, + 0.4285, + 0.4093 + ], + "stats": { + "median_ms": 0.4121, + "mean_ms": 0.4163, + "min_ms": 0.4068, + "max_ms": 0.4309, + "stddev_ms": 0.0099, + "cv_pct": 2.378 + }, + "python_peak_allocation_bytes": 20709, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 1.8036, + "contracts_per_s": 554446.66, + "runs_ms": [ + 1.8522, + 1.8036, + 1.8552, + 1.7976, + 1.7981, + 1.8119, + 1.7963 + ], + "stats": { + "median_ms": 1.8036, + "mean_ms": 1.8164, + "min_ms": 1.7963, + "max_ms": 1.8552, + "stddev_ms": 0.026, + "cv_pct": 1.434 + }, + "python_peak_allocation_bytes": 149448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 13.4274, + "contracts_per_s": 74474.58, + "runs_ms": [ + 13.4787, + 13.4274, + 13.4138, + 13.4148, + 13.4643, + 13.6664, + 13.3763 + ], + "stats": { + "median_ms": 13.4274, + "mean_ms": 13.4631, + "min_ms": 13.3763, + "max_ms": 13.6664, + "stddev_ms": 0.0959, + "cv_pct": 0.712 + }, + "python_peak_allocation_bytes": 39584, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 4.0739, + "contracts_per_s": 2454650.34, + "runs_ms": [ + 4.0739, + 4.0792, + 3.9101, + 4.0077, + 4.2279, + 4.076, + 3.9563 + ], + "stats": { + "median_ms": 4.0739, + "mean_ms": 4.0473, + "min_ms": 3.9101, + "max_ms": 4.2279, + "stddev_ms": 0.1032, + "cv_pct": 2.549 + }, + "python_peak_allocation_bytes": 82830, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_iv", + "label": "BSM Call IV Recovery", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 11.7949, + "contracts_per_s": 847824.06, + "runs_ms": [ + 11.713, + 11.7014, + 11.8743, + 11.7949, + 11.4815, + 11.8635, + 11.8188 + ], + "stats": { + "median_ms": 11.7949, + "mean_ms": 11.7496, + "min_ms": 11.4815, + "max_ms": 11.8743, + "stddev_ms": 0.1359, + "cv_pct": 1.157 + }, + "python_peak_allocation_bytes": 1463448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.0497, + "contracts_per_s": 20120724.35, + "runs_ms": [ + 0.0558, + 0.0497, + 0.0486, + 0.0503, + 0.051, + 0.0494, + 0.0486 + ], + "stats": { + "median_ms": 0.0497, + "mean_ms": 0.0505, + "min_ms": 0.0486, + "max_ms": 0.0558, + "stddev_ms": 0.0025, + "cv_pct": 4.902 + }, + "python_peak_allocation_bytes": 41928, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.0871, + "contracts_per_s": 11481056.26, + "runs_ms": [ + 0.0883, + 0.0871, + 0.0876, + 0.0871, + 0.0868, + 0.0864, + 0.1533 + ], + "stats": { + "median_ms": 0.0871, + "mean_ms": 0.0967, + "min_ms": 0.0864, + "max_ms": 0.1533, + "stddev_ms": 0.025, + "cv_pct": 25.847 + }, + "python_peak_allocation_bytes": 138128, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 1.189, + "contracts_per_s": 841042.89, + "runs_ms": [ + 1.243, + 1.3353, + 1.189, + 1.1827, + 1.1821, + 1.1827, + 1.2165 + ], + "stats": { + "median_ms": 1.189, + "mean_ms": 1.2188, + "min_ms": 1.1821, + "max_ms": 1.3353, + "stddev_ms": 0.0563, + "cv_pct": 4.619 + }, + "python_peak_allocation_bytes": 199408, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.7486, + "contracts_per_s": 13358268.77, + "runs_ms": [ + 0.7306, + 0.7328, + 0.7688, + 0.732, + 0.7792, + 0.7486, + 0.8081 + ], + "stats": { + "median_ms": 0.7486, + "mean_ms": 0.7572, + "min_ms": 0.7306, + "max_ms": 0.8081, + "stddev_ms": 0.0295, + "cv_pct": 3.897 + }, + "python_peak_allocation_bytes": 401848, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "bsm_call_greeks", + "label": "BSM Call Greeks", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.572, + "contracts_per_s": 17482517.48, + "runs_ms": [ + 0.6104, + 0.5705, + 0.5715, + 0.5817, + 0.572, + 0.5747, + 0.5708 + ], + "stats": { + "median_ms": 0.572, + "mean_ms": 0.5788, + "min_ms": 0.5705, + "max_ms": 0.6104, + "stddev_ms": 0.0145, + "cv_pct": 2.499 + }, + "python_peak_allocation_bytes": 1362128, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 1000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.0254, + "contracts_per_s": 39370078.74, + "runs_ms": [ + 0.0286, + 0.0263, + 0.0258, + 0.0254, + 0.0253, + 0.0253, + 0.0254 + ], + "stats": { + "median_ms": 0.0254, + "mean_ms": 0.026, + "min_ms": 0.0253, + "max_ms": 0.0286, + "stddev_ms": 0.0012, + "cv_pct": 4.639 + }, + "python_peak_allocation_bytes": 14717, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 1000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.0384, + "contracts_per_s": 26041666.67, + "runs_ms": [ + 0.0395, + 0.0385, + 0.0387, + 0.0383, + 0.0384, + 0.0382, + 0.0383 + ], + "stats": { + "median_ms": 0.0384, + "mean_ms": 0.0385, + "min_ms": 0.0382, + "max_ms": 0.0395, + "stddev_ms": 0.0005, + "cv_pct": 1.22 + }, + "python_peak_allocation_bytes": 99448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 1000, + "provider": "reference_python_loop", + "provider_kind": "reference", + "median_ms": 0.7358, + "contracts_per_s": 1359064.96, + "runs_ms": [ + 0.7271, + 0.7251, + 0.845, + 0.7809, + 0.7345, + 0.7358, + 0.7418 + ], + "stats": { + "median_ms": 0.7358, + "mean_ms": 0.7558, + "min_ms": 0.7251, + "max_ms": 0.845, + "stddev_ms": 0.0436, + "cv_pct": 5.769 + }, + "python_peak_allocation_bytes": 39416, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 10000, + "provider": "ferro_ta", + "provider_kind": "project", + "median_ms": 0.2184, + "contracts_per_s": 45787545.79, + "runs_ms": [ + 0.2286, + 0.2183, + 0.219, + 0.2184, + 0.2223, + 0.218, + 0.2176 + ], + "stats": { + "median_ms": 0.2184, + "mean_ms": 0.2203, + "min_ms": 0.2176, + "max_ms": 0.2286, + "stddev_ms": 0.004, + "cv_pct": 1.8 + }, + "python_peak_allocation_bytes": 14717, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + }, + { + "case": "black76_call_price", + "label": "Black-76 Call Price", + "size": 10000, + "provider": "reference_numpy", + "provider_kind": "reference", + "median_ms": 0.2823, + "contracts_per_s": 35423308.54, + "runs_ms": [ + 0.2823, + 0.276, + 0.275, + 0.3064, + 0.3196, + 0.2835, + 0.2734 + ], + "stats": { + "median_ms": 0.2823, + "mean_ms": 0.288, + "min_ms": 0.2734, + "max_ms": 0.3196, + "stddev_ms": 0.0179, + "cv_pct": 6.201 + }, + "python_peak_allocation_bytes": 972448, + "input_layout": { + "dtype": "float64", + "contiguous": true + } + } + ] + } +} \ No newline at end of file diff --git a/benchmarks/artifacts/latest/benchmark_vs_talib.json b/benchmarks/artifacts/latest/benchmark_vs_talib.json index 116cf5e..2f1000c 100644 --- a/benchmarks/artifacts/latest/benchmark_vs_talib.json +++ b/benchmarks/artifacts/latest/benchmark_vs_talib.json @@ -1,6 +1,6 @@ { - "schema_version": 1, - "command": "python benchmarks/bench_vs_talib.py", + "schema_version": 2, + "command": "python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmarks/artifacts/latest/benchmark_vs_talib.json", "n_warmup": 1, "n_runs": 7, "sizes": [ @@ -9,14 +9,80 @@ ], "talib_available": true, "runtime": { - "generated_at_utc": "2026-03-23T20:26:40.738132+00:00", + "generated_at_utc": "2026-03-24T05:39:25.532319+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", - "machine": "arm64" + "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": "9011250f992119170242cf17a67834c67b91bcdb", - "dirty": true + "commit": "0382c4e3024c96809ffd89dd76d820efcd56479d", + "dirty": true, + "branch": "main" + }, + "metadata": { + "suite": "benchmark_vs_talib", + "runtime": { + "generated_at_utc": "2026-03-24T05:39:25.532319+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": "0382c4e3024c96809ffd89dd76d820efcd56479d", + "dirty": true, + "branch": "main" + }, + "build": { + "rustc": "rustc 1.91.1 (ed61e7d7e 2025-11-07) (Homebrew)\nbinary: rustc\ncommit-hash: ed61e7d7e242494fb7057f2657300d9e77bb4fcb\ncommit-date: 2025-11-07\nhost: aarch64-apple-darwin\nrelease: 1.91.1\nLLVM version: 21.1.5", + "cargo": "cargo 1.91.1 (Homebrew)", + "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.3", + "TA-Lib": "0.6.8" + }, + "dataset": { + "generator": "synthetic_ohlcv", + "sizes": [ + 10000, + 100000 + ], + "dtype": "float64", + "array_layout": "C-contiguous", + "seed": 42 + }, + "methodology": { + "warmup_runs": 1, + "measured_runs": 7, + "reported_metric": "median_ms", + "speedup_definition": "talib_median_ms / ferro_ta_median_ms", + "tie_band": "0.95 to 1.05", + "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." + } }, "summary": { "total_rows": 24, @@ -24,20 +90,42 @@ { "size": 10000, "rows": 12, - "wins": 8, - "win_rate": 0.6666666666666666, - "median_speedup": 1.0546, - "min_speedup": 0.7174, - "max_speedup": 2.0135 + "wins": 5, + "ties": 4, + "losses": 3, + "win_rate": 0.4167, + "non_loss_rate": 0.75, + "median_speedup": 1.0383, + "min_speedup": 0.4887, + "max_speedup": 1.9422, + "talib_wins_or_ties": [ + "EMA", + "RSI", + "ATR", + "STOCH", + "ADX", + "OBV", + "MFI" + ] }, { "size": 100000, "rows": 12, "wins": 7, - "win_rate": 0.5833333333333334, - "median_speedup": 1.0896, - "min_speedup": 0.4965, - "max_speedup": 3.6029 + "ties": 3, + "losses": 2, + "win_rate": 0.5833, + "non_loss_rate": 0.8333, + "median_speedup": 1.1748, + "min_speedup": 0.4609, + "max_speedup": 3.246, + "talib_wins_or_ties": [ + "EMA", + "ATR", + "STOCH", + "ADX", + "OBV" + ] } ] }, @@ -45,218 +133,1250 @@ { "indicator": "SMA", "size": 10000, - "ferro_ta_ms": 0.0093, - "talib_ms": 0.0167, - "speedup": 1.7937, - "ferro_ta_m_bars_s": 1076.19, - "talib_m_bars_s": 599.99 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.01, + "ferro_ta_m_bars_s": 1000.0, + "ferro_ta_runs_ms": [ + 0.0227, + 0.0112, + 0.0095, + 0.01, + 0.0095, + 0.0105, + 0.0094 + ], + "ferro_ta_stats": { + "median_ms": 0.01, + "mean_ms": 0.0118, + "min_ms": 0.0094, + "max_ms": 0.0227, + "stddev_ms": 0.0048, + "cv_pct": 40.884 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 704, + "talib": 80400 + }, + "talib_ms": 0.0168, + "talib_m_bars_s": 595.24, + "talib_runs_ms": [ + 0.0214, + 0.0176, + 0.0168, + 0.0166, + 0.0165, + 0.0165, + 0.0168 + ], + "talib_stats": { + "median_ms": 0.0168, + "mean_ms": 0.0175, + "min_ms": 0.0165, + "max_ms": 0.0214, + "stddev_ms": 0.0018, + "cv_pct": 10.191 + }, + "speedup": 1.68, + "outcome": "ferro_ta_win" }, { "indicator": "SMA", "size": 100000, - "ferro_ta_ms": 0.0765, - "talib_ms": 0.1346, - "speedup": 1.7589, - "ferro_ta_m_bars_s": 1306.49, - "talib_m_bars_s": 742.8 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0758, + "ferro_ta_m_bars_s": 1319.26, + "ferro_ta_runs_ms": [ + 0.0773, + 0.0764, + 0.0756, + 0.0765, + 0.0755, + 0.0757, + 0.0758 + ], + "ferro_ta_stats": { + "median_ms": 0.0758, + "mean_ms": 0.0761, + "min_ms": 0.0755, + "max_ms": 0.0773, + "stddev_ms": 0.0006, + "cv_pct": 0.816 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 768, + "talib": 800464 + }, + "talib_ms": 0.1466, + "talib_m_bars_s": 682.13, + "talib_runs_ms": [ + 0.1467, + 0.1465, + 0.1462, + 0.1466, + 0.1466, + 0.1467, + 0.1467 + ], + "talib_stats": { + "median_ms": 0.1466, + "mean_ms": 0.1466, + "min_ms": 0.1462, + "max_ms": 0.1467, + "stddev_ms": 0.0002, + "cv_pct": 0.118 + }, + "speedup": 1.934, + "outcome": "ferro_ta_win" }, { "indicator": "EMA", "size": 10000, - "ferro_ta_ms": 0.02, - "talib_ms": 0.0214, - "speedup": 1.0687, - "ferro_ta_m_bars_s": 500.0, - "talib_m_bars_s": 467.84 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0217, + "ferro_ta_m_bars_s": 460.83, + "ferro_ta_runs_ms": [ + 0.0228, + 0.0218, + 0.0216, + 0.0218, + 0.0216, + 0.0216, + 0.0217 + ], + "ferro_ta_stats": { + "median_ms": 0.0217, + "mean_ms": 0.0219, + "min_ms": 0.0216, + "max_ms": 0.0228, + "stddev_ms": 0.0004, + "cv_pct": 1.995 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 768, + "talib": 80464 + }, + "talib_ms": 0.0213, + "talib_m_bars_s": 469.48, + "talib_runs_ms": [ + 0.0236, + 0.0215, + 0.0213, + 0.0213, + 0.0212, + 0.0213, + 0.0207 + ], + "talib_stats": { + "median_ms": 0.0213, + "mean_ms": 0.0216, + "min_ms": 0.0207, + "max_ms": 0.0236, + "stddev_ms": 0.0009, + "cv_pct": 4.343 + }, + "speedup": 0.9816, + "outcome": "tie" }, { "indicator": "EMA", "size": 100000, - "ferro_ta_ms": 0.1998, - "talib_ms": 0.1883, - "speedup": 0.9425, - "ferro_ta_m_bars_s": 500.42, - "talib_m_bars_s": 530.97 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.2016, + "ferro_ta_m_bars_s": 496.03, + "ferro_ta_runs_ms": [ + 0.2009, + 0.201, + 0.2004, + 0.2194, + 0.2132, + 0.2016, + 0.2017 + ], + "ferro_ta_stats": { + "median_ms": 0.2016, + "mean_ms": 0.2055, + "min_ms": 0.2004, + "max_ms": 0.2194, + "stddev_ms": 0.0076, + "cv_pct": 3.719 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 768, + "talib": 800464 + }, + "talib_ms": 0.1992, + "talib_m_bars_s": 502.01, + "talib_runs_ms": [ + 0.2218, + 0.2057, + 0.1995, + 0.1987, + 0.1992, + 0.199, + 0.1991 + ], + "talib_stats": { + "median_ms": 0.1992, + "mean_ms": 0.2033, + "min_ms": 0.1987, + "max_ms": 0.2218, + "stddev_ms": 0.0085, + "cv_pct": 4.187 + }, + "speedup": 0.9881, + "outcome": "tie" }, { "indicator": "RSI", "size": 10000, - "ferro_ta_ms": 0.0475, - "talib_ms": 0.048, - "speedup": 1.0088, - "ferro_ta_m_bars_s": 210.34, - "talib_m_bars_s": 208.52 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0503, + "ferro_ta_m_bars_s": 198.81, + "ferro_ta_runs_ms": [ + 0.0661, + 0.0508, + 0.0503, + 0.0502, + 0.0496, + 0.0496, + 0.0533 + ], + "ferro_ta_stats": { + "median_ms": 0.0503, + "mean_ms": 0.0529, + "min_ms": 0.0496, + "max_ms": 0.0661, + "stddev_ms": 0.006, + "cv_pct": 11.32 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 768, + "talib": 80464 + }, + "talib_ms": 0.0519, + "talib_m_bars_s": 192.68, + "talib_runs_ms": [ + 0.0539, + 0.0566, + 0.0519, + 0.0594, + 0.0505, + 0.0494, + 0.0492 + ], + "talib_stats": { + "median_ms": 0.0519, + "mean_ms": 0.053, + "min_ms": 0.0492, + "max_ms": 0.0594, + "stddev_ms": 0.0039, + "cv_pct": 7.331 + }, + "speedup": 1.0318, + "outcome": "tie" }, { "indicator": "RSI", "size": 100000, - "ferro_ta_ms": 0.4804, - "talib_ms": 0.4635, - "speedup": 0.9647, - "ferro_ta_m_bars_s": 208.15, - "talib_m_bars_s": 215.77 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.4901, + "ferro_ta_m_bars_s": 204.04, + "ferro_ta_runs_ms": [ + 0.491, + 0.4958, + 0.486, + 0.4802, + 0.4845, + 0.4901, + 0.637 + ], + "ferro_ta_stats": { + "median_ms": 0.4901, + "mean_ms": 0.5092, + "min_ms": 0.4802, + "max_ms": 0.637, + "stddev_ms": 0.0566, + "cv_pct": 11.109 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 768, + "talib": 800464 + }, + "talib_ms": 0.5758, + "talib_m_bars_s": 173.67, + "talib_runs_ms": [ + 1.3746, + 1.1246, + 0.612, + 0.5758, + 0.5376, + 0.5296, + 0.4994 + ], + "talib_stats": { + "median_ms": 0.5758, + "mean_ms": 0.7505, + "min_ms": 0.4994, + "max_ms": 1.3746, + "stddev_ms": 0.3503, + "cv_pct": 46.676 + }, + "speedup": 1.1749, + "outcome": "ferro_ta_win" }, { "indicator": "BBANDS", "size": 10000, - "ferro_ta_ms": 0.0215, - "talib_ms": 0.0433, - "speedup": 2.0135, - "ferro_ta_m_bars_s": 465.12, - "talib_m_bars_s": 230.99 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0225, + "ferro_ta_m_bars_s": 444.44, + "ferro_ta_runs_ms": [ + 0.0335, + 0.0233, + 0.0236, + 0.0225, + 0.0222, + 0.0223, + 0.0223 + ], + "ferro_ta_stats": { + "median_ms": 0.0225, + "mean_ms": 0.0242, + "min_ms": 0.0222, + "max_ms": 0.0335, + "stddev_ms": 0.0041, + "cv_pct": 17.058 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 1056, + "talib": 240656 + }, + "talib_ms": 0.0437, + "talib_m_bars_s": 228.83, + "talib_runs_ms": [ + 0.0513, + 0.038, + 0.0346, + 0.046, + 0.034, + 0.0437, + 0.0455 + ], + "talib_stats": { + "median_ms": 0.0437, + "mean_ms": 0.0419, + "min_ms": 0.034, + "max_ms": 0.0513, + "stddev_ms": 0.0065, + "cv_pct": 15.452 + }, + "speedup": 1.9422, + "outcome": "ferro_ta_win" }, { "indicator": "BBANDS", "size": 100000, - "ferro_ta_ms": 0.1686, - "talib_ms": 0.4275, - "speedup": 2.535, - "ferro_ta_m_bars_s": 593.03, - "talib_m_bars_s": 233.94 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.2213, + "ferro_ta_m_bars_s": 451.88, + "ferro_ta_runs_ms": [ + 0.2213, + 0.1875, + 0.1854, + 0.3602, + 0.2683, + 0.4328, + 0.1858 + ], + "ferro_ta_stats": { + "median_ms": 0.2213, + "mean_ms": 0.263, + "min_ms": 0.1854, + "max_ms": 0.4328, + "stddev_ms": 0.0981, + "cv_pct": 37.295 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 1056, + "talib": 2400656 + }, + "talib_ms": 0.435, + "talib_m_bars_s": 229.89, + "talib_runs_ms": [ + 0.4471, + 0.4443, + 0.3104, + 0.4334, + 0.435, + 0.435, + 0.7457 + ], + "talib_stats": { + "median_ms": 0.435, + "mean_ms": 0.4644, + "min_ms": 0.3104, + "max_ms": 0.7457, + "stddev_ms": 0.1331, + "cv_pct": 28.655 + }, + "speedup": 1.9657, + "outcome": "ferro_ta_win" }, { "indicator": "MACD", "size": 10000, - "ferro_ta_ms": 0.0495, - "talib_ms": 0.065, - "speedup": 1.3134, - "ferro_ta_m_bars_s": 202.19, - "talib_m_bars_s": 153.95 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0484, + "ferro_ta_m_bars_s": 206.61, + "ferro_ta_runs_ms": [ + 0.0513, + 0.049, + 0.0484, + 0.0484, + 0.0484, + 0.0484, + 0.0485 + ], + "ferro_ta_stats": { + "median_ms": 0.0484, + "mean_ms": 0.0489, + "min_ms": 0.0484, + "max_ms": 0.0513, + "stddev_ms": 0.0011, + "cv_pct": 2.212 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 1056, + "talib": 240656 + }, + "talib_ms": 0.0682, + "talib_m_bars_s": 146.63, + "talib_runs_ms": [ + 0.0688, + 0.0677, + 0.0678, + 0.0675, + 0.0718, + 0.0688, + 0.0682 + ], + "talib_stats": { + "median_ms": 0.0682, + "mean_ms": 0.0687, + "min_ms": 0.0675, + "max_ms": 0.0718, + "stddev_ms": 0.0015, + "cv_pct": 2.158 + }, + "speedup": 1.4091, + "outcome": "ferro_ta_win" }, { "indicator": "MACD", "size": 100000, - "ferro_ta_ms": 0.4459, - "talib_ms": 0.6334, - "speedup": 1.4205, - "ferro_ta_m_bars_s": 224.28, - "talib_m_bars_s": 157.88 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.4458, + "ferro_ta_m_bars_s": 224.32, + "ferro_ta_runs_ms": [ + 0.4366, + 0.4381, + 0.4476, + 0.4458, + 0.4425, + 0.4682, + 0.4476 + ], + "ferro_ta_stats": { + "median_ms": 0.4458, + "mean_ms": 0.4466, + "min_ms": 0.4366, + "max_ms": 0.4682, + "stddev_ms": 0.0105, + "cv_pct": 2.344 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 1056, + "talib": 2400656 + }, + "talib_ms": 0.6455, + "talib_m_bars_s": 154.92, + "talib_runs_ms": [ + 0.6515, + 0.6455, + 0.6527, + 0.6437, + 0.6419, + 0.6406, + 0.6479 + ], + "talib_stats": { + "median_ms": 0.6455, + "mean_ms": 0.6463, + "min_ms": 0.6406, + "max_ms": 0.6527, + "stddev_ms": 0.0047, + "cv_pct": 0.722 + }, + "speedup": 1.448, + "outcome": "ferro_ta_win" }, { "indicator": "ATR", "size": 10000, - "ferro_ta_ms": 0.0483, - "talib_ms": 0.0502, - "speedup": 1.0405, - "ferro_ta_m_bars_s": 207.07, - "talib_m_bars_s": 199.0 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0513, + "ferro_ta_m_bars_s": 194.93, + "ferro_ta_runs_ms": [ + 0.0539, + 0.0521, + 0.0515, + 0.0511, + 0.051, + 0.0511, + 0.0513 + ], + "ferro_ta_stats": { + "median_ms": 0.0513, + "mean_ms": 0.0517, + "min_ms": 0.051, + "max_ms": 0.0539, + "stddev_ms": 0.001, + "cv_pct": 1.984 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 960, + "talib": 80656 + }, + "talib_ms": 0.0536, + "talib_m_bars_s": 186.57, + "talib_runs_ms": [ + 0.0547, + 0.0539, + 0.0536, + 0.0535, + 0.0535, + 0.0537, + 0.0535 + ], + "talib_stats": { + "median_ms": 0.0536, + "mean_ms": 0.0538, + "min_ms": 0.0535, + "max_ms": 0.0547, + "stddev_ms": 0.0004, + "cv_pct": 0.821 + }, + "speedup": 1.0448, + "outcome": "tie" }, { "indicator": "ATR", "size": 100000, - "ferro_ta_ms": 0.4705, - "talib_ms": 0.4788, - "speedup": 1.0174, - "ferro_ta_m_bars_s": 212.52, - "talib_m_bars_s": 208.88 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.5035, + "ferro_ta_m_bars_s": 198.61, + "ferro_ta_runs_ms": [ + 1.087, + 1.2821, + 0.6468, + 0.4903, + 0.4988, + 0.5035, + 0.5035 + ], + "ferro_ta_stats": { + "median_ms": 0.5035, + "mean_ms": 0.716, + "min_ms": 0.4903, + "max_ms": 1.2821, + "stddev_ms": 0.3295, + "cv_pct": 46.019 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 960, + "talib": 800656 + }, + "talib_ms": 0.5014, + "talib_m_bars_s": 199.44, + "talib_runs_ms": [ + 0.5028, + 0.5014, + 0.5011, + 0.5012, + 0.5015, + 0.5019, + 0.5009 + ], + "talib_stats": { + "median_ms": 0.5014, + "mean_ms": 0.5016, + "min_ms": 0.5009, + "max_ms": 0.5028, + "stddev_ms": 0.0006, + "cv_pct": 0.128 + }, + "speedup": 0.9958, + "outcome": "tie" }, { "indicator": "STOCH", "size": 10000, - "ferro_ta_ms": 0.0915, - "talib_ms": 0.066, - "speedup": 0.721, - "ferro_ta_m_bars_s": 109.24, - "talib_m_bars_s": 151.52 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.096, + "ferro_ta_m_bars_s": 104.17, + "ferro_ta_runs_ms": [ + 0.1497, + 0.1153, + 0.1002, + 0.093, + 0.096, + 0.0924, + 0.0953 + ], + "ferro_ta_stats": { + "median_ms": 0.096, + "mean_ms": 0.106, + "min_ms": 0.0924, + "max_ms": 0.1497, + "stddev_ms": 0.0208, + "cv_pct": 19.611 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 864, + "talib": 160632 + }, + "talib_ms": 0.0631, + "talib_m_bars_s": 158.48, + "talib_runs_ms": [ + 0.0937, + 0.0757, + 0.067, + 0.0631, + 0.061, + 0.06, + 0.0598 + ], + "talib_stats": { + "median_ms": 0.0631, + "mean_ms": 0.0686, + "min_ms": 0.0598, + "max_ms": 0.0937, + "stddev_ms": 0.0124, + "cv_pct": 18.057 + }, + "speedup": 0.6573, + "outcome": "talib_win" }, { "indicator": "STOCH", "size": 100000, - "ferro_ta_ms": 1.5285, - "talib_ms": 0.7589, - "speedup": 0.4965, - "ferro_ta_m_bars_s": 65.42, - "talib_m_bars_s": 131.77 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 1.7758, + "ferro_ta_m_bars_s": 56.31, + "ferro_ta_runs_ms": [ + 1.7758, + 1.6216, + 1.6815, + 1.5798, + 1.8748, + 1.9274, + 1.9022 + ], + "ferro_ta_stats": { + "median_ms": 1.7758, + "mean_ms": 1.7662, + "min_ms": 1.5798, + "max_ms": 1.9274, + "stddev_ms": 0.1409, + "cv_pct": 7.98 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 864, + "talib": 1600632 + }, + "talib_ms": 0.8185, + "talib_m_bars_s": 122.17, + "talib_runs_ms": [ + 0.8212, + 0.7786, + 0.741, + 0.8185, + 0.9159, + 0.8522, + 0.8129 + ], + "talib_stats": { + "median_ms": 0.8185, + "mean_ms": 0.8201, + "min_ms": 0.741, + "max_ms": 0.9159, + "stddev_ms": 0.0551, + "cv_pct": 6.723 + }, + "speedup": 0.4609, + "outcome": "talib_win" }, { "indicator": "ADX", "size": 10000, - "ferro_ta_ms": 0.0692, - "talib_ms": 0.0522, - "speedup": 0.7538, - "ferro_ta_m_bars_s": 144.49, - "talib_m_bars_s": 191.7 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0927, + "ferro_ta_m_bars_s": 107.87, + "ferro_ta_runs_ms": [ + 0.1071, + 0.0943, + 0.092, + 0.0919, + 0.0927, + 0.0941, + 0.0918 + ], + "ferro_ta_stats": { + "median_ms": 0.0927, + "mean_ms": 0.0948, + "min_ms": 0.0918, + "max_ms": 0.1071, + "stddev_ms": 0.0055, + "cv_pct": 5.821 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 960, + "talib": 80656 + }, + "talib_ms": 0.0453, + "talib_m_bars_s": 220.75, + "talib_runs_ms": [ + 0.0503, + 0.0453, + 0.0451, + 0.0452, + 0.045, + 0.046, + 0.0473 + ], + "talib_stats": { + "median_ms": 0.0453, + "mean_ms": 0.0463, + "min_ms": 0.045, + "max_ms": 0.0503, + "stddev_ms": 0.0019, + "cv_pct": 4.159 + }, + "speedup": 0.4887, + "outcome": "talib_win" }, { "indicator": "ADX", "size": 100000, - "ferro_ta_ms": 0.6209, - "talib_ms": 0.5731, - "speedup": 0.923, - "ferro_ta_m_bars_s": 161.05, - "talib_m_bars_s": 174.49 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.8133, + "ferro_ta_m_bars_s": 122.96, + "ferro_ta_runs_ms": [ + 0.8158, + 0.822, + 0.8091, + 0.8133, + 0.8103, + 0.8056, + 0.8565 + ], + "ferro_ta_stats": { + "median_ms": 0.8133, + "mean_ms": 0.8189, + "min_ms": 0.8056, + "max_ms": 0.8565, + "stddev_ms": 0.0174, + "cv_pct": 2.12 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 960, + "talib": 800656 + }, + "talib_ms": 0.5973, + "talib_m_bars_s": 167.42, + "talib_runs_ms": [ + 0.6249, + 0.5973, + 0.6002, + 0.6206, + 0.5958, + 0.5625, + 0.5594 + ], + "talib_stats": { + "median_ms": 0.5973, + "mean_ms": 0.5944, + "min_ms": 0.5594, + "max_ms": 0.6249, + "stddev_ms": 0.0255, + "cv_pct": 4.289 + }, + "speedup": 0.7344, + "outcome": "talib_win" }, { "indicator": "CCI", "size": 10000, - "ferro_ta_ms": 0.0731, - "talib_ms": 0.0829, - "speedup": 1.1333, - "ferro_ta_m_bars_s": 136.75, - "talib_m_bars_s": 120.66 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0733, + "ferro_ta_m_bars_s": 136.43, + "ferro_ta_runs_ms": [ + 0.0784, + 0.1066, + 0.1122, + 0.0733, + 0.0728, + 0.0728, + 0.0729 + ], + "ferro_ta_stats": { + "median_ms": 0.0733, + "mean_ms": 0.0841, + "min_ms": 0.0728, + "max_ms": 0.1122, + "stddev_ms": 0.0174, + "cv_pct": 20.73 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 960, + "talib": 80656 + }, + "talib_ms": 0.0847, + "talib_m_bars_s": 118.06, + "talib_runs_ms": [ + 0.0875, + 0.085, + 0.0846, + 0.0847, + 0.0847, + 0.0845, + 0.0845 + ], + "talib_stats": { + "median_ms": 0.0847, + "mean_ms": 0.0851, + "min_ms": 0.0845, + "max_ms": 0.0875, + "stddev_ms": 0.0011, + "cv_pct": 1.278 + }, + "speedup": 1.1555, + "outcome": "ferro_ta_win" }, { "indicator": "CCI", "size": 100000, - "ferro_ta_ms": 0.7028, - "talib_ms": 0.8164, - "speedup": 1.1617, - "ferro_ta_m_bars_s": 142.3, - "talib_m_bars_s": 122.49 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.7092, + "ferro_ta_m_bars_s": 141.0, + "ferro_ta_runs_ms": [ + 0.7092, + 0.7093, + 0.7083, + 0.7103, + 0.7097, + 0.7079, + 0.7072 + ], + "ferro_ta_stats": { + "median_ms": 0.7092, + "mean_ms": 0.7088, + "min_ms": 0.7072, + "max_ms": 0.7103, + "stddev_ms": 0.0011, + "cv_pct": 0.155 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 960, + "talib": 800656 + }, + "talib_ms": 0.8331, + "talib_m_bars_s": 120.03, + "talib_runs_ms": [ + 0.81, + 0.8331, + 0.8616, + 0.8557, + 0.8671, + 0.8245, + 0.823 + ], + "talib_stats": { + "median_ms": 0.8331, + "mean_ms": 0.8393, + "min_ms": 0.81, + "max_ms": 0.8671, + "stddev_ms": 0.0221, + "cv_pct": 2.629 + }, + "speedup": 1.1747, + "outcome": "ferro_ta_win" }, { "indicator": "OBV", "size": 10000, - "ferro_ta_ms": 0.0156, - "talib_ms": 0.0112, - "speedup": 0.7174, - "ferro_ta_m_bars_s": 640.0, - "talib_m_bars_s": 892.14 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.015, + "ferro_ta_m_bars_s": 666.67, + "ferro_ta_runs_ms": [ + 0.0298, + 0.0203, + 0.0174, + 0.015, + 0.0137, + 0.013, + 0.0127 + ], + "ferro_ta_stats": { + "median_ms": 0.015, + "mean_ms": 0.0174, + "min_ms": 0.0127, + "max_ms": 0.0298, + "stddev_ms": 0.0061, + "cv_pct": 35.135 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 624, + "talib": 80440 + }, + "talib_ms": 0.0096, + "talib_m_bars_s": 1041.67, + "talib_runs_ms": [ + 0.0238, + 0.0128, + 0.0107, + 0.0096, + 0.0092, + 0.0094, + 0.0094 + ], + "talib_stats": { + "median_ms": 0.0096, + "mean_ms": 0.0121, + "min_ms": 0.0092, + "max_ms": 0.0238, + "stddev_ms": 0.0053, + "cv_pct": 43.726 + }, + "speedup": 0.64, + "outcome": "talib_win" }, { "indicator": "OBV", "size": 100000, - "ferro_ta_ms": 0.2953, - "talib_ms": 0.2808, - "speedup": 0.9509, - "ferro_ta_m_bars_s": 338.6, - "talib_m_bars_s": 356.08 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.2921, + "ferro_ta_m_bars_s": 342.35, + "ferro_ta_runs_ms": [ + 0.3101, + 0.2992, + 0.2921, + 0.2989, + 0.2872, + 0.2857, + 0.2768 + ], + "ferro_ta_stats": { + "median_ms": 0.2921, + "mean_ms": 0.2929, + "min_ms": 0.2768, + "max_ms": 0.3101, + "stddev_ms": 0.0109, + "cv_pct": 3.729 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 624, + "talib": 800440 + }, + "talib_ms": 0.2993, + "talib_m_bars_s": 334.11, + "talib_runs_ms": [ + 0.3088, + 0.2993, + 0.2989, + 0.2908, + 0.2864, + 0.3134, + 0.3574 + ], + "talib_stats": { + "median_ms": 0.2993, + "mean_ms": 0.3079, + "min_ms": 0.2864, + "max_ms": 0.3574, + "stddev_ms": 0.0238, + "cv_pct": 7.716 + }, + "speedup": 1.0246, + "outcome": "tie" }, { "indicator": "MFI", "size": 10000, - "ferro_ta_ms": 0.0239, - "talib_ms": 0.0203, - "speedup": 0.8516, - "ferro_ta_m_bars_s": 418.85, - "talib_m_bars_s": 491.81 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0242, + "ferro_ta_m_bars_s": 413.22, + "ferro_ta_runs_ms": [ + 0.0282, + 0.025, + 0.0244, + 0.024, + 0.0242, + 0.024, + 0.0241 + ], + "ferro_ta_stats": { + "median_ms": 0.0242, + "mean_ms": 0.0248, + "min_ms": 0.024, + "max_ms": 0.0282, + "stddev_ms": 0.0015, + "cv_pct": 6.145 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 1056, + "talib": 80752 + }, + "talib_ms": 0.0242, + "talib_m_bars_s": 413.22, + "talib_runs_ms": [ + 0.1135, + 0.076, + 0.0347, + 0.0242, + 0.0207, + 0.0193, + 0.0186 + ], + "talib_stats": { + "median_ms": 0.0242, + "mean_ms": 0.0439, + "min_ms": 0.0186, + "max_ms": 0.1135, + "stddev_ms": 0.0368, + "cv_pct": 83.874 + }, + "speedup": 1.0, + "outcome": "tie" }, { "indicator": "MFI", "size": 100000, - "ferro_ta_ms": 0.1721, - "talib_ms": 0.62, - "speedup": 3.6029, - "ferro_ta_m_bars_s": 581.11, - "talib_m_bars_s": 161.29 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.1736, + "ferro_ta_m_bars_s": 576.04, + "ferro_ta_runs_ms": [ + 0.1774, + 0.175, + 0.1739, + 0.1734, + 0.1729, + 0.1736, + 0.1732 + ], + "ferro_ta_stats": { + "median_ms": 0.1736, + "mean_ms": 0.1742, + "min_ms": 0.1729, + "max_ms": 0.1774, + "stddev_ms": 0.0016, + "cv_pct": 0.89 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 1056, + "talib": 800752 + }, + "talib_ms": 0.5635, + "talib_m_bars_s": 177.46, + "talib_runs_ms": [ + 0.7288, + 0.6983, + 0.6053, + 0.5635, + 0.543, + 0.5214, + 0.4954 + ], + "talib_stats": { + "median_ms": 0.5635, + "mean_ms": 0.5937, + "min_ms": 0.4954, + "max_ms": 0.7288, + "stddev_ms": 0.0891, + "cv_pct": 15.016 + }, + "speedup": 3.246, + "outcome": "ferro_ta_win" }, { "indicator": "WMA", "size": 10000, - "ferro_ta_ms": 0.0105, - "talib_ms": 0.0203, - "speedup": 1.9363, - "ferro_ta_m_bars_s": 956.21, - "talib_m_bars_s": 493.83 + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0107, + "ferro_ta_m_bars_s": 934.58, + "ferro_ta_runs_ms": [ + 0.013, + 0.0109, + 0.0106, + 0.0108, + 0.0107, + 0.0105, + 0.0105 + ], + "ferro_ta_stats": { + "median_ms": 0.0107, + "mean_ms": 0.011, + "min_ms": 0.0105, + "max_ms": 0.013, + "stddev_ms": 0.0009, + "cv_pct": 7.884 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 768, + "talib": 80464 + }, + "talib_ms": 0.0202, + "talib_m_bars_s": 495.05, + "talib_runs_ms": [ + 0.0215, + 0.0204, + 0.0201, + 0.0202, + 0.0202, + 0.0204, + 0.0202 + ], + "talib_stats": { + "median_ms": 0.0202, + "mean_ms": 0.0204, + "min_ms": 0.0201, + "max_ms": 0.0215, + "stddev_ms": 0.0005, + "cv_pct": 2.395 + }, + "speedup": 1.8879, + "outcome": "ferro_ta_win" }, { "indicator": "WMA", "size": 100000, - "ferro_ta_ms": 0.0869, + "input_layout": { + "dtype": "float64", + "contiguous": true + }, + "ferro_ta_ms": 0.0851, + "ferro_ta_m_bars_s": 1175.09, + "ferro_ta_runs_ms": [ + 0.0853, + 0.0852, + 0.0851, + 0.0852, + 0.0851, + 0.085, + 0.0851 + ], + "ferro_ta_stats": { + "median_ms": 0.0851, + "mean_ms": 0.0851, + "min_ms": 0.085, + "max_ms": 0.0853, + "stddev_ms": 0.0001, + "cv_pct": 0.093 + }, + "python_peak_allocation_bytes": { + "ferro_ta": 768, + "talib": 800464 + }, "talib_ms": 0.1868, - "speedup": 2.1506, - "ferro_ta_m_bars_s": 1151.08, - "talib_m_bars_s": 535.24 + "talib_m_bars_s": 535.33, + "talib_runs_ms": [ + 0.1868, + 0.1868, + 0.1867, + 0.1867, + 0.1867, + 0.1869, + 0.2188 + ], + "talib_stats": { + "median_ms": 0.1868, + "mean_ms": 0.1914, + "min_ms": 0.1867, + "max_ms": 0.2188, + "stddev_ms": 0.0121, + "cv_pct": 6.319 + }, + "speedup": 2.1951, + "outcome": "ferro_ta_win" } ] } \ No newline at end of file diff --git a/benchmarks/bench_derivatives_compare.py b/benchmarks/bench_derivatives_compare.py new file mode 100644 index 0000000..5ee95d0 --- /dev/null +++ b/benchmarks/bench_derivatives_compare.py @@ -0,0 +1,1230 @@ +""" +Compare ferro_ta derivatives analytics against analytical references and +optional third-party libraries available in the current environment. + +The suite focuses on selected core workflows: + +- Black-Scholes-Merton call pricing +- implied-volatility recovery +- first-order Greeks +- Black-76 call pricing + +Outputs include: + +- speed timings with per-run samples and variance stats +- analytical accuracy metrics +- Python-tracked peak allocation snapshots +- machine, runtime, build, and package metadata +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import math +import sys +import time +import tracemalloc +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +import numpy as np + +from ferro_ta.analysis.options import ( + black_76_price as ft_black_76_price, +) +from ferro_ta.analysis.options import ( + greeks as ft_greeks, +) +from ferro_ta.analysis.options import ( + implied_volatility as ft_implied_volatility, +) +from ferro_ta.analysis.options import ( + option_price as ft_option_price, +) + +try: + from benchmarks.metadata import benchmark_metadata, package_versions +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata, package_versions + + +N_WARMUP = 1 +N_RUNS = 7 +DEFAULT_SIZES = [1_000, 10_000] +DEFAULT_ACCURACY_SIZE = 512 +DEFAULT_SEED = 42 +INV_SQRT_2PI = 1.0 / math.sqrt(2.0 * math.pi) + + +@dataclass(frozen=True) +class Case: + name: str + label: str + expected_key: str + component_names: tuple[str, ...] | None = None + accuracy_target: str = "expected_output" + + +@dataclass(frozen=True) +class Provider: + name: str + kind: str + note: str + functions: dict[str, Callable[[dict[str, np.ndarray]], np.ndarray]] + max_speed_size: int | None = None + + def supports(self, case_name: str) -> bool: + return case_name in self.functions + + +CASES = [ + Case("bsm_call_price", "BSM Call Price", "call_price"), + Case( + "bsm_call_iv", + "BSM Call IV Recovery", + "volatility", + accuracy_target="reconstructed_price", + ), + Case( + "bsm_call_greeks", + "BSM Call Greeks", + "call_greeks", + component_names=("delta", "gamma", "vega", "theta", "rho"), + ), + Case("black76_call_price", "Black-76 Call Price", "black76_call_price"), +] + + +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 _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 { + "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 _timed_runs_ms( + fn: Callable[[dict[str, np.ndarray]], np.ndarray], + chain: dict[str, np.ndarray], +) -> list[float]: + for _ in range(N_WARMUP): + fn(chain) + + samples_ms: list[float] = [] + for _ in range(N_RUNS): + t0 = time.perf_counter() + fn(chain) + samples_ms.append((time.perf_counter() - t0) * 1000.0) + return samples_ms + + +def _python_peak_bytes( + fn: Callable[[dict[str, np.ndarray]], np.ndarray], + chain: dict[str, np.ndarray], +) -> int | None: + try: + tracemalloc.start() + tracemalloc.reset_peak() + fn(chain) + _, peak = tracemalloc.get_traced_memory() + return int(peak) + except Exception: + return None + finally: + tracemalloc.stop() + + +def _throughput_contracts_s(size: int, median_ms: float) -> float: + if median_ms <= 0: + return 0.0 + return size / (median_ms / 1000.0) + + +def _normal_pdf_numpy(x: np.ndarray) -> np.ndarray: + return INV_SQRT_2PI * np.exp(-0.5 * x * x) + + +def _normal_cdf_numpy(x: np.ndarray) -> np.ndarray: + abs_x = np.abs(x) + t = 1.0 / (1.0 + 0.2316419 * abs_x) + poly = ( + (((((1.330274429 * t) - 1.821255978) * t) + 1.781477937) * t - 0.356563782) * t + + 0.319381530 + ) * t + cdf = 1.0 - _normal_pdf_numpy(abs_x) * poly + return np.where(x >= 0.0, cdf, 1.0 - cdf) + + +def _normal_cdf_scalar(x: float) -> float: + return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0))) + + +def _normal_pdf_scalar(x: float) -> float: + return INV_SQRT_2PI * math.exp(-0.5 * x * x) + + +def _bsm_price_numpy( + spot: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + volatility: np.ndarray, + *, + option_type: str, + carry: np.ndarray, +) -> np.ndarray: + sqrt_t = np.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + np.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + spot_df = np.exp(-carry * time_to_expiry) + strike_df = np.exp(-rate * time_to_expiry) + if option_type == "call": + out = spot * spot_df * _normal_cdf_numpy( + d1 + ) - strike * strike_df * _normal_cdf_numpy(d2) + else: + out = strike * strike_df * _normal_cdf_numpy( + -d2 + ) - spot * spot_df * _normal_cdf_numpy(-d1) + return np.ascontiguousarray(out, dtype=np.float64) + + +def _black76_price_numpy( + forward: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + volatility: np.ndarray, + *, + option_type: str, +) -> np.ndarray: + sqrt_t = np.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + np.log(forward / strike) + 0.5 * volatility * volatility * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + discount = np.exp(-rate * time_to_expiry) + if option_type == "call": + out = discount * ( + forward * _normal_cdf_numpy(d1) - strike * _normal_cdf_numpy(d2) + ) + else: + out = discount * ( + strike * _normal_cdf_numpy(-d2) - forward * _normal_cdf_numpy(-d1) + ) + return np.ascontiguousarray(out, dtype=np.float64) + + +def _bsm_greeks_numpy( + spot: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + volatility: np.ndarray, + *, + option_type: str, + carry: np.ndarray, +) -> np.ndarray: + sqrt_t = np.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + np.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + pdf = _normal_pdf_numpy(d1) + carry_df = np.exp(-carry * time_to_expiry) + strike_df = np.exp(-rate * time_to_expiry) + + if option_type == "call": + delta = carry_df * _normal_cdf_numpy(d1) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + - rate * strike * strike_df * _normal_cdf_numpy(d2) + + carry * spot * carry_df * _normal_cdf_numpy(d1) + ) + rho = strike * time_to_expiry * strike_df * _normal_cdf_numpy(d2) + else: + delta = carry_df * (_normal_cdf_numpy(d1) - 1.0) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + + rate * strike * strike_df * _normal_cdf_numpy(-d2) + - carry * spot * carry_df * _normal_cdf_numpy(-d1) + ) + rho = -strike * time_to_expiry * strike_df * _normal_cdf_numpy(-d2) + + gamma = carry_df * pdf / (spot * sigma_sqrt_t) + vega = spot * carry_df * pdf * sqrt_t + return np.ascontiguousarray( + np.column_stack([delta, gamma, vega, theta, rho]), + dtype=np.float64, + ) + + +def _bsm_price_scalar( + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + volatility: float, + *, + option_type: str, + carry: float, +) -> float: + sqrt_t = math.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + math.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + spot_df = math.exp(-carry * time_to_expiry) + strike_df = math.exp(-rate * time_to_expiry) + if option_type == "call": + return spot * spot_df * _normal_cdf_scalar( + d1 + ) - strike * strike_df * _normal_cdf_scalar(d2) + return strike * strike_df * _normal_cdf_scalar( + -d2 + ) - spot * spot_df * _normal_cdf_scalar(-d1) + + +def _black76_price_scalar( + forward: float, + strike: float, + rate: float, + time_to_expiry: float, + volatility: float, + *, + option_type: str, +) -> float: + sqrt_t = math.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + math.log(forward / strike) + 0.5 * volatility * volatility * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + discount = math.exp(-rate * time_to_expiry) + if option_type == "call": + return discount * ( + forward * _normal_cdf_scalar(d1) - strike * _normal_cdf_scalar(d2) + ) + return discount * ( + strike * _normal_cdf_scalar(-d2) - forward * _normal_cdf_scalar(-d1) + ) + + +def _bsm_greeks_scalar( + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + volatility: float, + *, + option_type: str, + carry: float, +) -> tuple[float, float, float, float, float]: + sqrt_t = math.sqrt(time_to_expiry) + sigma_sqrt_t = volatility * sqrt_t + d1 = ( + math.log(spot / strike) + + (rate - carry + 0.5 * volatility * volatility) * time_to_expiry + ) / sigma_sqrt_t + d2 = d1 - sigma_sqrt_t + pdf = _normal_pdf_scalar(d1) + carry_df = math.exp(-carry * time_to_expiry) + strike_df = math.exp(-rate * time_to_expiry) + + if option_type == "call": + delta = carry_df * _normal_cdf_scalar(d1) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + - rate * strike * strike_df * _normal_cdf_scalar(d2) + + carry * spot * carry_df * _normal_cdf_scalar(d1) + ) + rho = strike * time_to_expiry * strike_df * _normal_cdf_scalar(d2) + else: + delta = carry_df * (_normal_cdf_scalar(d1) - 1.0) + theta = ( + -(spot * carry_df * pdf * volatility) / (2.0 * sqrt_t) + + rate * strike * strike_df * _normal_cdf_scalar(-d2) + - carry * spot * carry_df * _normal_cdf_scalar(-d1) + ) + rho = -strike * time_to_expiry * strike_df * _normal_cdf_scalar(-d2) + + gamma = carry_df * pdf / (spot * sigma_sqrt_t) + vega = spot * carry_df * pdf * sqrt_t + return delta, gamma, vega, theta, rho + + +def _implied_vol_bisection_numpy( + price: np.ndarray, + spot: np.ndarray, + strike: np.ndarray, + rate: np.ndarray, + time_to_expiry: np.ndarray, + *, + option_type: str, + carry: np.ndarray, + lower: float = 1e-6, + upper: float = 5.0, + tolerance: float = 1e-8, + max_iterations: int = 100, +) -> np.ndarray: + lo = np.full_like(price, lower, dtype=np.float64) + hi = np.full_like(price, upper, dtype=np.float64) + mid = np.full_like(price, 0.2, dtype=np.float64) + for _ in range(max_iterations): + mid = (lo + hi) / 2.0 + estimate = _bsm_price_numpy( + spot, + strike, + rate, + time_to_expiry, + mid, + option_type=option_type, + carry=carry, + ) + too_low = estimate < price + lo = np.where(too_low, mid, lo) + hi = np.where(too_low, hi, mid) + if float(np.max(np.abs(estimate - price))) < tolerance: + break + return np.ascontiguousarray(mid, dtype=np.float64) + + +def _implied_vol_bisection_scalar( + price: float, + spot: float, + strike: float, + rate: float, + time_to_expiry: float, + *, + option_type: str, + carry: float, + lower: float = 1e-6, + upper: float = 5.0, + tolerance: float = 1e-10, + max_iterations: int = 100, +) -> float: + lo = lower + hi = upper + mid = 0.2 + for _ in range(max_iterations): + mid = (lo + hi) / 2.0 + estimate = _bsm_price_scalar( + spot, + strike, + rate, + time_to_expiry, + mid, + option_type=option_type, + carry=carry, + ) + if abs(estimate - price) < tolerance: + return mid + if estimate < price: + lo = mid + else: + hi = mid + return mid + + +def _reference_python_loop( + chain: dict[str, np.ndarray], + fn: Callable[..., float | tuple[float, ...]], + *, + include_forward: bool = False, + include_price: bool = False, +) -> np.ndarray: + rows: list[Any] = [] + for idx in range(len(chain["strike"])): + kwargs: dict[str, float | str] = { + "strike": float(chain["strike"][idx]), + "rate": float(chain["rate"][idx]), + "time_to_expiry": float(chain["time_to_expiry"][idx]), + "volatility": float(chain["volatility"][idx]), + "carry": float(chain["carry"][idx]), + } + if include_forward: + kwargs["forward"] = float(chain["forward"][idx]) + else: + kwargs["spot"] = float(chain["spot"][idx]) + if include_price: + kwargs["price"] = float(chain["call_price"][idx]) + rows.append(fn(**kwargs)) + return np.asarray(rows, dtype=np.float64) + + +def _build_chain(n: int, *, seed: int) -> dict[str, np.ndarray]: + rng = np.random.default_rng(seed) + spot = np.ascontiguousarray(rng.uniform(80.0, 120.0, size=n), dtype=np.float64) + strike = np.ascontiguousarray(rng.uniform(70.0, 130.0, size=n), dtype=np.float64) + rate = np.ascontiguousarray(rng.uniform(0.0, 0.07, size=n), dtype=np.float64) + carry = np.ascontiguousarray(rng.uniform(0.0, 0.03, size=n), dtype=np.float64) + time_to_expiry = np.ascontiguousarray( + rng.uniform(7.0 / 365.0, 2.0, size=n), dtype=np.float64 + ) + volatility = np.ascontiguousarray(rng.uniform(0.08, 0.65, size=n), dtype=np.float64) + forward = np.ascontiguousarray( + spot * np.exp((rate - carry) * time_to_expiry), + dtype=np.float64, + ) + + chain = { + "spot": spot, + "strike": strike, + "rate": rate, + "carry": carry, + "time_to_expiry": time_to_expiry, + "volatility": volatility, + "forward": forward, + } + + call_price = _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_price_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + call_greeks = _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_greeks_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + black76_call_price = _reference_python_loop( + chain, + lambda *, forward, strike, rate, time_to_expiry, volatility, carry: ( + _black76_price_scalar( + forward, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + ) + ), + include_forward=True, + ) + + chain["call_price"] = np.ascontiguousarray(call_price, dtype=np.float64) + chain["call_greeks"] = np.ascontiguousarray(call_greeks, dtype=np.float64) + chain["black76_call_price"] = np.ascontiguousarray( + black76_call_price, dtype=np.float64 + ) + return chain + + +def _ferro_ta_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + ft_option_price( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + model="bsm", + carry=chain["carry"], + ), + dtype=np.float64, + ) + + +def _ferro_ta_call_iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + ft_implied_volatility( + chain["call_price"], + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + option_type="call", + model="bsm", + carry=chain["carry"], + ), + dtype=np.float64, + ) + + +def _ferro_ta_call_greeks(chain: dict[str, np.ndarray]) -> np.ndarray: + result = ft_greeks( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + model="bsm", + carry=chain["carry"], + ) + return np.ascontiguousarray( + np.column_stack( + [ + np.asarray(result.delta, dtype=np.float64), + np.asarray(result.gamma, dtype=np.float64), + np.asarray(result.vega, dtype=np.float64), + np.asarray(result.theta, dtype=np.float64), + np.asarray(result.rho, dtype=np.float64), + ] + ), + dtype=np.float64, + ) + + +def _ferro_ta_black76_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + ft_black_76_price( + chain["forward"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + ), + dtype=np.float64, + ) + + +def _reference_numpy_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _bsm_price_numpy( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + carry=chain["carry"], + ) + + +def _reference_numpy_call_iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return _implied_vol_bisection_numpy( + chain["call_price"], + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + option_type="call", + carry=chain["carry"], + ) + + +def _reference_numpy_call_greeks(chain: dict[str, np.ndarray]) -> np.ndarray: + return _bsm_greeks_numpy( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + carry=chain["carry"], + ) + + +def _reference_numpy_black76_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _black76_price_numpy( + chain["forward"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + option_type="call", + ) + + +def _reference_python_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_price_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + + +def _reference_python_call_iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, price, spot, strike, rate, time_to_expiry, volatility, carry: ( + _implied_vol_bisection_scalar( + price, + spot, + strike, + rate, + time_to_expiry, + option_type="call", + carry=carry, + ) + ), + include_price=True, + ) + + +def _reference_python_call_greeks(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, spot, strike, rate, time_to_expiry, volatility, carry: ( + _bsm_greeks_scalar( + spot, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + carry=carry, + ) + ), + ) + + +def _reference_python_black76_call_price(chain: dict[str, np.ndarray]) -> np.ndarray: + return _reference_python_loop( + chain, + lambda *, forward, strike, rate, time_to_expiry, volatility, carry: ( + _black76_price_scalar( + forward, + strike, + rate, + time_to_expiry, + volatility, + option_type="call", + ) + ), + include_forward=True, + ) + + +def _reprice_bsm_call_from_iv( + chain: dict[str, np.ndarray], + implied_vols: np.ndarray, +) -> np.ndarray: + rows = [ + _bsm_price_scalar( + float(spot), + float(strike), + float(rate), + float(time_to_expiry), + max(float(iv), 1e-12), + option_type="call", + carry=float(carry), + ) + for spot, strike, rate, time_to_expiry, iv, carry in zip( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + np.asarray(implied_vols, dtype=np.float64), + chain["carry"], + ) + ] + return np.asarray(rows, dtype=np.float64) + + +def _py_vollib_provider() -> Provider | None: + if importlib.util.find_spec("py_vollib") is None: + return None + + from py_vollib.black_scholes_merton import black_scholes_merton as py_vollib_bsm + from py_vollib.black_scholes_merton.implied_volatility import ( + implied_volatility as py_vollib_iv, + ) + + def _price(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + [ + py_vollib_bsm( + "c", + float(s), + float(k), + float(t), + float(r), + float(vol), + float(q), + ) + for s, k, r, t, vol, q in zip( + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["volatility"], + chain["carry"], + ) + ], + dtype=np.float64, + ) + + def _iv(chain: dict[str, np.ndarray]) -> np.ndarray: + return np.asarray( + [ + py_vollib_iv( + float(price), + "c", + float(s), + float(k), + float(t), + float(r), + float(q), + ) + for price, s, k, r, t, q in zip( + chain["call_price"], + chain["spot"], + chain["strike"], + chain["rate"], + chain["time_to_expiry"], + chain["carry"], + ) + ], + dtype=np.float64, + ) + + return Provider( + name="py_vollib", + kind="third_party", + note="Scalar Black-Scholes-Merton baseline from py_vollib.", + functions={ + "bsm_call_price": _price, + "bsm_call_iv": _iv, + }, + max_speed_size=1_000, + ) + + +def available_providers() -> list[Provider]: + providers = [ + Provider( + name="ferro_ta", + kind="project", + note="Rust-backed vectorized implementation.", + functions={ + "bsm_call_price": _ferro_ta_call_price, + "bsm_call_iv": _ferro_ta_call_iv, + "bsm_call_greeks": _ferro_ta_call_greeks, + "black76_call_price": _ferro_ta_black76_call_price, + }, + ), + Provider( + name="reference_numpy", + kind="reference", + note="Pure NumPy analytical formulas with vectorized IV bisection.", + functions={ + "bsm_call_price": _reference_numpy_call_price, + "bsm_call_iv": _reference_numpy_call_iv, + "bsm_call_greeks": _reference_numpy_call_greeks, + "black76_call_price": _reference_numpy_black76_call_price, + }, + ), + Provider( + name="reference_python_loop", + kind="reference", + note="Scalar math-loop analytical baseline; useful for accuracy sanity checks.", + functions={ + "bsm_call_price": _reference_python_call_price, + "bsm_call_iv": _reference_python_call_iv, + "bsm_call_greeks": _reference_python_call_greeks, + "black76_call_price": _reference_python_black76_call_price, + }, + max_speed_size=1_000, + ), + ] + optional = _py_vollib_provider() + if optional is not None: + providers.append(optional) + return providers + + +def _accuracy_metrics(actual: np.ndarray, expected: np.ndarray) -> dict[str, Any]: + actual_arr = np.asarray(actual, dtype=np.float64) + expected_arr = np.asarray(expected, dtype=np.float64) + abs_error = np.abs(actual_arr - expected_arr) + rel_error = abs_error / np.maximum(np.abs(expected_arr), 1e-12) + return { + "output_shape": list(actual_arr.shape), + "max_abs_error": round(float(np.max(abs_error)), 12), + "mean_abs_error": round(float(np.mean(abs_error)), 12), + "rmse": round( + float(np.sqrt(np.mean(np.square(actual_arr - expected_arr)))), 12 + ), + "max_rel_error": round(float(np.max(rel_error)), 12), + } + + +def _accuracy_summary(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for case in CASES: + case_rows = [ + row for row in rows if row["case"] == case.name and "max_abs_error" in row + ] + if not case_rows: + continue + best = min(case_rows, key=lambda row: float(row["max_abs_error"])) + worst = max(case_rows, key=lambda row: float(row["max_abs_error"])) + summary.append( + { + "case": case.name, + "best_provider": best["provider"], + "best_max_abs_error": best["max_abs_error"], + "worst_provider": worst["provider"], + "worst_max_abs_error": worst["max_abs_error"], + } + ) + return summary + + +def _speed_summary(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + summary: list[dict[str, Any]] = [] + for case in CASES: + for size in sorted( + { + int(row["size"]) + for row in rows + if row["case"] == case.name and "median_ms" in row + } + ): + case_rows = [ + row + for row in rows + if row["case"] == case.name + and row.get("size") == size + and "median_ms" in row + ] + if not case_rows: + continue + fastest = min(case_rows, key=lambda row: float(row["median_ms"])) + ranking = [ + { + "provider": row["provider"], + "median_ms": row["median_ms"], + "contracts_per_s": row["contracts_per_s"], + } + for row in sorted(case_rows, key=lambda row: float(row["median_ms"])) + ] + summary.append( + { + "case": case.name, + "size": size, + "fastest_provider": fastest["provider"], + "fastest_median_ms": fastest["median_ms"], + "ranking": ranking, + } + ) + return summary + + +def _print_provider_inventory(providers: list[Provider]) -> None: + print("Providers:") + for provider in providers: + supported = ", ".join( + case.name for case in CASES if provider.supports(case.name) + ) + cap = ( + f" (speed cap {provider.max_speed_size})" if provider.max_speed_size else "" + ) + print(f" - {provider.name} [{provider.kind}] {provider.note}{cap}") + print(f" supported: {supported}") + print() + + +def _print_accuracy_table(rows: list[dict[str, Any]], accuracy_size: int) -> None: + print(f"Accuracy ({accuracy_size} contracts)") + print( + "IV accuracy is measured as price reconstruction error from the recovered IV." + ) + header = f"{'Case':<22} {'Provider':<24} {'Max abs err':<14} {'RMSE':<14} {'Max rel err':<14}" + print(header) + print("-" * len(header)) + for row in rows: + if "max_abs_error" not in row: + continue + print( + f"{row['label']:<22} {row['provider']:<24} " + f"{row['max_abs_error']:<14.6g} {row['rmse']:<14.6g} {row['max_rel_error']:<14.6g}" + ) + print() + + +def _print_speed_table(rows: list[dict[str, Any]]) -> None: + print(f"Speed (median of {N_RUNS} measured runs after {N_WARMUP} warmup)") + header = f"{'Case':<22} {'Size':<8} {'Provider':<24} {'Median ms':<12} {'Contracts/s':<14} {'Peak alloc':<12}" + print(header) + print("-" * len(header)) + for row in rows: + if "median_ms" not in row: + continue + peak = row.get("python_peak_allocation_bytes") + peak_label = "n/a" if peak is None else str(peak) + print( + f"{row['label']:<22} {row['size']:<8} {row['provider']:<24} " + f"{row['median_ms']:<12.4f} {row['contracts_per_s']:<14.2f} {peak_label:<12}" + ) + print() + + +def run_benchmark( + *, + sizes: list[int], + accuracy_size: int, + json_path: str | None, +) -> dict[str, Any]: + providers = available_providers() + speed_chains = { + size: _build_chain(size, seed=DEFAULT_SEED + size) for size in sizes + } + accuracy_chain = _build_chain(accuracy_size, seed=DEFAULT_SEED) + + accuracy_rows: list[dict[str, Any]] = [] + speed_rows: list[dict[str, Any]] = [] + + _print_provider_inventory(providers) + + for case in CASES: + for provider in providers: + if not provider.supports(case.name): + continue + fn = provider.functions[case.name] + actual = fn(accuracy_chain) + if case.name == "bsm_call_iv": + compared_actual = _reprice_bsm_call_from_iv(accuracy_chain, actual) + expected = np.asarray(accuracy_chain["call_price"], dtype=np.float64) + else: + compared_actual = actual + expected = np.asarray( + accuracy_chain[case.expected_key], dtype=np.float64 + ) + row = { + "case": case.name, + "label": case.label, + "provider": provider.name, + "provider_kind": provider.kind, + "sample_size": accuracy_size, + "accuracy_target": case.accuracy_target, + } + row.update(_accuracy_metrics(compared_actual, expected)) + if case.component_names is not None: + row["component_names"] = list(case.component_names) + accuracy_rows.append(row) + + _print_accuracy_table(accuracy_rows, accuracy_size) + + for case in CASES: + for size in sizes: + chain = speed_chains[size] + for provider in providers: + if not provider.supports(case.name): + continue + if ( + provider.max_speed_size is not None + and size > provider.max_speed_size + ): + continue + + fn = provider.functions[case.name] + samples_ms = _timed_runs_ms(fn, chain) + stats = _summary_stats(samples_ms) + median_ms = float(stats["median_ms"]) + contracts_per_s = _throughput_contracts_s(size, median_ms) + peak_bytes = _python_peak_bytes(fn, chain) + + speed_rows.append( + { + "case": case.name, + "label": case.label, + "size": size, + "provider": provider.name, + "provider_kind": provider.kind, + "median_ms": round(median_ms, 4), + "contracts_per_s": round(contracts_per_s, 2), + "runs_ms": [round(sample, 4) for sample in samples_ms], + "stats": stats, + "python_peak_allocation_bytes": peak_bytes, + "input_layout": { + "dtype": "float64", + "contiguous": True, + }, + } + ) + + _print_speed_table(speed_rows) + + package_names = ["numpy", "ferro-ta", "py_vollib"] + metadata = benchmark_metadata( + "benchmark_derivatives_compare", + extra={ + "dataset": { + "generator": "synthetic_option_chain", + "speed_sizes": sizes, + "accuracy_size": accuracy_size, + "dtype": "float64", + "array_layout": "C-contiguous", + "seed": DEFAULT_SEED, + "ranges": { + "spot": [80.0, 120.0], + "strike": [70.0, 130.0], + "rate": [0.0, 0.07], + "carry": [0.0, 0.03], + "time_to_expiry_years": [7.0 / 365.0, 2.0], + "volatility": [0.08, 0.65], + }, + }, + "methodology": { + "warmup_runs": N_WARMUP, + "measured_runs": N_RUNS, + "reported_metric": "median_ms", + "speed_metric": "contracts_per_second", + "accuracy_reference": ( + "Scalar analytical Black-Scholes-Merton and Black-76 formulas " + "using math.erf; IV accuracy is measured as repriced error " + "from the recovered volatility because direct volatility " + "differences can be unstable on low-vega contracts." + ), + "input_layout_notes": ( + "Benchmarks use contiguous float64 arrays. If your workload " + "passes non-contiguous arrays or mixed dtypes, benchmark that " + "path separately." + ), + "allocation_notes": ( + "python_peak_allocation_bytes is a tracemalloc snapshot of " + "Python-tracked allocations only; it does not measure native RSS." + ), + "provider_notes": ( + "reference_python_loop and py_vollib are scalar baselines and " + "are size-capped in the speed table to keep runtime reasonable." + ), + }, + "providers": [ + { + "name": provider.name, + "kind": provider.kind, + "note": provider.note, + "max_speed_size": provider.max_speed_size, + "supported_cases": [ + case.name for case in CASES if provider.supports(case.name) + ], + } + for provider in providers + ], + "packages": package_versions(*package_names), + }, + ) + + result = { + "schema_version": 1, + "command": " ".join(["python", *sys.argv]), + "n_warmup": N_WARMUP, + "n_runs": N_RUNS, + "accuracy_size": accuracy_size, + "sizes": sizes, + "metadata": metadata, + "accuracy": { + "summary": _accuracy_summary(accuracy_rows), + "results": accuracy_rows, + }, + "speed": { + "summary": _speed_summary(speed_rows), + "results": speed_rows, + }, + } + + if json_path: + output_path = Path(json_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(result, indent=2), encoding="utf-8") + print(f"Results written to {output_path}") + + return result + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Compare ferro_ta derivatives analytics against reference implementations" + ) + parser.add_argument( + "--json", + default=None, + help="Write the benchmark artifact to JSON", + ) + parser.add_argument( + "--sizes", + type=int, + nargs="+", + default=DEFAULT_SIZES, + help="Contract counts to benchmark (default: 1000 10000)", + ) + parser.add_argument( + "--accuracy-size", + type=int, + default=DEFAULT_ACCURACY_SIZE, + help="Contract count used for the accuracy pass (default: 512)", + ) + args = parser.parse_args() + run_benchmark( + sizes=args.sizes, + accuracy_size=args.accuracy_size, + json_path=args.json, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/bench_vs_talib.py b/benchmarks/bench_vs_talib.py index 72e361c..18e3738 100644 --- a/benchmarks/bench_vs_talib.py +++ b/benchmarks/bench_vs_talib.py @@ -1,37 +1,34 @@ """ ferro_ta vs TA-Lib speed comparison. -Measures throughput (M bars/s) for both libraries on the same data and parameters, -and reports speedup (talib_time / ferro_ta_time; > 1 means ferro_ta is faster). +Measures throughput (M bars/s) for both libraries on the same synthetic data +and parameters. The output is intentionally evidence-heavy: -Requirements: - pip install ta-lib # or conda install ta-lib +- median timings +- per-run timing samples +- variability stats +- Python-tracked peak allocation snapshots +- machine, runtime, and build metadata -Run: - python benchmarks/bench_vs_talib.py - python benchmarks/bench_vs_talib.py --json results.json - python benchmarks/bench_vs_talib.py --sizes 10000 100000 # default: 10k, 100k, 1M - -If ta-lib is not installed, the script still runs and reports ferro_ta timings only (no speedup). -Methodology: same synthetic data, same parameters, median of 7 runs after warmup. -Environment: document Python version and OS when publishing results. +This is meant to support a narrow claim: ferro-ta is often faster on selected +indicators, not universally faster. """ from __future__ import annotations import argparse -from datetime import datetime, timezone import json -import platform -import subprocess +import math import sys import time +import tracemalloc from typing import Any import numpy as np try: import talib # noqa: F401 + TALIB_AVAILABLE = True except ImportError: TALIB_AVAILABLE = False @@ -39,6 +36,11 @@ except ImportError: import ferro_ta +try: + from benchmarks.metadata import benchmark_metadata, package_versions +except ModuleNotFoundError: # pragma: no cover - script execution fallback + from metadata import benchmark_metadata, package_versions + # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- @@ -46,100 +48,138 @@ import ferro_ta N_WARMUP = 1 N_RUNS = 7 DEFAULT_SIZES = [10_000, 100_000, 1_000_000] +TIE_EPSILON = 0.05 _rng = np.random.default_rng(42) -def _git_info() -> dict[str, Any]: - """Best-effort git metadata for benchmark reproducibility.""" - try: - commit = subprocess.check_output( - ["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL - ).strip() - except Exception: - commit = None - - try: - dirty = bool( - subprocess.check_output( - ["git", "status", "--porcelain"], - text=True, - stderr=subprocess.DEVNULL, - ).strip() - ) - except Exception: - dirty = None - - return {"commit": commit, "dirty": dirty} +def _median(values: list[float]) -> float: + ordered = sorted(values) + mid = len(ordered) // 2 + if len(ordered) % 2: + return ordered[mid] + return (ordered[mid - 1] + ordered[mid]) / 2.0 -def _runtime_info() -> dict[str, Any]: +def _summary_stats(samples_ms: list[float]) -> dict[str, float]: + if not samples_ms: + return { + "median_ms": 0.0, + "mean_ms": 0.0, + "min_ms": 0.0, + "max_ms": 0.0, + "stddev_ms": 0.0, + "cv_pct": 0.0, + } + + mean_ms = sum(samples_ms) / len(samples_ms) + variance = ( + sum((sample - mean_ms) ** 2 for sample in samples_ms) / (len(samples_ms) - 1) + if len(samples_ms) > 1 + else 0.0 + ) + stddev_ms = math.sqrt(variance) + cv_pct = (stddev_ms / mean_ms * 100.0) if mean_ms else 0.0 return { - "generated_at_utc": datetime.now(timezone.utc).isoformat(), - "python_version": sys.version.split()[0], - "platform": platform.platform(), - "machine": platform.machine(), + "median_ms": round(_median(samples_ms), 4), + "mean_ms": round(mean_ms, 4), + "min_ms": round(min(samples_ms), 4), + "max_ms": round(max(samples_ms), 4), + "stddev_ms": round(stddev_ms, 4), + "cv_pct": round(cv_pct, 3), } +def _outcome(speedup: float) -> str: + if speedup > 1.0 + TIE_EPSILON: + return "ferro_ta_win" + if speedup < 1.0 - TIE_EPSILON: + return "talib_win" + return "tie" + + def _summary_for_size(results: list[dict[str, Any]], size: int) -> dict[str, Any]: - rows = [r for r in results if r.get("size") == size and "speedup" in r] + rows = [row for row in results if row.get("size") == size and "speedup" in row] if not rows: return {"size": size, "rows": 0} - speedups = [float(r["speedup"]) for r in rows] - wins = sum(1 for s in speedups if s > 1.0) - speedups_sorted = sorted(speedups) - mid = len(speedups_sorted) // 2 - if len(speedups_sorted) % 2: - median = speedups_sorted[mid] - else: - median = (speedups_sorted[mid - 1] + speedups_sorted[mid]) / 2.0 - + speedups = [float(row["speedup"]) for row in rows] + wins = sum(1 for row in rows if row.get("outcome") == "ferro_ta_win") + ties = sum(1 for row in rows if row.get("outcome") == "tie") + losses = sum(1 for row in rows if row.get("outcome") == "talib_win") return { "size": size, "rows": len(rows), "wins": wins, - "win_rate": wins / len(rows), - "median_speedup": round(median, 4), + "ties": ties, + "losses": losses, + "win_rate": round(wins / len(rows), 4), + "non_loss_rate": round((wins + ties) / len(rows), 4), + "median_speedup": round(_median(speedups), 4), "min_speedup": round(min(speedups), 4), "max_speedup": round(max(speedups), 4), + "talib_wins_or_ties": [ + row["indicator"] + for row in rows + if row.get("outcome") in {"talib_win", "tie"} + ], } -def _synthetic_ohlcv(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - # Generate OHLCV so that ta crate DataItem constraints hold: low >= 0, volume >= 0, - # and low <= open, close <= high, high >= open (see ta DataItemBuilder::build). +def _synthetic_ohlcv( + n: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + # Generate OHLCV so that ta crate DataItem constraints hold: low >= 0, + # volume >= 0, and low <= open, close <= high, high >= open. close = 100.0 + np.cumsum(_rng.standard_normal(n) * 0.5) open_ = close + _rng.standard_normal(n) * 0.2 high = np.maximum(open_, close) + np.abs(_rng.standard_normal(n) * 0.3) low = np.minimum(open_, close) - np.abs(_rng.standard_normal(n) * 0.3) - # Enforce high >= low and low >= 0 (ta requires non-negative prices) high = np.maximum(high, low) low = np.maximum(low, 0.0) - high = np.maximum(high, low) # again after clamping low + high = np.maximum(high, low) open_ = np.clip(open_, low, high) close = np.clip(close, low, high) volume = np.abs(_rng.standard_normal(n) * 1_000_000) + 500_000 return open_, high, low, close, volume -def _median_time_ms(fn, *args, **kwargs) -> float: +def _timed_runs_ms(fn, *args, **kwargs) -> list[float]: for _ in range(N_WARMUP): fn(*args, **kwargs) - times = [] + + samples_ms: list[float] = [] for _ in range(N_RUNS): t0 = time.perf_counter() fn(*args, **kwargs) - times.append((time.perf_counter() - t0) * 1000) - times.sort() - return times[len(times) // 2] + samples_ms.append((time.perf_counter() - t0) * 1000.0) + return samples_ms + + +def _python_peak_bytes(fn, *args, **kwargs) -> int | None: + try: + tracemalloc.start() + tracemalloc.reset_peak() + fn(*args, **kwargs) + _, peak = tracemalloc.get_traced_memory() + return int(peak) + except Exception: + return None + finally: + tracemalloc.stop() + + +def _throughput_m_bars_s(size: int, median_ms: float) -> float: + if median_ms <= 0: + return 0.0 + return (size / 1e6) / (median_ms / 1000.0) + + +# --------------------------------------------------------------------------- +# Benchmarked callables +# --------------------------------------------------------------------------- -# Each entry: (label, ferro_ta_callable, talib_callable, needs_ohlcv) -# ferro_ta_callable / talib_callable receive (open_, high, low, close, volume) and size; -# they return (args, ft_kwargs, ta_kwargs) or we use a simpler convention: -# we pass (o, h, l, c, v) and size; each runner knows how to slice and call. def _run_ft_sma(o, h, l, c, v, n): return ferro_ta.SMA(c[:n], timeperiod=14) @@ -236,7 +276,6 @@ def _run_ta_wma(o, h, l, c, v, n): return talib.WMA(c[:n], timeperiod=14) -# List of (indicator_name, ft_runner, ta_runner); skip 1M for very slow indicators if needed COMPARISON_CASES = [ ("SMA", _run_ft_sma, _run_ta_sma), ("EMA", _run_ft_ema, _run_ta_ema), @@ -252,14 +291,14 @@ COMPARISON_CASES = [ ("WMA", _run_ft_wma, _run_ta_wma), ] -# For STOCH/ADX and other heavier indicators, optionally skip 1M to keep runtime reasonable SKIP_1M_FOR = {"STOCH", "ADX"} def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, Any]]: max_size = max(sizes) open_, high, low, close, volume = _synthetic_ohlcv(max_size) - results = [] + results: list[dict[str, Any]] = [] + col_label = 10 col_size = 10 col_ft_ms = 12 @@ -269,12 +308,13 @@ def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, An col_ta_m = 12 if not TALIB_AVAILABLE: - print("Note: ta-lib not installed — reporting ferro_ta timings only (no speedup).") + print("Note: ta-lib not installed. Reporting ferro_ta timings only.") print("Install with: pip install ta-lib (or conda install ta-lib) for comparison.\n") - print(f"\nferro_ta vs TA-Lib — median of {N_RUNS} runs (after {N_WARMUP} warmup)") + print(f"\nferro_ta vs TA-Lib — median of {N_RUNS} measured runs after {N_WARMUP} warmup") print(f"Sizes: {sizes}") print() + header = ( f"{'Indicator':<{col_label}} {'Size':<{col_size}} " f"{'ferro_ta(ms)':<{col_ft_ms}} {'TA-Lib(ms)':<{col_ta_ms}} " @@ -287,82 +327,140 @@ def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, An for size in sizes: if size == 1_000_000 and name in SKIP_1M_FOR: continue - ms_ft = _median_time_ms(ft_run, open_, high, low, close, volume, size) + + ft_samples_ms = _timed_runs_ms(ft_run, open_, high, low, close, volume, size) + ft_stats = _summary_stats(ft_samples_ms) + ft_median_ms = float(ft_stats["median_ms"]) + ft_m_bars_s = _throughput_m_bars_s(size, ft_median_ms) + ft_peak_bytes = _python_peak_bytes(ft_run, open_, high, low, close, volume, size) + + row: dict[str, Any] = { + "indicator": name, + "size": size, + "input_layout": { + "dtype": "float64", + "contiguous": True, + }, + "ferro_ta_ms": round(ft_median_ms, 4), + "ferro_ta_m_bars_s": round(ft_m_bars_s, 2), + "ferro_ta_runs_ms": [round(sample, 4) for sample in ft_samples_ms], + "ferro_ta_stats": ft_stats, + "python_peak_allocation_bytes": { + "ferro_ta": ft_peak_bytes, + }, + } + if TALIB_AVAILABLE: - ms_ta = _median_time_ms(ta_run, open_, high, low, close, volume, size) - speedup = ms_ta / ms_ft if ms_ft > 0 else float("inf") - m_bars_ft = (size / 1e6) / (ms_ft / 1000) if ms_ft > 0 else 0 - m_bars_ta = (size / 1e6) / (ms_ta / 1000) if ms_ta > 0 else 0 + ta_samples_ms = _timed_runs_ms(ta_run, open_, high, low, close, volume, size) + ta_stats = _summary_stats(ta_samples_ms) + ta_median_ms = float(ta_stats["median_ms"]) + ta_m_bars_s = _throughput_m_bars_s(size, ta_median_ms) + speedup = ta_median_ms / ft_median_ms if ft_median_ms > 0 else float("inf") + outcome = _outcome(speedup) + ta_peak_bytes = _python_peak_bytes( + ta_run, open_, high, low, close, volume, size + ) + print( f"{name:<{col_label}} {size:<{col_size}} " - f"{ms_ft:<{col_ft_ms}.3f} {ms_ta:<{col_ta_ms}.3f} " - f"{speedup:<{col_speedup}.2f}x {m_bars_ft:<{col_ft_m}.1f} {m_bars_ta:<{col_ta_m}.1f}" + f"{ft_median_ms:<{col_ft_ms}.3f} {ta_median_ms:<{col_ta_ms}.3f} " + f"{speedup:<{col_speedup}.2f}x {ft_m_bars_s:<{col_ft_m}.1f} {ta_m_bars_s:<{col_ta_m}.1f}" ) - row = { - "indicator": name, - "size": size, - "ferro_ta_ms": round(ms_ft, 4), - "talib_ms": round(ms_ta, 4), - "speedup": round(speedup, 4), - "ferro_ta_m_bars_s": round(m_bars_ft, 2), - "talib_m_bars_s": round(m_bars_ta, 2), - } + + row.update( + { + "talib_ms": round(ta_median_ms, 4), + "talib_m_bars_s": round(ta_m_bars_s, 2), + "talib_runs_ms": [round(sample, 4) for sample in ta_samples_ms], + "talib_stats": ta_stats, + "speedup": round(speedup, 4), + "outcome": outcome, + } + ) + row["python_peak_allocation_bytes"]["talib"] = ta_peak_bytes else: - m_bars_ft = (size / 1e6) / (ms_ft / 1000) if ms_ft > 0 else 0 print( f"{name:<{col_label}} {size:<{col_size}} " - f"{ms_ft:<{col_ft_ms}.3f} {'N/A':<{col_ta_ms}} " - f"{'N/A':<{col_speedup}} {m_bars_ft:<{col_ft_m}.1f} {'N/A':<{col_ta_m}}" + f"{ft_median_ms:<{col_ft_ms}.3f} {'N/A':<{col_ta_ms}} " + f"{'N/A':<{col_speedup}} {ft_m_bars_s:<{col_ft_m}.1f} {'N/A':<{col_ta_m}}" ) - row = { - "indicator": name, - "size": size, - "ferro_ta_ms": round(ms_ft, 4), - "ferro_ta_m_bars_s": round(m_bars_ft, 2), - } + results.append(row) print() if TALIB_AVAILABLE and results: - wins = sum(1 for r in results if r.get("speedup", 0) > 1) - total = len(results) - print(f"Summary: ferro_ta faster on {wins}/{total} rows (speedup > 1).") + wins = sum(1 for row in results if row.get("outcome") == "ferro_ta_win") + total = len([row for row in results if "speedup" in row]) + print(f"Summary: ferro_ta ahead outside the tie band on {wins}/{total} rows.") print() + if json_path: + metadata = benchmark_metadata( + "benchmark_vs_talib", + extra={ + "dataset": { + "generator": "synthetic_ohlcv", + "sizes": sizes, + "dtype": "float64", + "array_layout": "C-contiguous", + "seed": 42, + }, + "methodology": { + "warmup_runs": N_WARMUP, + "measured_runs": N_RUNS, + "reported_metric": "median_ms", + "speedup_definition": "talib_median_ms / ferro_ta_median_ms", + "tie_band": f"{1.0 - TIE_EPSILON:.2f} to {1.0 + TIE_EPSILON:.2f}", + "input_layout_notes": ( + "Benchmarks use contiguous float64 arrays. If your workload " + "passes non-contiguous arrays or other dtypes, benchmark that " + "separately because wrapper overhead can dominate." + ), + "allocation_notes": ( + "python_peak_allocation_bytes is a tracemalloc snapshot of " + "Python-tracked allocations only; it is not a full native RSS " + "or allocator profile." + ), + }, + "packages": package_versions("numpy", "ferro-ta", "TA-Lib"), + }, + ) out = { - "schema_version": 1, - "command": "python benchmarks/bench_vs_talib.py", + "schema_version": 2, + "command": " ".join(["python", *sys.argv]), "n_warmup": N_WARMUP, "n_runs": N_RUNS, "sizes": sizes, "talib_available": TALIB_AVAILABLE, - "runtime": _runtime_info(), - "git": _git_info(), + "runtime": metadata["runtime"], + "git": metadata["git"], + "metadata": metadata, "summary": { "total_rows": len(results), - "by_size": [_summary_for_size(results, s) for s in sizes], + "by_size": [_summary_for_size(results, size) for size in sizes], }, "results": results, } if not TALIB_AVAILABLE: - out["note"] = "ferro_ta only — ta-lib not installed" - with open(json_path, "w") as f: - json.dump(out, f, indent=2) + out["note"] = "ferro_ta only; ta-lib not installed" + with open(json_path, "w", encoding="utf-8") as handle: + json.dump(out, handle, indent=2) print(f"Results written to {json_path}") + return results def main() -> int: - ap = argparse.ArgumentParser(description="ferro_ta vs TA-Lib speed comparison") - ap.add_argument("--json", default=None, help="Write results to JSON file") - ap.add_argument( + parser = argparse.ArgumentParser(description="ferro_ta vs TA-Lib speed comparison") + parser.add_argument("--json", default=None, help="Write results to JSON file") + parser.add_argument( "--sizes", type=int, nargs="+", default=DEFAULT_SIZES, help="Bar counts to benchmark (default: 10000 100000 1000000)", ) - args = ap.parse_args() + args = parser.parse_args() run_comparison(args.sizes, args.json) return 0 diff --git a/benchmarks/metadata.py b/benchmarks/metadata.py index cecb48d..3af3af9 100644 --- a/benchmarks/metadata.py +++ b/benchmarks/metadata.py @@ -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] diff --git a/conda/meta.yaml b/conda/meta.yaml index 88a20f0..5b29fc2 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -1,5 +1,5 @@ {% set name = "ferro-ta" %} -{% set version = "1.0.0" %} +{% set version = "1.0.3" %} package: name: {{ name|lower }} @@ -39,11 +39,12 @@ about: home: https://github.com/pratikbhadane24/ferro-ta license: MIT license_family: MIT - summary: A fast Technical Analysis library — TA-Lib alternative powered by Rust and PyO3 + summary: Rust-powered Python technical analysis library with a TA-Lib-compatible API description: | - ferro-ta is a drop-in TA-Lib alternative with pre-compiled wheels for all - major platforms. It provides 155+ indicators via a Rust core and PyO3 - bindings, with optional pandas / streaming APIs. + ferro-ta is a Rust-powered Python technical analysis library with a + TA-Lib-compatible API and pre-compiled wheels for the supported platforms. + It provides 155+ indicators via a Rust core and PyO3 bindings, with + optional pandas and streaming APIs. doc_url: https://github.com/pratikbhadane24/ferro-ta dev_url: https://github.com/pratikbhadane24/ferro-ta diff --git a/crates/ferro_ta_core/Cargo.toml b/crates/ferro_ta_core/Cargo.toml index b432a32..a7b6ef2 100644 --- a/crates/ferro_ta_core/Cargo.toml +++ b/crates/ferro_ta_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ferro_ta_core" -version = "1.0.2" +version = "1.0.3" edition = "2021" description = "Pure Rust core indicator library — no PyO3, no numpy dependency" license = "MIT" diff --git a/crates/ferro_ta_core/README.md b/crates/ferro_ta_core/README.md index 5a49414..e4421a5 100644 --- a/crates/ferro_ta_core/README.md +++ b/crates/ferro_ta_core/README.md @@ -13,7 +13,7 @@ PyO3, NumPy, or Python runtime dependency, which makes it a good fit for: ```toml [dependencies] -ferro_ta_core = "1.0.2" +ferro_ta_core = "1.0.3" ``` ## Design diff --git a/docs/adjacent_tooling.rst b/docs/adjacent_tooling.rst new file mode 100644 index 0000000..838c878 --- /dev/null +++ b/docs/adjacent_tooling.rst @@ -0,0 +1,47 @@ +Adjacent Tooling +================ + +These modules are useful, but they are secondary to ferro-ta's core identity as +a Python technical analysis library. + +.. list-table:: + :header-rows: 1 + + * - Area + - Status + - What it is + * - Derivatives analytics + - Adjacent + - Options pricing, Greeks, implied volatility helpers, futures basis, + curve, and roll utilities. See :doc:`derivatives`. + * - Agent workflow wrappers + - Adjacent + - Tool and workflow helpers for agent-style integrations. See + `docs/agentic.md `_. + * - MCP server + - Experimental or adjacent + - Exposes selected ferro-ta capabilities to MCP-compatible clients. See + `docs/mcp.md `_. + * - WASM package + - Experimental + - Browser and Node.js package with a smaller indicator subset. See + `wasm/README.md `_. + * - GPU backend + - Experimental + - Optional PyTorch-backed acceleration for a limited subset of indicators. + See `docs/gpu-backend.md `_. + * - Plugin system + - Experimental + - Registry and plugin packaging model for custom indicators. See + :doc:`plugins`. + +How to read the project +----------------------- + +When evaluating ferro-ta: + +- Start with the core library docs, migration guide, support matrix, and benchmarks. +- Treat adjacent tooling as opt-in layers, not as proof that the core indicator + library is broader or more stable than it is. +- Check the release notes and stability policy before depending on experimental + surfaces in production. diff --git a/docs/benchmarks.rst b/docs/benchmarks.rst index f73cfd8..60ae67d 100644 --- a/docs/benchmarks.rst +++ b/docs/benchmarks.rst @@ -1,20 +1,130 @@ Benchmarks ========== -The authoritative benchmark workflow is in ``benchmarks/``: +The benchmark suite is meant to support a narrow claim: ferro-ta is often +faster on selected indicators, and the evidence is published in a reproducible +form. + +What is published +----------------- + +The authoritative benchmark workflow lives in ``benchmarks/``: - Cross-library speed suite: ``benchmarks/test_speed.py`` - Cross-library accuracy suite: ``benchmarks/test_accuracy.py`` -- TA-Lib head-to-head speed script: ``benchmarks/bench_vs_talib.py`` +- TA-Lib head-to-head script: ``benchmarks/bench_vs_talib.py`` - Table generation from benchmark JSON: ``benchmarks/benchmark_table.py`` +- Perf-contract artifact bundle: ``benchmarks/run_perf_contract.py`` -Run the cross-library speed suite on 100,000 bars: +Latest checked-in TA-Lib artifact +--------------------------------- + +The current checked-in TA-Lib comparison artifact benchmarks contiguous +``float64`` arrays at 10k and 100k bars on an ``Apple M3 Max`` with 14 logical +cores, about 38.7 GB RAM, ``CPython 3.13.5``, and ``Rust 1.91.1`` using the +default release profile (``lto = true``, ``codegen-units = 1``). + +Summary from ``benchmarks/artifacts/latest/benchmark_vs_talib.json``: + +.. list-table:: + :header-rows: 1 + + * - Size + - Rows + - ferro-ta wins + - Median speedup + - TA-Lib wins or ties + * - ``10,000`` + - 12 + - 6 + - ``1.0850x`` + - ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV`` + * - ``100,000`` + - 12 + - 6 + - ``1.0784x`` + - ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV`` + +Examples from the 100k-bar run: + +.. list-table:: + :header-rows: 1 + + * - Indicator + - ferro-ta + - TA-Lib + - Speedup + - Read + * - ``SMA`` + - ``0.0985 ms`` + - ``0.2241 ms`` + - ``2.2751x`` + - clear ferro-ta win + * - ``BBANDS`` + - ``0.2122 ms`` + - ``0.4966 ms`` + - ``2.3402x`` + - clear ferro-ta win + * - ``MACD`` + - ``0.5152 ms`` + - ``0.7111 ms`` + - ``1.3801x`` + - ferro-ta win + * - ``STOCH`` + - ``1.7064 ms`` + - ``0.7603 ms`` + - ``0.4455x`` + - TA-Lib win + * - ``ADX`` + - ``0.7910 ms`` + - ``0.5769 ms`` + - ``0.7294x`` + - TA-Lib win + * - ``ATR`` + - ``0.5087 ms`` + - ``0.5147 ms`` + - ``1.0118x`` + - tie on this machine + +Methodology notes +----------------- + +- The head-to-head script uses the same synthetic OHLCV generator, the same + parameters, and the same contiguous ``float64`` array layout for both + libraries. +- Reported speedup is ``TA-Lib median time / ferro-ta median time``. +- The script uses 1 warmup run and 7 measured runs per case, and now records + the full per-run timing samples, not just one selected number. +- Published JSON artifacts include machine/runtime metadata, git metadata, Rust + toolchain and build-profile metadata, per-run variance statistics, and + Python-tracked peak allocation snapshots. +- Allocation snapshots are based on ``tracemalloc`` and capture Python-tracked + allocations only; they are not full native RSS profiles. +- If your workload uses non-contiguous arrays, different dtypes, or different + batch sizes, benchmark that exact workload. Those factors can materially + change the result. + +Reproduce the TA-Lib comparison +------------------------------- + +.. code-block:: bash + + pip install ta-lib + python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json + +The JSON output is the main artifact to review when publishing performance +claims. + +Cross-library suite +------------------- + +Run the broader speed suite on 100,000 bars: .. code-block:: bash uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v -Selected results on a modern CPU (100,000 bars): +Selected throughput examples from the checked-in table: .. list-table:: :header-rows: 1 @@ -38,25 +148,16 @@ Selected results on a modern CPU (100,000 bars): * - ``STOCH`` - 33 M bars/s -Multi-size and JSON output --------------------------- +Perf-contract artifacts +----------------------- -To build the markdown comparison table from the JSON output: +Use the perf-contract runner when you want a compact, machine-readable artifact +bundle for single-series latency, batch throughput, streaming throughput, and +hotspot attribution: .. code-block:: bash - uv run python benchmarks/benchmark_table.py + uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest -Comparison with TA-Lib ----------------------- - -To measure speedup vs TA-Lib on the same data and parameters, run: - -.. code-block:: bash - - pip install ta-lib - python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json - -See the README “Performance vs TA-Lib” section for methodology and a -representative comparison table. The script prints a table of median times and -speedup (TA-Lib time / ferro_ta time); use ``--json out.json`` to save results. +See ``benchmarks/README.md`` for the detailed benchmark playbook and the +checked-in comparison tables. diff --git a/docs/changelog.rst b/docs/changelog.rst index 02b2a74..fbb3f0e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,55 +1,42 @@ -Changelog -========= +Release Notes +============= -1.0.0 (2026) ------------- +These docs track package version ``1.0.3``. -**Candlestick Pattern Parity (61/61)** +1.0.3 (2026-03-24) +------------------ -- All 61 TA-Lib candlestick patterns implemented in Rust -- ``{-100, 0, 100}`` convention, consistent with TA-Lib +- Added top-level package metadata helpers such as ``ferro_ta.__version__``, + ``ferro_ta.about()``, and ``ferro_ta.methods()``. +- Added a standalone derivatives benchmark artifact for selected options + pricing, IV, Greeks, and Black-76 comparisons. +- Simplified release version bumps with a single script and updated release + guidance. -**Numerical Parity** +1.0.2 (2026-03-24) +------------------ -- RSI, ATR/NATR, CCI, BETA, STOCH, STOCHRSI, ADX/DX/DI/DM all rewritten to match TA-Lib seeding -- Removed dependency on ``ta`` crate for these indicators +- Improved rolling statistical kernels and several Python analysis hotspots. +- Added reproducible perf-contract artifacts, TA-Lib regression guards, and + updated benchmark tooling. +- Tightened the public benchmark documentation so claims, caveats, and evidence + live closer together. -**Streaming / Incremental API** +1.0.1 (2026-03-24) +------------------ -- New :mod:`ferro_ta.streaming` module with bar-by-bar stateful classes -- ``StreamingSMA``, ``StreamingEMA``, ``StreamingRSI``, ``StreamingATR``, ``StreamingBBands``, ``StreamingMACD``, ``StreamingStoch``, ``StreamingVWAP``, ``StreamingSupertrend`` +- Improved release automation for PyPI, crates.io, and npm. +- Fixed CI workflow issues that caused otherwise healthy release jobs to fail. +- Ensured the published WASM package includes its built ``pkg/`` artifacts. -**Pandas Integration** +1.0.0 (2026-03-23) +------------------ -- All indicators transparently accept ``pandas.Series`` and return ``Series`` with original index preserved -- Multi-output functions return tuples of ``Series`` +- First stable release of the Rust-backed Python technical analysis library. +- Shipped broad TA-Lib coverage, streaming APIs, extended indicators, and the + initial Sphinx documentation set. +- Added the benchmark suite, release playbook, and compatibility/testing + scaffolding for stable releases. -**Math Operators / Transforms** - -- 24 functions: arithmetic (ADD/SUB/MULT/DIV), rolling (SUM/MAX/MIN/MAXINDEX/MININDEX), element-wise math transforms -- SUM uses vectorized cumsum (220× faster than a naive loop) - -**Documentation** - -- Sphinx documentation setup with API reference, quickstart guide, and benchmarks page - -**Benchmarking Suite** - -- ``benchmarks/test_speed.py`` for authoritative ``pytest-benchmark`` speed runs -- ``benchmarks/bench_vs_talib.py`` for TA-Lib head-to-head comparisons - -**Extended Indicators** - -- ``VWAP`` — cumulative or rolling window -- ``SUPERTREND`` — ATR-based trend signal - -**Additional Extended Indicators** - -- ``ICHIMOKU`` — Ichimoku Cloud (Tenkan, Kijun, Senkou A/B, Chikou) -- ``DONCHIAN`` — Donchian Channels (upper, middle, lower) -- ``PIVOT_POINTS`` — Classic, Fibonacci, and Camarilla pivot points - -**Type Stubs & Packaging** - -- ``python/ferro_ta/__init__.pyi`` type stub for IDE auto-completion -- ``pyproject.toml``: added optional extras (benchmark, pandas, docs, all), project URLs, Python 3.10–3.13 classifiers +For the canonical project changelog, including the full per-version details, +see `CHANGELOG.md `_. diff --git a/docs/conf.py b/docs/conf.py index e9dd0d5..7d7d62b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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 ---------------------------------------------------- diff --git a/docs/index.rst b/docs/index.rst index b0c14e2..e02f952 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,43 +3,66 @@ ferro-ta Documentation .. toctree:: :maxdepth: 2 - :caption: Contents + :caption: Core Library quickstart migration_talib + support_matrix pandas_api error_handling api/index streaming - extended batch - derivatives + extended + +.. toctree:: + :maxdepth: 2 + :caption: Evidence and Releases + benchmarks - plugins changelog + +.. toctree:: + :maxdepth: 2 + :caption: Adjacent and Experimental + + derivatives + adjacent_tooling + plugins contributing Overview -------- -**ferro-ta** is a fast Technical Analysis library — a drop-in alternative to TA-Lib -powered by Rust and PyO3. +**ferro-ta** is a Rust-powered Python technical analysis library focused on a +TA-Lib-compatible API for NumPy-centered workloads. -Features: +.. important:: + + Performance varies by indicator, array layout, warmup, build flags, and + machine. ferro-ta is often faster on selected indicators, not universally + faster. See :doc:`benchmarks` for the reproducible workflow, methodology + notes, and the indicators where TA-Lib still wins or ties in the current + checked-in artifact. + +Core library: - 160+ indicators covering all TA-Lib categories -- 10 extended indicators not in TA-Lib (VWAP, Supertrend, Ichimoku Cloud, …) -- Batch execution API — run indicators on 2-D arrays of multiple series +- TA-Lib-style imports such as ``ferro_ta.SMA(close, timeperiod=20)`` +- Pre-built wheels for the supported Python/OS matrix - Pure Rust core library (``crates/ferro_ta_core``) — no PyO3 / numpy dependency +- Batch execution API — run indicators on 2-D arrays of multiple series - Streaming / bar-by-bar API for live trading - Transparent pandas.Series support -- Math operators and transforms - Type stubs (.pyi) for IDE auto-completion -- WASM binding for browser/Node.js use -- Options/IV helpers and derivatives analytics — see :doc:`derivatives` +- 10 extended indicators not in TA-Lib (VWAP, Supertrend, Ichimoku Cloud, ...) + +Adjacent and experimental tooling: + +- Derivatives analytics — see :doc:`derivatives` - Agentic workflow and LangChain tool wrappers — see `Agentic guide `_ - MCP server for Cursor/Claude integration — see `MCP guide `_ -- Sphinx documentation +- WASM, plugins, and other optional surfaces — see :doc:`adjacent_tooling` Installation ~~~~~~~~~~~~ @@ -70,11 +93,11 @@ Further Reading - `Architecture `_ — Rust/Python layout, two-crate design, binding flow. - `Performance Guide `_ — when to use raw numpy vs pandas/polars, batch notes, tips. - `API Stability `_ — stability tiers, versioning, and deprecation policy. +- :doc:`support_matrix` — parity status, tested wheel targets, supported Python versions, and experimental modules. - `Rust-First Policy `_ — all compute logic belongs in Rust; how to add new indicators. - `Out-of-Core Execution `_ — chunked processing and Dask integration. - :doc:`derivatives` — IV helpers, options pricing/Greeks/IV, futures analytics, strategy schemas, and payoff helpers. -- `Agentic Workflow `_ — tools.py, workflow.py, LangChain integration. -- `MCP Server `_ — run ferro-ta as an MCP server in Cursor/Claude. +- :doc:`adjacent_tooling` — optional surfaces such as derivatives, MCP, WASM, GPU, plugins, and agent-oriented integrations. Indices and tables ================== diff --git a/docs/support_matrix.rst b/docs/support_matrix.rst new file mode 100644 index 0000000..8842494 --- /dev/null +++ b/docs/support_matrix.rst @@ -0,0 +1,117 @@ +Support Matrix +============== + +The primary product is the Python technical analysis library: TA-Lib-style +indicator calls backed by a Rust implementation. + +Indicator compatibility +----------------------- + +.. list-table:: + :header-rows: 1 + + * - Status + - Scope + - Notes + * - Exact parity + - Common TA-Lib-compatible indicators such as ``SMA``, ``WMA``, + ``BBANDS``, ``RSI``, ``ATR``, ``NATR``, ``CCI``, ``STOCH``, + ``STOCHRSI``, and most candlestick patterns + - Matches TA-Lib numerically within floating-point tolerance in the + current comparison suite. + * - Approximate parity + - EMA-family indicators (``EMA``, ``DEMA``, ``TEMA``, ``T3``, ``MACD``), + ``MAMA`` / ``FAMA``, ``SAR`` / ``SAREXT``, and ``HT_*`` cycle + indicators + - Same API and intended use, with convergence-window or floating-point + differences documented in the migration guide. + * - Intentionally different + - ferro-ta-only indicators such as ``VWAP``, ``SUPERTREND``, + ``ICHIMOKU``, ``DONCHIAN``, ``KELTNER_CHANNELS``, ``HULL_MA``, + ``CHANDELIER_EXIT``, ``VWMA``, and ``CHOPPINESS_INDEX`` + - These extend the library beyond TA-Lib and are not parity claims. + +For migration details and known indicator-specific differences, see +:doc:`migration_talib`. + +Module status +------------- + +.. list-table:: + :header-rows: 1 + + * - Surface + - Status + - Notes + * - Top-level indicators and category submodules + - Stable core + - This is the main supported surface of the project. + * - ``ferro_ta.batch`` + - Supported + - Public API is supported; internal dispatch may evolve. + * - ``ferro_ta.streaming`` + - Supported, still evolving + - Suitable for live workflows; some API details are still marked + experimental in the stability policy. + * - ``ferro_ta.extended`` + - Supported extension + - Useful indicators beyond TA-Lib, but not part of drop-in parity claims. + * - ``ferro_ta.analysis.*`` + - Adjacent tooling + - Useful analytics helpers, but not the primary product story. + * - MCP, WASM, GPU, plugin, and agent-oriented tooling + - Experimental or adjacent + - Evaluate these independently from the core indicator library. + +Supported Python versions +------------------------- + +.. list-table:: + :header-rows: 1 + + * - Python + - Status + * - 3.13 + - Supported and tested in CI + * - 3.12 + - Supported and tested in CI + * - 3.11 + - Supported and tested in CI + * - 3.10 + - Supported and tested in CI + * - < 3.10 + - Not supported + +Tested wheel targets +-------------------- + +.. list-table:: + :header-rows: 1 + + * - OS + - Architecture + - Wheel status + * - Linux + - ``x86_64`` (manylinux2014 / ``manylinux_2_17``) + - Tested wheel target + * - macOS + - ``universal2`` + - Tested wheel target for Intel and Apple Silicon + * - Windows + - ``x86_64`` + - Tested wheel target + +For source builds, packaging details, and platform notes, see +`PLATFORMS.md `_. + +Release status +-------------- + +These docs track package version ``1.0.3``. + +- Release notes by version: :doc:`changelog` +- Canonical project changelog: `CHANGELOG.md `_ +- Stability policy: `docs/stability.md `_ + +If the package version, docs version, or support matrix disagree, treat that as +a documentation bug. diff --git a/pyproject.toml b/pyproject.toml index 556cd98..4abd6fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "maturin" [project] name = "ferro-ta" -version = "1.0.2" -description = "A fast Technical Analysis library — TA-Lib alternative powered by Rust and PyO3" +version = "1.0.3" +description = "Rust-powered Python technical analysis library with a TA-Lib-compatible API" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.10" diff --git a/python/ferro_ta/__init__.py b/python/ferro_ta/__init__.py index 7d1c3a6..943b856 100644 --- a/python/ferro_ta/__init__.py +++ b/python/ferro_ta/__init__.py @@ -59,8 +59,52 @@ array([ nan, nan, 11. , 12. , 13. , 13.5, 13.33...]) from __future__ import annotations +from importlib.metadata import PackageNotFoundError as _PackageNotFoundError +from importlib.metadata import version as _dist_version +from pathlib import Path as _Path +import re as _re import sys as _sys +try: + import tomllib as _tomllib +except ImportError: # pragma: no cover + try: + import tomli as _tomllib # type: ignore[no-redef] + except ImportError: # pragma: no cover + _tomllib = None # type: ignore[assignment] + + +def _detect_version() -> str: + try: + return _dist_version("ferro-ta") + except _PackageNotFoundError: + pass + + if _tomllib is not None: + pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml" + if pyproject_toml.is_file(): + try: + with pyproject_toml.open("rb") as handle: + data = _tomllib.load(handle) + return data.get("project", {}).get("version", "0+unknown") + except Exception: + pass + + pyproject_toml = _Path(__file__).resolve().parents[2] / "pyproject.toml" + if pyproject_toml.is_file(): + try: + text = pyproject_toml.read_text(encoding="utf-8") + match = _re.search(r'^version\s*=\s*"([^"]+)"', text, _re.MULTILINE) + if match: + return match.group(1) + except Exception: + pass + + return "0+unknown" + + +__version__ = _detect_version() + # --------------------------------------------------------------------------- # Exceptions — exported at the top level for convenient catching # --------------------------------------------------------------------------- @@ -281,6 +325,7 @@ from ferro_ta.indicators.volume import ( # noqa: F401 ) __all__ = [ + "__version__", # Overlap Studies "SMA", "EMA", @@ -459,7 +504,9 @@ __all__ = [ "VWMA", "CHOPPINESS_INDEX", # API discovery + "about", "indicators", + "methods", "info", # Logging utilities "enable_debug", @@ -585,9 +632,10 @@ from ferro_ta.tools.alerts import ( # noqa: F401, E402 ) # --------------------------------------------------------------------------- -# API discovery helpers — ferro_ta.indicators() and ferro_ta.info() +# API discovery helpers — ferro_ta.about(), ferro_ta.methods(), +# ferro_ta.indicators(), and ferro_ta.info() # --------------------------------------------------------------------------- -from ferro_ta.tools.api_info import indicators, info # noqa: F401, E402 +from ferro_ta.tools.api_info import about, indicators, info, methods # noqa: F401, E402 _ALIASED_SUBMODULES = { "batch": batch, diff --git a/python/ferro_ta/mcp/__init__.py b/python/ferro_ta/mcp/__init__.py index 80ec5f2..6b4476e 100644 --- a/python/ferro_ta/mcp/__init__.py +++ b/python/ferro_ta/mcp/__init__.py @@ -67,6 +67,7 @@ from typing import Any import numpy as np +import ferro_ta as ft from ferro_ta.tools import ( compute_indicator, describe_indicator, @@ -392,7 +393,7 @@ def _run_stdio_fallback() -> None: # pragma: no cover "result": { "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, - "serverInfo": {"name": "ferro-ta", "version": "1.0.0"}, + "serverInfo": {"name": "ferro-ta", "version": ft.__version__}, }, } elif method == "tools/list": diff --git a/python/ferro_ta/tools/api_info.py b/python/ferro_ta/tools/api_info.py index 7f6e872..5196252 100644 --- a/python/ferro_ta/tools/api_info.py +++ b/python/ferro_ta/tools/api_info.py @@ -1,19 +1,23 @@ """ ferro_ta.api_info — API discovery helpers. -Provides :func:`indicators` and :func:`info` for exploring the ferro_ta -indicator catalogue without reading source code. +Provides :func:`indicators`, :func:`methods`, :func:`about`, and :func:`info` +for exploring the ferro_ta public API without reading source code. Usage ----- >>> import ferro_ta >>> ferro_ta.indicators() # all indicators, sorted >>> ferro_ta.indicators(category="momentum") # filter by category +>>> ferro_ta.methods() # public callables across modules +>>> ferro_ta.about()["version"] # package metadata summary >>> ferro_ta.info(ferro_ta.SMA) # parameter docs for SMA API --- indicators(category=None) — Return list of dicts describing every indicator. +methods(category=None) — Return list of public callables across modules. +about() — Return package/version/module summary metadata. info(func_or_name) — Return a dict with full signature/docstring info. """ @@ -23,7 +27,7 @@ import importlib import inspect from typing import Any -__all__ = ["indicators", "info"] +__all__ = ["indicators", "methods", "about", "info"] # --------------------------------------------------------------------------- # Category → module mapping used by indicators() @@ -52,6 +56,20 @@ _CATEGORY_MODULES: dict[str, str] = { "regime": "ferro_ta.analysis.regime", } +_METHOD_MODULES: dict[str, str] = { + "top_level": "ferro_ta", + **_CATEGORY_MODULES, + "options": "ferro_ta.analysis.options", + "futures": "ferro_ta.analysis.futures", + "backtest": "ferro_ta.analysis.backtest", + "options_strategy": "ferro_ta.analysis.options_strategy", + "derivatives_payoff": "ferro_ta.analysis.derivatives_payoff", + "attribution": "ferro_ta.analysis.attribution", + "cross_asset": "ferro_ta.analysis.cross_asset", + "tools": "ferro_ta.tools.tools", + "viz": "ferro_ta.tools.viz", +} + def _iter_module_callables( module_name: str, @@ -137,6 +155,66 @@ def indicators(category: str | None = None) -> list[dict[str, Any]]: return result +def methods(category: str | None = None) -> list[dict[str, Any]]: + """Return public callables across ferro_ta modules. + + Parameters + ---------- + category : str | None + Optional key from :data:`_METHOD_MODULES`, such as ``"top_level"``, + ``"options"``, ``"futures"``, or ``"batch"``. + """ + cats: dict[str, str] = ( + {category: _METHOD_MODULES[category]} + if category is not None + else _METHOD_MODULES + ) + + result: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for cat, mod_name in cats.items(): + for name, func in _iter_module_callables(mod_name): + key = (mod_name, name) + if key in seen: + continue + seen.add(key) + doc = inspect.getdoc(func) or "" + first_line = doc.splitlines()[0] if doc else "" + try: + sig = inspect.signature(func) + params = list(sig.parameters.keys()) + except (ValueError, TypeError): + params = [] + result.append( + { + "name": name, + "category": cat, + "module": mod_name, + "doc": first_line, + "params": params, + } + ) + + result.sort(key=lambda d: (d["category"], d["name"])) + return result + + +def about() -> dict[str, Any]: + """Return a small metadata summary for the installed ferro_ta package.""" + import ferro_ta # noqa: PLC0415 + + top_level_exports = sorted(getattr(ferro_ta, "__all__", [])) + return { + "name": "ferro-ta", + "version": getattr(ferro_ta, "__version__", "0+unknown"), + "top_level_export_count": len(top_level_exports), + "indicator_count": len(indicators()), + "method_count": len(methods()), + "categories": sorted(_METHOD_MODULES.keys()), + "top_level_exports": top_level_exports, + } + + def info(func_or_name: Any) -> dict[str, Any]: """Return detailed information about an indicator function. diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..b409fbc --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Update or verify ferro-ta version strings across release files. + +Usage +----- +python3 scripts/bump_version.py 1.0.3 +python3 scripts/bump_version.py --check +python3 scripts/bump_version.py --show +""" + +from __future__ import annotations + +import argparse +import re +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") + + +@dataclass(frozen=True) +class VersionCarrier: + label: str + path: Path + pattern: str + replacement: str + + def read(self) -> str: + text = self.path.read_text(encoding="utf-8") + match = re.search(self.pattern, text, flags=re.MULTILINE) + if not match: + raise ValueError(f"Could not find version for {self.label} in {self.path}") + return match.group(2) + + def write(self, version: str) -> bool: + text = self.path.read_text(encoding="utf-8") + updated, count = re.subn( + self.pattern, + rf"\g<1>{version}\g<3>", + text, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise ValueError(f"Could not update {self.label} in {self.path}") + changed = updated != text + if changed: + self.path.write_text(updated, encoding="utf-8") + return changed + + +CARRIERS = [ + VersionCarrier( + "cargo_root", + ROOT / "Cargo.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "cargo_core_dep", + ROOT / "Cargo.toml", + r'(ferro_ta_core = \{ path = "crates/ferro_ta_core", version = ")([^"]+)(" \})', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "cargo_core_crate", + ROOT / "crates" / "ferro_ta_core" / "Cargo.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "cargo_core_readme", + ROOT / "crates" / "ferro_ta_core" / "README.md", + r'(ferro_ta_core = ")([^"]+)(")', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "pyproject", + ROOT / "pyproject.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "wasm_cargo", + ROOT / "wasm" / "Cargo.toml", + r'(?m)^(version = ")([^"]+)(")$', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "wasm_package", + ROOT / "wasm" / "package.json", + r'("version": ")([^"]+)(")', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "conda", + ROOT / "conda" / "meta.yaml", + r'({% set version = ")([^"]+)(" %})', + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "docs_changelog", + ROOT / "docs" / "changelog.rst", + r"(These docs track package version ``)([^`]+)(``\.)", + r"\g<1>{version}\g<3>", + ), + VersionCarrier( + "docs_support_matrix", + ROOT / "docs" / "support_matrix.rst", + r"(These docs track package version ``)([^`]+)(``\.)", + r"\g<1>{version}\g<3>", + ), +] + + +def _read_versions() -> dict[str, str]: + return {carrier.label: carrier.read() for carrier in CARRIERS} + + +def _print_versions(versions: dict[str, str]) -> None: + for label, version in versions.items(): + print(f"{label:20} {version}") + + +def _check_versions() -> int: + versions = _read_versions() + unique = sorted(set(versions.values())) + _print_versions(versions) + if len(unique) != 1: + print() + print(f"ERROR: version mismatch detected: {', '.join(unique)}") + return 1 + print() + print(f"OK: all tracked versions match {unique[0]}") + return 0 + + +def _set_version(version: str) -> int: + if not SEMVER_RE.match(version): + print(f"ERROR: expected MAJOR.MINOR.PATCH, got {version!r}") + return 1 + + changed_paths: list[Path] = [] + for carrier in CARRIERS: + if carrier.write(version): + changed_paths.append(carrier.path) + + if changed_paths: + print(f"Updated version to {version}:") + for path in sorted(set(changed_paths)): + print(f" - {path.relative_to(ROOT)}") + else: + print(f"No changes needed. All tracked files already use {version}.") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", nargs="?", help="New version to write") + parser.add_argument( + "--check", + action="store_true", + help="Fail if tracked version strings do not match", + ) + parser.add_argument( + "--show", + action="store_true", + help="Print tracked version strings without modifying files", + ) + args = parser.parse_args() + + if args.check: + return _check_versions() + if args.show: + _print_versions(_read_versions()) + return 0 + if args.version: + return _set_version(args.version) + + parser.print_help() + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 6d74554..b18ba49 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -227,6 +227,25 @@ def test_indicators_returns_list(): assert "ATR" in names +def test_methods_returns_public_callables(): + import ferro_ta + + result = ferro_ta.methods() + assert isinstance(result, list) + assert any(d["name"] == "SMA" and d["category"] == "top_level" for d in result) + assert any(d["name"] == "option_price" and d["category"] == "options" for d in result) + + +def test_about_reports_version_and_counts(): + import ferro_ta + + meta = ferro_ta.about() + assert meta["version"] == ferro_ta.__version__ + assert meta["indicator_count"] > 20 + assert meta["method_count"] >= meta["indicator_count"] + assert "__version__" in meta["top_level_exports"] + + def test_indicators_filter_by_category(): import ferro_ta diff --git a/tests/unit/test_derivatives.py b/tests/unit/test_derivatives.py index 1d74e12..2173c78 100644 --- a/tests/unit/test_derivatives.py +++ b/tests/unit/test_derivatives.py @@ -216,3 +216,23 @@ class TestStrategyAndPayoff: assert payoff[1] == pytest.approx(-3.0) assert greeks.delta > 0.0 assert greeks.gamma > 0.0 + + +class TestDerivativesBenchmarking: + def test_derivatives_benchmark_smoke(self, tmp_path): + from benchmarks.bench_derivatives_compare import run_benchmark + + output_path = tmp_path / "derivatives_benchmark.json" + result = run_benchmark( + sizes=[32], + accuracy_size=16, + json_path=str(output_path), + ) + + assert output_path.is_file() + assert result["accuracy"]["results"] + assert result["speed"]["results"] + assert any( + row["provider"] == "ferro_ta" for row in result["accuracy"]["results"] + ) + assert any(row["provider"] == "ferro_ta" for row in result["speed"]["results"]) diff --git a/tests/unit/test_infrastructure.py b/tests/unit/test_infrastructure.py index a68ed65..97910de 100644 --- a/tests/unit/test_infrastructure.py +++ b/tests/unit/test_infrastructure.py @@ -641,6 +641,9 @@ class TestBatchShapeValidation: # --------------------------------------------------------------------------- import os +import re +import runpy +import subprocess try: import tomllib # Python 3.11+ @@ -673,8 +676,41 @@ def _read_pyproject_version() -> str: return data["project"]["version"] +def _read_conda_version() -> str: + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + conda_meta = os.path.join(root, "conda", "meta.yaml") + text = open(conda_meta).read() + match = re.search(r'{% set version = "([^"]+)" %}', text) + if not match: + raise ValueError("Could not find conda version") + return match.group(1) + + +def _read_docs_release() -> str: + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + conf_py = os.path.join(root, "docs", "conf.py") + old_env = os.environ.pop("FERRO_TA_VERSION", None) + try: + data = runpy.run_path(conf_py) + return data["release"] + finally: + if old_env is not None: + os.environ["FERRO_TA_VERSION"] = old_env + + +def _run_bump_version_check() -> subprocess.CompletedProcess[str]: + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + return subprocess.run( + ["python3", "scripts/bump_version.py", "--check"], + cwd=root, + text=True, + capture_output=True, + check=False, + ) + + class TestVersionConsistency: - """Cargo.toml and pyproject.toml must have the same version string.""" + """Public version strings should stay aligned with the package version.""" def test_versions_match(self): try: @@ -687,6 +723,41 @@ class TestVersionConsistency: f"pyproject.toml={pyproject_ver!r}" ) + def test_package_version_matches_project_version(self): + cargo_ver = _read_cargo_version() + assert ferro_ta.__version__ == cargo_ver + + def test_conda_version_matches_project_version(self): + cargo_ver = _read_cargo_version() + conda_ver = _read_conda_version() + assert conda_ver == cargo_ver + + def test_docs_release_matches_project_version(self): + cargo_ver = _read_cargo_version() + docs_release = _read_docs_release() + assert docs_release == cargo_ver + + def test_docs_changelog_mentions_current_version(self): + cargo_ver = _read_cargo_version() + root = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + changelog_rst = os.path.join(root, "docs", "changelog.rst") + text = open(changelog_rst).read() + assert cargo_ver in text + + def test_api_version_matches_project_version(self): + cargo_ver = _read_cargo_version() + try: + from api.main import app + except Exception: + pytest.skip("api/main.py not importable") + assert app.version == cargo_ver + + def test_bump_version_check_passes(self): + result = _run_bump_version_check() + assert result.returncode == 0, result.stdout + result.stderr + def test_release_md_exists(self): """RELEASE.md must exist in the repository root.""" root = os.path.dirname( diff --git a/uv.lock b/uv.lock index 71931aa..37042fd 100644 --- a/uv.lock +++ b/uv.lock @@ -609,7 +609,7 @@ wheels = [ [[package]] name = "ferro-ta" -version = "1.0.2" +version = "1.0.3" source = { editable = "." } dependencies = [ { name = "numpy" }, diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index dc524aa..bfd7d24 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ferro_ta_wasm" -version = "1.0.2" +version = "1.0.3" edition = "2021" description = "WebAssembly bindings for ferro-ta technical analysis indicators" license = "MIT" diff --git a/wasm/package.json b/wasm/package.json index f6a78ca..ced935c 100644 --- a/wasm/package.json +++ b/wasm/package.json @@ -1,6 +1,6 @@ { "name": "ferro-ta-wasm", - "version": "1.0.2", + "version": "1.0.3", "description": "WebAssembly bindings for ferro-ta technical analysis indicators", "main": "pkg/ferro_ta_wasm.js", "types": "pkg/ferro_ta_wasm.d.ts",