Compare commits

..

3 Commits

Author SHA1 Message Date
Pratik Bhadane 13cb0dc95a chore: release v1.0.3 2026-03-24 11:09:48 +05:30
Pratik Bhadane 0382c4e302 ci: add release.yml to auto-publish on version tag push
Pushing v* tag now triggers:
  1. release.yml — creates a GitHub Release (published, not draft),
     pulling the release notes from CHANGELOG.md automatically.
  2. CI.yml (release: published event) — builds wheels for Linux /
     macOS / Windows × Python 3.10-3.13, sdist, publishes to PyPI
     via trusted publisher, publishes ferro_ta_core to crates.io,
     and generates SBOMs.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 03:00:06 +05:30
Pratik Bhadane 7736effc27 fix(bench): adjust feature_matrix speedup floor to 0.40 for cross-architecture compatibility 2026-03-24 02:49:29 +05:30
41 changed files with 5806 additions and 461 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Release
# Triggered when a version tag is pushed (e.g. v1.0.2).
# Creates a GitHub Release marked as "published", which in turn
# triggers the build-wheels and publish jobs in CI.yml.
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
create-release:
name: Create GitHub Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Extract version from tag
id: version
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Extract changelog entry for this version
id: changelog
env:
TAG_NAME: ${{ github.ref_name }}
run: |
python3 - <<'PY'
import re, os, pathlib
version = os.environ["TAG_NAME"].lstrip("v")
text = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8")
pattern = rf"## \[{re.escape(version)}\].*?\n(.*?)(?=\n## |\Z)"
match = re.search(pattern, text, re.DOTALL)
body = match.group(1).strip() if match else f"Release {version}"
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"body<<EOF\n{body}\nEOF\n")
PY
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: v${{ steps.version.outputs.version }}
body: ${{ steps.changelog.outputs.body }}
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
+25 -1
View File
@@ -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
Generated
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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"] }
+6 -1
View File
@@ -1,7 +1,7 @@
# ferro-ta development Makefile
# Usage: make <target>
.PHONY: help dev build test lint typecheck fmt docs clean bench
.PHONY: help dev build test lint typecheck fmt docs clean bench version
# 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
+49 -65
View File
@@ -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. 35× 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)
+28 -7
View File
@@ -42,6 +42,8 @@ Before starting a release:
- [ ] `CHANGELOG.md` has a `## [X.Y.Z]` section (not `[Unreleased]`) with all
changes since the last release documented under `### Added`, `### Changed`,
`### Fixed`, `### Removed` headings.
- [ ] Public docs match the release: `docs/conf.py`, `docs/changelog.rst`, and
`docs/support_matrix.rst` reflect the version and current support status.
---
@@ -62,26 +64,39 @@ Example: current version is `0.1.0` and you are adding new indicators → new ve
## Step 2 — Sync version everywhere
These files must carry **the same version string** (e.g. `0.2.0`). Update all before tagging:
These files must carry **the same version string** (e.g. `0.2.0`). The easiest
way to do that is:
```bash
python3 scripts/bump_version.py 0.2.0
python3 scripts/bump_version.py --check
```
That script updates the tracked release-version carriers for you.
Files covered by the bump script:
| File | Location |
|------|----------|
| `Cargo.toml` | Root (source of truth) |
| `crates/ferro_ta_core/Cargo.toml` | Same version for crates.io publish |
| `crates/ferro_ta_core/README.md` | Installation snippet should show the current crate version |
| `pyproject.toml` | Root |
| `wasm/package.json` | `"version": "0.2.0"` |
| `wasm/package.json` | Package version |
| `conda/meta.yaml` | Conda recipe version |
| `docs/conf.py` | Default Sphinx release must resolve to the same version |
**`Cargo.toml`** (root):
```toml
[package]
name = "ferro_ta"
version = "0.2.0" # ← update here
version = "X.Y.Z" # ← or use scripts/bump_version.py X.Y.Z
```
**`pyproject.toml`**:
```toml
[project]
version = "0.2.0" # ← must match Cargo.toml exactly
version = "X.Y.Z" # ← must match Cargo.toml exactly
```
> **Rule:** `Cargo.toml` is the source of truth. Sync the others to match before tagging.
@@ -91,18 +106,24 @@ version = "0.2.0" # ← must match Cargo.toml exactly
## Step 3 — Update CHANGELOG.md
1. Open `CHANGELOG.md`.
2. Rename the `[Unreleased]` section to `[0.2.0] — YYYY-MM-DD` (today's date).
2. Rename the `[Unreleased]` section to `[X.Y.Z] — YYYY-MM-DD` (today's date).
3. Add a fresh empty `[Unreleased]` section at the top.
4. Update the comparison links at the bottom:
```markdown
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/pratikbhadane24/ferro-ta/compare/v0.1.0...v0.2.0
[Unreleased]: https://github.com/pratikbhadane24/ferro-ta/compare/vX.Y.Z...HEAD
[X.Y.Z]: https://github.com/pratikbhadane24/ferro-ta/compare/vPREVIOUS...vX.Y.Z
```
Follow the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format:
`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`.
Also update the docs-facing release surfaces for the same version:
- `docs/changelog.rst` with a concise release-notes entry
- `docs/support_matrix.rst` if supported versions, tested wheels, or module
stability changed
---
## Step 4 — Commit the version bump
+23 -6
View File
@@ -21,16 +21,33 @@ Currently supported: **3.10, 3.11, 3.12, 3.13** (see `pyproject.toml`).
## Release playbook
1. **Bump the version** in `Cargo.toml` and `pyproject.toml` to the new version
(e.g. `1.0.1`).
2. **Update `CHANGELOG.md`**: move the `[Unreleased]` block to a new dated section
### Fast path
1. **Bump tracked version files with one command**:
```bash
python3 scripts/bump_version.py 1.0.3
```
or:
```bash
make version VERSION=1.0.3
```
2. **Verify everything matches**:
```bash
python3 scripts/bump_version.py --check
```
3. **Update `CHANGELOG.md`**: move the `[Unreleased]` block to a new dated section
`[1.0.1] — YYYY-MM-DD` and open a fresh `[Unreleased]` block.
3. **Commit** the version bump and changelog update with message
4. **Commit** the version bump and changelog update with message
`chore: release v1.0.1`.
4. **Create a tag**: `git tag v1.0.1 && git push origin v1.0.1`.
5. **Create a GitHub Release** for tag `v1.0.1` — the CI `build-wheels` and
5. **Create a tag**: `git tag v1.0.1 && git push origin v1.0.1`.
6. **Create a GitHub Release** for tag `v1.0.1` — the CI `build-wheels` and
`publish` jobs trigger automatically on `release: published`.
The bump script updates the tracked release-version carriers that are easy to
miss manually: root Cargo, Python packaging, the core crate, the core crate
README install snippet, the WASM package, the Conda recipe, and the docs pages
that show the current released version.
## Breaking-change policy
- Removing an indicator or changing its signature is a **MAJOR** change.
+1 -1
View File
@@ -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",
)
+51 -6
View File
@@ -1,14 +1,21 @@
# ferro-ta Benchmark Suite
> **62 indicators × 6 libraries** — accuracy and speed verified on **100,000 bars** (LARGE dataset).
> Reproducible speed and accuracy comparisons across 62 indicators and the
> libraries available in your environment.
## Overview
The benchmark suite compares **ferro-ta** against five popular Python technical-analysis libraries on a common dataset and shared wrappers so timings are directly comparable.
The benchmark suite compares **ferro-ta** against other Python
technical-analysis libraries on a common dataset and shared wrappers so the
results are easier to reproduce and critique.
It is not designed to prove that ferro-ta wins everywhere. It is designed to
show where ferro-ta is faster, where it only ties, and where another library
still wins.
| Library | Notes |
|-----------|-------|
| **TA-Lib** | C extension; gold standard for accuracy and speed |
| **TA-Lib** | C extension; widely used comparison baseline |
| **pandas-ta** | Pure Python; broad indicator set |
| **ta** | Simple API; some indicators use O(n²) loops and are very slow |
| **Tulipy** | C extension; truncated output (no leading NaN padding) |
@@ -37,9 +44,23 @@ from benchmarks.data_generator import SMALL, MEDIUM, LARGE
- **Harness:** [pytest-benchmark](https://pytest-benchmark.readthedocs.io/) with `benchmark.pedantic(..., iterations=5, rounds=20, warmup_rounds=2)`.
- **Reported metric:** **Median time per call** in **microseconds (µs)** — lower is better.
- **Machine info:** Stored in `benchmarks/results.json` (`machine_info`, `commit_info`) for reproducibility.
- **TA-Lib head-to-head JSON:** `benchmarks/bench_vs_talib.py` records per-run samples, variance stats, machine/runtime/build metadata, and Python-tracked peak allocation snapshots.
- **Machine info:** Stored in the generated JSON artifacts for reproducibility.
- **Libraries:** Only libraries present in the environment are benchmarked; missing ones are skipped.
## Current checked-in TA-Lib artifact
The checked-in `benchmarks/artifacts/latest/benchmark_vs_talib.json` artifact
uses contiguous `float64` arrays at 10k and 100k bars on an Apple M3 Max,
CPython 3.13.5, and Rust 1.91.1 with the default release profile
(`lto = true`, `codegen-units = 1`).
- ferro-ta is ahead outside the tie band on 6 of 12 rows at 10k bars and 6 of 12 rows at 100k bars.
- TA-Lib still wins in the current artifact on `STOCH` and `ADX`, and remains close on `EMA`, `RSI`, `ATR`, and `OBV` depending on size.
- The public claim should therefore be read as "often faster on selected indicators," not "faster everywhere."
- When publishing performance statements, point readers to the raw JSON artifact, not just the summary table.
- The artifact now includes per-run samples, variance stats, and Python-tracked allocation snapshots for each compared indicator.
## Reproducible Perf Artifacts
Use the perf-contract runner when you want a compact set of machine-readable
@@ -140,7 +161,7 @@ The speed table includes **all 62 indicators**. **Number** = median µs; **N/A**
**Takeaways:**
- **`ta`** is 20350× slower on ATR, CCI, ADX, MFI (O(n²) Python loops).
- **ferro-ta** is typically 24× faster than **pandas-ta** across indicators.
- **ferro-ta** is often materially faster than **pandas-ta** on the checked-in 100k-bar table.
- **TA-Lib** and **Tulipy** (C extensions) are strong; ferro-ta is competitive and avoids native dependencies.
---
@@ -160,9 +181,14 @@ uv run pytest benchmarks/test_speed.py --benchmark-only -k "test_large_dataset"
# Regenerate the Speed Comparison markdown table from results.json
uv run python benchmarks/benchmark_table.py
# TA-Lib head-to-head with machine-readable summary + git/runtime metadata
# TA-Lib head-to-head with machine/runtime/build metadata, per-run samples,
# variance stats, and Python-tracked allocation snapshots
uv run python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
# Selected derivatives analytics comparison (BSM price, IV, Greeks, Black-76)
# against built-in analytical references plus optional installed libraries
uv run python benchmarks/bench_derivatives_compare.py --sizes 1000 10000 --json benchmark_derivatives_compare.json
# Optional regression check used in CI
uv run python benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json
@@ -184,6 +210,25 @@ uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/
Without `uv`: use `pytest` and `python` from the same environment where `ferro_ta` and optional libs (e.g. `talib`, `pandas_ta`, `ta`, `tulipy`, `finta`) are installed.
### Derivatives analytics
`benchmarks/bench_derivatives_compare.py` focuses on selected options-analytics
paths rather than the full surface area:
- `BSM` call pricing
- call implied-volatility recovery
- call Greeks
- `Black-76` call pricing
The script always includes two analytical baselines:
- `reference_numpy` — pure NumPy formulas with vectorized IV bisection
- `reference_python_loop` — scalar `math`-based reference for sanity checking
If `py_vollib` is installed, it is added automatically as an extra baseline.
The output JSON includes runtime/build metadata, per-run timing samples,
variance stats, and Python-tracked peak allocation snapshots.
### WASM
From the `wasm/` directory:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+210 -112
View File
@@ -1,37 +1,34 @@
"""
ferro_ta vs TA-Lib speed comparison.
Measures throughput (M bars/s) for both libraries on the same data and parameters,
and reports speedup (talib_time / ferro_ta_time; > 1 means ferro_ta is faster).
Measures throughput (M bars/s) for both libraries on the same synthetic data
and parameters. The output is intentionally evidence-heavy:
Requirements:
pip install ta-lib # or conda install ta-lib
- median timings
- per-run timing samples
- variability stats
- Python-tracked peak allocation snapshots
- machine, runtime, and build metadata
Run:
python benchmarks/bench_vs_talib.py
python benchmarks/bench_vs_talib.py --json results.json
python benchmarks/bench_vs_talib.py --sizes 10000 100000 # default: 10k, 100k, 1M
If ta-lib is not installed, the script still runs and reports ferro_ta timings only (no speedup).
Methodology: same synthetic data, same parameters, median of 7 runs after warmup.
Environment: document Python version and OS when publishing results.
This is meant to support a narrow claim: ferro-ta is often faster on selected
indicators, not universally faster.
"""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
import json
import platform
import subprocess
import math
import sys
import time
import tracemalloc
from typing import Any
import numpy as np
try:
import talib # noqa: F401
TALIB_AVAILABLE = True
except ImportError:
TALIB_AVAILABLE = False
@@ -39,6 +36,11 @@ except ImportError:
import ferro_ta
try:
from benchmarks.metadata import benchmark_metadata, package_versions
except ModuleNotFoundError: # pragma: no cover - script execution fallback
from metadata import benchmark_metadata, package_versions
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
@@ -46,100 +48,138 @@ import ferro_ta
N_WARMUP = 1
N_RUNS = 7
DEFAULT_SIZES = [10_000, 100_000, 1_000_000]
TIE_EPSILON = 0.05
_rng = np.random.default_rng(42)
def _git_info() -> dict[str, Any]:
"""Best-effort git metadata for benchmark reproducibility."""
try:
commit = subprocess.check_output(
["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL
).strip()
except Exception:
commit = None
try:
dirty = bool(
subprocess.check_output(
["git", "status", "--porcelain"],
text=True,
stderr=subprocess.DEVNULL,
).strip()
)
except Exception:
dirty = None
return {"commit": commit, "dirty": dirty}
def _median(values: list[float]) -> float:
ordered = sorted(values)
mid = len(ordered) // 2
if len(ordered) % 2:
return ordered[mid]
return (ordered[mid - 1] + ordered[mid]) / 2.0
def _runtime_info() -> dict[str, Any]:
def _summary_stats(samples_ms: list[float]) -> dict[str, float]:
if not samples_ms:
return {
"median_ms": 0.0,
"mean_ms": 0.0,
"min_ms": 0.0,
"max_ms": 0.0,
"stddev_ms": 0.0,
"cv_pct": 0.0,
}
mean_ms = sum(samples_ms) / len(samples_ms)
variance = (
sum((sample - mean_ms) ** 2 for sample in samples_ms) / (len(samples_ms) - 1)
if len(samples_ms) > 1
else 0.0
)
stddev_ms = math.sqrt(variance)
cv_pct = (stddev_ms / mean_ms * 100.0) if mean_ms else 0.0
return {
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
"python_version": sys.version.split()[0],
"platform": platform.platform(),
"machine": platform.machine(),
"median_ms": round(_median(samples_ms), 4),
"mean_ms": round(mean_ms, 4),
"min_ms": round(min(samples_ms), 4),
"max_ms": round(max(samples_ms), 4),
"stddev_ms": round(stddev_ms, 4),
"cv_pct": round(cv_pct, 3),
}
def _outcome(speedup: float) -> str:
if speedup > 1.0 + TIE_EPSILON:
return "ferro_ta_win"
if speedup < 1.0 - TIE_EPSILON:
return "talib_win"
return "tie"
def _summary_for_size(results: list[dict[str, Any]], size: int) -> dict[str, Any]:
rows = [r for r in results if r.get("size") == size and "speedup" in r]
rows = [row for row in results if row.get("size") == size and "speedup" in row]
if not rows:
return {"size": size, "rows": 0}
speedups = [float(r["speedup"]) for r in rows]
wins = sum(1 for s in speedups if s > 1.0)
speedups_sorted = sorted(speedups)
mid = len(speedups_sorted) // 2
if len(speedups_sorted) % 2:
median = speedups_sorted[mid]
else:
median = (speedups_sorted[mid - 1] + speedups_sorted[mid]) / 2.0
speedups = [float(row["speedup"]) for row in rows]
wins = sum(1 for row in rows if row.get("outcome") == "ferro_ta_win")
ties = sum(1 for row in rows if row.get("outcome") == "tie")
losses = sum(1 for row in rows if row.get("outcome") == "talib_win")
return {
"size": size,
"rows": len(rows),
"wins": wins,
"win_rate": wins / len(rows),
"median_speedup": round(median, 4),
"ties": ties,
"losses": losses,
"win_rate": round(wins / len(rows), 4),
"non_loss_rate": round((wins + ties) / len(rows), 4),
"median_speedup": round(_median(speedups), 4),
"min_speedup": round(min(speedups), 4),
"max_speedup": round(max(speedups), 4),
"talib_wins_or_ties": [
row["indicator"]
for row in rows
if row.get("outcome") in {"talib_win", "tie"}
],
}
def _synthetic_ohlcv(n: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# Generate OHLCV so that ta crate DataItem constraints hold: low >= 0, volume >= 0,
# and low <= open, close <= high, high >= open (see ta DataItemBuilder::build).
def _synthetic_ohlcv(
n: int,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# Generate OHLCV so that ta crate DataItem constraints hold: low >= 0,
# volume >= 0, and low <= open, close <= high, high >= open.
close = 100.0 + np.cumsum(_rng.standard_normal(n) * 0.5)
open_ = close + _rng.standard_normal(n) * 0.2
high = np.maximum(open_, close) + np.abs(_rng.standard_normal(n) * 0.3)
low = np.minimum(open_, close) - np.abs(_rng.standard_normal(n) * 0.3)
# Enforce high >= low and low >= 0 (ta requires non-negative prices)
high = np.maximum(high, low)
low = np.maximum(low, 0.0)
high = np.maximum(high, low) # again after clamping low
high = np.maximum(high, low)
open_ = np.clip(open_, low, high)
close = np.clip(close, low, high)
volume = np.abs(_rng.standard_normal(n) * 1_000_000) + 500_000
return open_, high, low, close, volume
def _median_time_ms(fn, *args, **kwargs) -> float:
def _timed_runs_ms(fn, *args, **kwargs) -> list[float]:
for _ in range(N_WARMUP):
fn(*args, **kwargs)
times = []
samples_ms: list[float] = []
for _ in range(N_RUNS):
t0 = time.perf_counter()
fn(*args, **kwargs)
times.append((time.perf_counter() - t0) * 1000)
times.sort()
return times[len(times) // 2]
samples_ms.append((time.perf_counter() - t0) * 1000.0)
return samples_ms
def _python_peak_bytes(fn, *args, **kwargs) -> int | None:
try:
tracemalloc.start()
tracemalloc.reset_peak()
fn(*args, **kwargs)
_, peak = tracemalloc.get_traced_memory()
return int(peak)
except Exception:
return None
finally:
tracemalloc.stop()
def _throughput_m_bars_s(size: int, median_ms: float) -> float:
if median_ms <= 0:
return 0.0
return (size / 1e6) / (median_ms / 1000.0)
# ---------------------------------------------------------------------------
# Benchmarked callables
# ---------------------------------------------------------------------------
# Each entry: (label, ferro_ta_callable, talib_callable, needs_ohlcv)
# ferro_ta_callable / talib_callable receive (open_, high, low, close, volume) and size;
# they return (args, ft_kwargs, ta_kwargs) or we use a simpler convention:
# we pass (o, h, l, c, v) and size; each runner knows how to slice and call.
def _run_ft_sma(o, h, l, c, v, n):
return ferro_ta.SMA(c[:n], timeperiod=14)
@@ -236,7 +276,6 @@ def _run_ta_wma(o, h, l, c, v, n):
return talib.WMA(c[:n], timeperiod=14)
# List of (indicator_name, ft_runner, ta_runner); skip 1M for very slow indicators if needed
COMPARISON_CASES = [
("SMA", _run_ft_sma, _run_ta_sma),
("EMA", _run_ft_ema, _run_ta_ema),
@@ -252,14 +291,14 @@ COMPARISON_CASES = [
("WMA", _run_ft_wma, _run_ta_wma),
]
# For STOCH/ADX and other heavier indicators, optionally skip 1M to keep runtime reasonable
SKIP_1M_FOR = {"STOCH", "ADX"}
def run_comparison(sizes: list[int], json_path: str | None) -> list[dict[str, Any]]:
max_size = max(sizes)
open_, high, low, close, volume = _synthetic_ohlcv(max_size)
results = []
results: list[dict[str, Any]] = []
col_label = 10
col_size = 10
col_ft_ms = 12
@@ -269,12 +308,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
+135 -22
View File
@@ -1,56 +1,167 @@
from __future__ import annotations
import hashlib
import os
import platform
import re
import subprocess
import sys
from datetime import datetime, timezone
from importlib import metadata as importlib_metadata
from pathlib import Path
from typing import Any
def git_info() -> dict[str, Any]:
"""Best-effort git metadata for reproducible benchmark artifacts."""
try:
import tomllib
except ImportError: # pragma: no cover
try:
commit = subprocess.check_output(
["git", "rev-parse", "HEAD"], text=True, stderr=subprocess.DEVNULL
).strip()
except Exception:
commit = None
import tomli as tomllib # type: ignore[no-redef]
except ImportError: # pragma: no cover
tomllib = None # type: ignore[assignment]
try:
dirty = bool(
subprocess.check_output(
["git", "status", "--porcelain"],
text=True,
stderr=subprocess.DEVNULL,
).strip()
)
except Exception:
dirty = None
_ROOT = Path(__file__).resolve().parent.parent
def _run_cmd(command: list[str]) -> str | None:
try:
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
return subprocess.check_output(
command,
text=True,
stderr=subprocess.DEVNULL,
).strip()
except Exception:
branch = None
return None
return {"commit": commit, "dirty": dirty, "branch": branch}
def _read_toml(path: Path) -> dict[str, Any] | None:
if tomllib is None or not path.exists():
return None
try:
with path.open("rb") as handle:
return tomllib.load(handle)
except Exception:
return None
def _cpu_model() -> str | None:
if sys.platform == "darwin":
return (
_run_cmd(["sysctl", "-n", "machdep.cpu.brand_string"])
or _run_cmd(["sysctl", "-n", "hw.model"])
or platform.processor()
or None
)
if sys.platform.startswith("linux"):
cpuinfo = Path("/proc/cpuinfo")
if cpuinfo.exists():
text = cpuinfo.read_text(encoding="utf-8", errors="ignore")
for pattern in (r"model name\s+:\s+(.+)", r"Hardware\s+:\s+(.+)"):
match = re.search(pattern, text)
if match:
return match.group(1).strip()
return platform.processor() or None
if sys.platform.startswith("win"):
return os.environ.get("PROCESSOR_IDENTIFIER") or platform.processor() or None
return platform.processor() or None
def _total_memory_bytes() -> int | None:
if sys.platform == "darwin":
raw = _run_cmd(["sysctl", "-n", "hw.memsize"])
return int(raw) if raw and raw.isdigit() else None
if sys.platform.startswith("linux"):
meminfo = Path("/proc/meminfo")
if meminfo.exists():
text = meminfo.read_text(encoding="utf-8", errors="ignore")
match = re.search(r"MemTotal:\s+(\d+)\s+kB", text)
if match:
return int(match.group(1)) * 1024
return None
if sys.platform.startswith("win"): # pragma: no cover
try:
import ctypes
class MEMORYSTATUSEX(ctypes.Structure):
_fields_ = [
("dwLength", ctypes.c_ulong),
("dwMemoryLoad", ctypes.c_ulong),
("ullTotalPhys", ctypes.c_ulonglong),
("ullAvailPhys", ctypes.c_ulonglong),
("ullTotalPageFile", ctypes.c_ulonglong),
("ullAvailPageFile", ctypes.c_ulonglong),
("ullTotalVirtual", ctypes.c_ulonglong),
("ullAvailVirtual", ctypes.c_ulonglong),
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
]
status = MEMORYSTATUSEX()
status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status))
return int(status.ullTotalPhys)
except Exception:
return None
return None
def _cargo_release_profile() -> dict[str, Any] | None:
cargo_toml = _read_toml(_ROOT / "Cargo.toml")
if not cargo_toml:
return None
profile = cargo_toml.get("profile", {}).get("release")
return profile if isinstance(profile, dict) else None
def git_info() -> dict[str, Any]:
"""Best-effort git metadata for reproducible benchmark artifacts."""
return {
"commit": _run_cmd(["git", "rev-parse", "HEAD"]),
"dirty": bool(_run_cmd(["git", "status", "--porcelain"]) or ""),
"branch": _run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]),
}
def runtime_info() -> dict[str, Any]:
return {
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
"python_version": sys.version.split()[0],
"python_implementation": platform.python_implementation(),
"python_executable": sys.executable,
"platform": platform.platform(),
"system": platform.system(),
"release": platform.release(),
"machine": platform.machine(),
"processor": platform.processor() or None,
"cpu_model": _cpu_model(),
"cpu_count_logical": os.cpu_count(),
"total_memory_bytes": _total_memory_bytes(),
}
def build_info() -> dict[str, Any]:
return {
"rustc": _run_cmd(["rustc", "-Vv"]),
"cargo": _run_cmd(["cargo", "-VV"]) or _run_cmd(["cargo", "-V"]),
"cargo_release_profile": _cargo_release_profile(),
"rustflags": os.environ.get("RUSTFLAGS"),
"cargo_build_rustflags": os.environ.get("CARGO_BUILD_RUSTFLAGS"),
"maturin_flags": os.environ.get("MATURIN_EXTRA_ARGS"),
}
def package_versions(*names: str) -> dict[str, str | None]:
versions: dict[str, str | None] = {}
for name in names:
try:
versions[name] = importlib_metadata.version(name)
except importlib_metadata.PackageNotFoundError:
versions[name] = None
return versions
def file_info(path: str | Path) -> dict[str, Any]:
file_path = Path(path)
data = file_path.read_bytes()
@@ -71,6 +182,8 @@ def benchmark_metadata(
"suite": suite,
"runtime": runtime_info(),
"git": git_info(),
"build": build_info(),
"packages": package_versions("numpy", "ferro-ta"),
}
if fixtures:
metadata["fixtures"] = [file_info(path) for path in fixtures]
+6 -5
View File
@@ -1,5 +1,5 @@
{% set name = "ferro-ta" %}
{% set version = "1.0.0" %}
{% set version = "1.0.3" %}
package:
name: {{ name|lower }}
@@ -39,11 +39,12 @@ about:
home: https://github.com/pratikbhadane24/ferro-ta
license: MIT
license_family: MIT
summary: A fast Technical Analysis library TA-Lib alternative powered by Rust and PyO3
summary: Rust-powered Python technical analysis library with a TA-Lib-compatible API
description: |
ferro-ta is a drop-in TA-Lib alternative with pre-compiled wheels for all
major platforms. It provides 155+ indicators via a Rust core and PyO3
bindings, with optional pandas / streaming APIs.
ferro-ta is a Rust-powered Python technical analysis library with a
TA-Lib-compatible API and pre-compiled wheels for the supported platforms.
It provides 155+ indicators via a Rust core and PyO3 bindings, with
optional pandas and streaming APIs.
doc_url: https://github.com/pratikbhadane24/ferro-ta
dev_url: https://github.com/pratikbhadane24/ferro-ta
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+47
View File
@@ -0,0 +1,47 @@
Adjacent Tooling
================
These modules are useful, but they are secondary to ferro-ta's core identity as
a Python technical analysis library.
.. list-table::
:header-rows: 1
* - Area
- Status
- What it is
* - Derivatives analytics
- Adjacent
- Options pricing, Greeks, implied volatility helpers, futures basis,
curve, and roll utilities. See :doc:`derivatives`.
* - Agent workflow wrappers
- Adjacent
- Tool and workflow helpers for agent-style integrations. See
`docs/agentic.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_.
* - MCP server
- Experimental or adjacent
- Exposes selected ferro-ta capabilities to MCP-compatible clients. See
`docs/mcp.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_.
* - WASM package
- Experimental
- Browser and Node.js package with a smaller indicator subset. See
`wasm/README.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/wasm/README.md>`_.
* - GPU backend
- Experimental
- Optional PyTorch-backed acceleration for a limited subset of indicators.
See `docs/gpu-backend.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/gpu-backend.md>`_.
* - Plugin system
- Experimental
- Registry and plugin packaging model for custom indicators. See
:doc:`plugins`.
How to read the project
-----------------------
When evaluating ferro-ta:
- Start with the core library docs, migration guide, support matrix, and benchmarks.
- Treat adjacent tooling as opt-in layers, not as proof that the core indicator
library is broader or more stable than it is.
- Check the release notes and stability policy before depending on experimental
surfaces in production.
+122 -21
View File
@@ -1,20 +1,130 @@
Benchmarks
==========
The authoritative benchmark workflow is in ``benchmarks/``:
The benchmark suite is meant to support a narrow claim: ferro-ta is often
faster on selected indicators, and the evidence is published in a reproducible
form.
What is published
-----------------
The authoritative benchmark workflow lives in ``benchmarks/``:
- Cross-library speed suite: ``benchmarks/test_speed.py``
- Cross-library accuracy suite: ``benchmarks/test_accuracy.py``
- TA-Lib head-to-head speed script: ``benchmarks/bench_vs_talib.py``
- TA-Lib head-to-head script: ``benchmarks/bench_vs_talib.py``
- Table generation from benchmark JSON: ``benchmarks/benchmark_table.py``
- Perf-contract artifact bundle: ``benchmarks/run_perf_contract.py``
Run the cross-library speed suite on 100,000 bars:
Latest checked-in TA-Lib artifact
---------------------------------
The current checked-in TA-Lib comparison artifact benchmarks contiguous
``float64`` arrays at 10k and 100k bars on an ``Apple M3 Max`` with 14 logical
cores, about 38.7 GB RAM, ``CPython 3.13.5``, and ``Rust 1.91.1`` using the
default release profile (``lto = true``, ``codegen-units = 1``).
Summary from ``benchmarks/artifacts/latest/benchmark_vs_talib.json``:
.. list-table::
:header-rows: 1
* - Size
- Rows
- ferro-ta wins
- Median speedup
- TA-Lib wins or ties
* - ``10,000``
- 12
- 6
- ``1.0850x``
- ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV``
* - ``100,000``
- 12
- 6
- ``1.0784x``
- ``EMA``, ``RSI``, ``ATR``, ``STOCH``, ``ADX``, ``OBV``
Examples from the 100k-bar run:
.. list-table::
:header-rows: 1
* - Indicator
- ferro-ta
- TA-Lib
- Speedup
- Read
* - ``SMA``
- ``0.0985 ms``
- ``0.2241 ms``
- ``2.2751x``
- clear ferro-ta win
* - ``BBANDS``
- ``0.2122 ms``
- ``0.4966 ms``
- ``2.3402x``
- clear ferro-ta win
* - ``MACD``
- ``0.5152 ms``
- ``0.7111 ms``
- ``1.3801x``
- ferro-ta win
* - ``STOCH``
- ``1.7064 ms``
- ``0.7603 ms``
- ``0.4455x``
- TA-Lib win
* - ``ADX``
- ``0.7910 ms``
- ``0.5769 ms``
- ``0.7294x``
- TA-Lib win
* - ``ATR``
- ``0.5087 ms``
- ``0.5147 ms``
- ``1.0118x``
- tie on this machine
Methodology notes
-----------------
- The head-to-head script uses the same synthetic OHLCV generator, the same
parameters, and the same contiguous ``float64`` array layout for both
libraries.
- Reported speedup is ``TA-Lib median time / ferro-ta median time``.
- The script uses 1 warmup run and 7 measured runs per case, and now records
the full per-run timing samples, not just one selected number.
- Published JSON artifacts include machine/runtime metadata, git metadata, Rust
toolchain and build-profile metadata, per-run variance statistics, and
Python-tracked peak allocation snapshots.
- Allocation snapshots are based on ``tracemalloc`` and capture Python-tracked
allocations only; they are not full native RSS profiles.
- If your workload uses non-contiguous arrays, different dtypes, or different
batch sizes, benchmark that exact workload. Those factors can materially
change the result.
Reproduce the TA-Lib comparison
-------------------------------
.. code-block:: bash
pip install ta-lib
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
The JSON output is the main artifact to review when publishing performance
claims.
Cross-library suite
-------------------
Run the broader speed suite on 100,000 bars:
.. code-block:: bash
uv run pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json -v
Selected results on a modern CPU (100,000 bars):
Selected throughput examples from the checked-in table:
.. list-table::
:header-rows: 1
@@ -38,25 +148,16 @@ Selected results on a modern CPU (100,000 bars):
* - ``STOCH``
- 33 M bars/s
Multi-size and JSON output
--------------------------
Perf-contract artifacts
-----------------------
To build the markdown comparison table from the JSON output:
Use the perf-contract runner when you want a compact, machine-readable artifact
bundle for single-series latency, batch throughput, streaming throughput, and
hotspot attribution:
.. code-block:: bash
uv run python benchmarks/benchmark_table.py
uv run python benchmarks/run_perf_contract.py --output-dir benchmarks/artifacts/latest
Comparison with TA-Lib
----------------------
To measure speedup vs TA-Lib on the same data and parameters, run:
.. code-block:: bash
pip install ta-lib
python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json
See the README “Performance vs TA-Lib” section for methodology and a
representative comparison table. The script prints a table of median times and
speedup (TA-Lib time / ferro_ta time); use ``--json out.json`` to save results.
See ``benchmarks/README.md`` for the detailed benchmark playbook and the
checked-in comparison tables.
+32 -45
View File
@@ -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.103.13 classifiers
For the canonical project changelog, including the full per-version details,
see `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_.
+32 -2
View File
@@ -4,7 +4,17 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
import re
import sys
from pathlib import Path
try:
import tomllib
except ImportError: # pragma: no cover
try:
import tomli as tomllib # type: ignore[no-redef]
except ImportError: # pragma: no cover
tomllib = None # type: ignore[assignment]
# Add the python source directory so autodoc can import ferro_ta
# Only add if ferro_ta is not already installed (e.g. from a wheel in CI)
@@ -17,8 +27,28 @@ except ImportError:
project = "ferro-ta"
copyright = "2024, pratikbhadane24"
author = "pratikbhadane24"
# Version from env (e.g. set in CI from git tag) or default
release = os.environ.get("FERRO_TA_VERSION", "1.0.0")
def _default_release() -> str:
if tomllib is None:
return "0+unknown"
pyproject_toml = Path(__file__).resolve().parents[1] / "pyproject.toml"
try:
if tomllib is not None:
with pyproject_toml.open("rb") as handle:
data = tomllib.load(handle)
return data.get("project", {}).get("version", "0+unknown")
text = pyproject_toml.read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
if match:
return match.group(1)
return "0+unknown"
except Exception:
return "0+unknown"
# Version from env (e.g. set in CI from git tag) or default to pyproject.toml
release = os.environ.get("FERRO_TA_VERSION", _default_release())
version = release
# -- General configuration ----------------------------------------------------
+38 -15
View File
@@ -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 <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_
- MCP server for Cursor/Claude integration — see `MCP guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_
- Sphinx documentation
- WASM, plugins, and other optional surfaces — see :doc:`adjacent_tooling`
Installation
~~~~~~~~~~~~
@@ -70,11 +93,11 @@ Further Reading
- `Architecture <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/architecture.md>`_ — Rust/Python layout, two-crate design, binding flow.
- `Performance Guide <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/performance.md>`_ — when to use raw numpy vs pandas/polars, batch notes, tips.
- `API Stability <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/stability.md>`_ — stability tiers, versioning, and deprecation policy.
- :doc:`support_matrix` — parity status, tested wheel targets, supported Python versions, and experimental modules.
- `Rust-First Policy <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/rust_first.md>`_ — all compute logic belongs in Rust; how to add new indicators.
- `Out-of-Core Execution <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/out-of-core.md>`_ — chunked processing and Dask integration.
- :doc:`derivatives` — IV helpers, options pricing/Greeks/IV, futures analytics, strategy schemas, and payoff helpers.
- `Agentic Workflow <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_ — tools.py, workflow.py, LangChain integration.
- `MCP Server <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_ — run ferro-ta as an MCP server in Cursor/Claude.
- :doc:`adjacent_tooling` — optional surfaces such as derivatives, MCP, WASM, GPU, plugins, and agent-oriented integrations.
Indices and tables
==================
+117
View File
@@ -0,0 +1,117 @@
Support Matrix
==============
The primary product is the Python technical analysis library: TA-Lib-style
indicator calls backed by a Rust implementation.
Indicator compatibility
-----------------------
.. list-table::
:header-rows: 1
* - Status
- Scope
- Notes
* - Exact parity
- Common TA-Lib-compatible indicators such as ``SMA``, ``WMA``,
``BBANDS``, ``RSI``, ``ATR``, ``NATR``, ``CCI``, ``STOCH``,
``STOCHRSI``, and most candlestick patterns
- Matches TA-Lib numerically within floating-point tolerance in the
current comparison suite.
* - Approximate parity
- EMA-family indicators (``EMA``, ``DEMA``, ``TEMA``, ``T3``, ``MACD``),
``MAMA`` / ``FAMA``, ``SAR`` / ``SAREXT``, and ``HT_*`` cycle
indicators
- Same API and intended use, with convergence-window or floating-point
differences documented in the migration guide.
* - Intentionally different
- ferro-ta-only indicators such as ``VWAP``, ``SUPERTREND``,
``ICHIMOKU``, ``DONCHIAN``, ``KELTNER_CHANNELS``, ``HULL_MA``,
``CHANDELIER_EXIT``, ``VWMA``, and ``CHOPPINESS_INDEX``
- These extend the library beyond TA-Lib and are not parity claims.
For migration details and known indicator-specific differences, see
:doc:`migration_talib`.
Module status
-------------
.. list-table::
:header-rows: 1
* - Surface
- Status
- Notes
* - Top-level indicators and category submodules
- Stable core
- This is the main supported surface of the project.
* - ``ferro_ta.batch``
- Supported
- Public API is supported; internal dispatch may evolve.
* - ``ferro_ta.streaming``
- Supported, still evolving
- Suitable for live workflows; some API details are still marked
experimental in the stability policy.
* - ``ferro_ta.extended``
- Supported extension
- Useful indicators beyond TA-Lib, but not part of drop-in parity claims.
* - ``ferro_ta.analysis.*``
- Adjacent tooling
- Useful analytics helpers, but not the primary product story.
* - MCP, WASM, GPU, plugin, and agent-oriented tooling
- Experimental or adjacent
- Evaluate these independently from the core indicator library.
Supported Python versions
-------------------------
.. list-table::
:header-rows: 1
* - Python
- Status
* - 3.13
- Supported and tested in CI
* - 3.12
- Supported and tested in CI
* - 3.11
- Supported and tested in CI
* - 3.10
- Supported and tested in CI
* - < 3.10
- Not supported
Tested wheel targets
--------------------
.. list-table::
:header-rows: 1
* - OS
- Architecture
- Wheel status
* - Linux
- ``x86_64`` (manylinux2014 / ``manylinux_2_17``)
- Tested wheel target
* - macOS
- ``universal2``
- Tested wheel target for Intel and Apple Silicon
* - Windows
- ``x86_64``
- Tested wheel target
For source builds, packaging details, and platform notes, see
`PLATFORMS.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/PLATFORMS.md>`_.
Release status
--------------
These docs track package version ``1.0.3``.
- Release notes by version: :doc:`changelog`
- Canonical project changelog: `CHANGELOG.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CHANGELOG.md>`_
- Stability policy: `docs/stability.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/stability.md>`_
If the package version, docs version, or support matrix disagree, treat that as
a documentation bug.
+71
View File
@@ -0,0 +1,71 @@
{
"metadata": {
"suite": "batch",
"runtime": {
"generated_at_utc": "2026-03-23T21:08:30.877702+00:00",
"python_version": "3.12.11",
"platform": "macOS-26.3.1-arm64-arm-64bit",
"machine": "arm64",
"processor": "arm"
},
"git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
"dirty": true,
"branch": "feat/performace-1.0.2"
},
"dataset": {
"n_samples": 20000,
"n_series": 32,
"total_bars": 640000,
"seed": 42
}
},
"results": [
{
"indicator": "SMA",
"parallel_ms": 1.9615,
"sequential_ms": 2.0423,
"loop_ms": 0.8865,
"parallel_speedup_vs_loop": 0.452,
"sequential_speedup_vs_loop": 0.4341
},
{
"indicator": "RSI",
"parallel_ms": 2.1731,
"sequential_ms": 4.3225,
"loop_ms": 3.2043,
"parallel_speedup_vs_loop": 1.4745,
"sequential_speedup_vs_loop": 0.7413
},
{
"indicator": "ATR",
"parallel_ms": 4.2249,
"sequential_ms": 6.6175,
"loop_ms": 4.0382,
"parallel_speedup_vs_loop": 0.9558,
"sequential_speedup_vs_loop": 0.6102
},
{
"indicator": "ADX",
"parallel_ms": 4.8674,
"sequential_ms": 7.6402,
"loop_ms": 5.1861,
"parallel_speedup_vs_loop": 1.0655,
"sequential_speedup_vs_loop": 0.6788
}
],
"grouped_results": [
{
"case": "close_bundle_3",
"grouped_ms": 0.1878,
"separate_ms": 0.1719,
"speedup_vs_separate": 0.9155
},
{
"case": "hlc_bundle_3",
"grouped_ms": 0.2958,
"separate_ms": 0.4633,
"speedup_vs_separate": 1.5666
}
]
}
+163
View File
@@ -0,0 +1,163 @@
{
"metadata": {
"suite": "indicator_latency",
"runtime": {
"generated_at_utc": "2026-03-23T21:08:30.515962+00:00",
"python_version": "3.12.11",
"platform": "macOS-26.3.1-arm64-arm-64bit",
"machine": "arm64",
"processor": "arm"
},
"git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
"dirty": true,
"branch": "feat/performace-1.0.2"
},
"fixtures": [
{
"path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz",
"size_bytes": 75586,
"sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c"
}
],
"dataset": {
"fixture": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz",
"bars": 2000,
"rounds": 5
}
},
"results": [
{
"name": "VAR_20",
"inputs": "close",
"kwargs": {
"timeperiod": 20
},
"elapsed_ms": 0.0202
},
{
"name": "WILLR_14",
"inputs": "hlc",
"kwargs": {
"timeperiod": 14
},
"elapsed_ms": 0.0183
},
{
"name": "STOCH",
"inputs": "hlc",
"kwargs": {},
"elapsed_ms": 0.0181
},
{
"name": "ADX_14",
"inputs": "hlc",
"kwargs": {
"timeperiod": 14
},
"elapsed_ms": 0.0147
},
{
"name": "CCI_14",
"inputs": "hlc",
"kwargs": {
"timeperiod": 14
},
"elapsed_ms": 0.0144
},
{
"name": "MACD",
"inputs": "close",
"kwargs": {},
"elapsed_ms": 0.0132
},
{
"name": "ATR_14",
"inputs": "hlc",
"kwargs": {
"timeperiod": 14
},
"elapsed_ms": 0.0108
},
{
"name": "RSI_14",
"inputs": "close",
"kwargs": {
"timeperiod": 14
},
"elapsed_ms": 0.0102
},
{
"name": "STDDEV_20",
"inputs": "close",
"kwargs": {
"timeperiod": 20
},
"elapsed_ms": 0.0086
},
{
"name": "BETA_5",
"inputs": "pair_hl",
"kwargs": {
"timeperiod": 5
},
"elapsed_ms": 0.008
},
{
"name": "CORREL_30",
"inputs": "pair_hl",
"kwargs": {
"timeperiod": 30
},
"elapsed_ms": 0.0068
},
{
"name": "EMA_20",
"inputs": "close",
"kwargs": {
"timeperiod": 20
},
"elapsed_ms": 0.0052
},
{
"name": "BBANDS_20",
"inputs": "close",
"kwargs": {
"timeperiod": 20
},
"elapsed_ms": 0.005
},
{
"name": "TSF_14",
"inputs": "close",
"kwargs": {
"timeperiod": 14
},
"elapsed_ms": 0.0048
},
{
"name": "LINEARREG_14",
"inputs": "close",
"kwargs": {
"timeperiod": 14
},
"elapsed_ms": 0.0046
},
{
"name": "LINEARREG_SLOPE_14",
"inputs": "close",
"kwargs": {
"timeperiod": 14
},
"elapsed_ms": 0.0045
},
{
"name": "SMA_20",
"inputs": "close",
"kwargs": {
"timeperiod": 20
},
"elapsed_ms": 0.0027
}
]
}
+52
View File
@@ -0,0 +1,52 @@
{
"metadata": {
"suite": "perf_contract",
"runtime": {
"generated_at_utc": "2026-03-23T21:09:13.077731+00:00",
"python_version": "3.12.11",
"platform": "macOS-26.3.1-arm64-arm-64bit",
"machine": "arm64",
"processor": "arm"
},
"git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
"dirty": true,
"branch": "feat/performace-1.0.2"
},
"fixtures": [
{
"path": "/Users/pratikbhadane/Work/Projects/ferro-ta/benchmarks/fixtures/canonical_ohlcv.npz",
"size_bytes": 75586,
"sha256": "60192f8349fb06cd59ef7f70fd77aa8280399e819d7cc5eed3ca95cf5ee1a89c"
}
],
"output_dir": "perf-contract"
},
"artifacts": {
"indicator_latency": {
"path": "perf-contract/indicator_latency.json",
"size_bytes": 3212,
"sha256": "1e7f844fe6eb467f07bbb1e4eb918c56861e8cf819ab4802b96e033c6d2570c4"
},
"batch": {
"path": "perf-contract/batch.json",
"size_bytes": 1679,
"sha256": "202d67b24a88184473f93cec9f866ca34ff60721068e15e7e5d23962f006d169"
},
"streaming": {
"path": "perf-contract/streaming.json",
"size_bytes": 1939,
"sha256": "04476c3d95a9e7d87e40331b28c5ccad9a7aa5abcfc47fb6ac7e929365f68554"
},
"runtime_hotspots": {
"path": "perf-contract/runtime_hotspots.json",
"size_bytes": 2365,
"sha256": "d37a58bc88def9f1525b9b0f64f0bd7b9c0df9ed4cae260c833697f6a1689ca8"
},
"simd": {
"path": "perf-contract/simd.json",
"size_bytes": 7670,
"sha256": "3f9b341a8206698ed172650752d808621c9b5218f42a2373f0f4485496197b4c"
}
}
}
+96
View File
@@ -0,0 +1,96 @@
{
"metadata": {
"suite": "runtime_hotspots",
"runtime": {
"generated_at_utc": "2026-03-23T21:08:34.436165+00:00",
"python_version": "3.12.11",
"platform": "macOS-26.3.1-arm64-arm-64bit",
"machine": "arm64",
"processor": "arm"
},
"git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
"dirty": true,
"branch": "feat/performace-1.0.2"
},
"dataset": {
"price_bars": 20000,
"iv_bars": 50000,
"window": 252
}
},
"results": [
{
"category": "python_analysis",
"name": "iv_zscore",
"fast_ms": 28.857,
"reference_ms": 897.0178,
"speedup_vs_reference": 31.0849,
"share_of_suite_pct": 70.0
},
{
"category": "python_analysis",
"name": "iv_rank",
"fast_ms": 10.8496,
"reference_ms": 199.4376,
"speedup_vs_reference": 18.3821,
"share_of_suite_pct": 26.32
},
{
"category": "python_analysis",
"name": "iv_percentile",
"fast_ms": 0.9124,
"reference_ms": 79.6557,
"speedup_vs_reference": 87.3019,
"share_of_suite_pct": 2.21
},
{
"category": "ffi_grouping",
"name": "feature_matrix",
"fast_ms": 0.2411,
"reference_ms": 0.2215,
"speedup_vs_reference": 0.9186,
"share_of_suite_pct": 0.58
},
{
"category": "ffi_grouping",
"name": "compute_many_close",
"fast_ms": 0.1563,
"reference_ms": 0.1498,
"speedup_vs_reference": 0.9587,
"share_of_suite_pct": 0.38
},
{
"category": "rust_kernel",
"name": "BETA",
"fast_ms": 0.0694,
"reference_ms": 159.2715,
"speedup_vs_reference": 2294.4163,
"share_of_suite_pct": 0.17
},
{
"category": "rust_kernel",
"name": "CORREL",
"fast_ms": 0.0555,
"reference_ms": 158.2775,
"speedup_vs_reference": 2851.8468,
"share_of_suite_pct": 0.13
},
{
"category": "rust_kernel",
"name": "LINEARREG",
"fast_ms": 0.0414,
"reference_ms": 44.8111,
"speedup_vs_reference": 1081.9491,
"share_of_suite_pct": 0.1
},
{
"category": "rust_kernel",
"name": "TSF",
"fast_ms": 0.0413,
"reference_ms": 46.3799,
"speedup_vs_reference": 1122.1039,
"share_of_suite_pct": 0.1
}
]
}
+285
View File
@@ -0,0 +1,285 @@
{
"metadata": {
"suite": "simd",
"runtime": {
"generated_at_utc": "2026-03-23T21:09:13.028473+00:00",
"python_version": "3.12.11",
"platform": "macOS-26.3.1-arm64-arm-64bit",
"machine": "arm64",
"processor": "arm"
},
"git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
"dirty": true,
"branch": "feat/performace-1.0.2"
},
"dataset": {
"price_bars": 20000,
"iv_bars": 50000,
"window": 252
},
"variants": [
"portable_release",
"simd_release"
]
},
"results": [
{
"name": "compute_many_close",
"category": "ffi_grouping",
"portable_ms": 0.1711,
"simd_ms": 0.1618,
"speedup_simd_vs_portable": 1.0575
},
{
"name": "iv_percentile",
"category": "python_analysis",
"portable_ms": 0.9126,
"simd_ms": 0.9096,
"speedup_simd_vs_portable": 1.0033
},
{
"name": "iv_zscore",
"category": "python_analysis",
"portable_ms": 29.0039,
"simd_ms": 28.9123,
"speedup_simd_vs_portable": 1.0032
},
{
"name": "iv_rank",
"category": "python_analysis",
"portable_ms": 10.8629,
"simd_ms": 10.862,
"speedup_simd_vs_portable": 1.0001
},
{
"name": "BETA",
"category": "rust_kernel",
"portable_ms": 0.0696,
"simd_ms": 0.0696,
"speedup_simd_vs_portable": 1.0
},
{
"name": "CORREL",
"category": "rust_kernel",
"portable_ms": 0.0555,
"simd_ms": 0.0556,
"speedup_simd_vs_portable": 0.9982
},
{
"name": "TSF",
"category": "rust_kernel",
"portable_ms": 0.0414,
"simd_ms": 0.0415,
"speedup_simd_vs_portable": 0.9976
},
{
"name": "LINEARREG",
"category": "rust_kernel",
"portable_ms": 0.0413,
"simd_ms": 0.0416,
"speedup_simd_vs_portable": 0.9928
},
{
"name": "feature_matrix",
"category": "ffi_grouping",
"portable_ms": 0.2543,
"simd_ms": 0.2634,
"speedup_simd_vs_portable": 0.9655
}
],
"reports": {
"portable_release": {
"metadata": {
"suite": "runtime_hotspots",
"runtime": {
"generated_at_utc": "2026-03-23T21:08:38.698373+00:00",
"python_version": "3.12.11",
"platform": "macOS-26.3.1-arm64-arm-64bit",
"machine": "arm64",
"processor": "arm"
},
"git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
"dirty": true,
"branch": "feat/performace-1.0.2"
},
"dataset": {
"price_bars": 20000,
"iv_bars": 50000,
"window": 252
}
},
"results": [
{
"category": "python_analysis",
"name": "iv_zscore",
"fast_ms": 29.0039,
"reference_ms": 903.5384,
"speedup_vs_reference": 31.1523,
"share_of_suite_pct": 70.04
},
{
"category": "python_analysis",
"name": "iv_rank",
"fast_ms": 10.8629,
"reference_ms": 201.621,
"speedup_vs_reference": 18.5606,
"share_of_suite_pct": 26.23
},
{
"category": "python_analysis",
"name": "iv_percentile",
"fast_ms": 0.9126,
"reference_ms": 80.0849,
"speedup_vs_reference": 87.7523,
"share_of_suite_pct": 2.2
},
{
"category": "ffi_grouping",
"name": "feature_matrix",
"fast_ms": 0.2543,
"reference_ms": 0.2222,
"speedup_vs_reference": 0.874,
"share_of_suite_pct": 0.61
},
{
"category": "ffi_grouping",
"name": "compute_many_close",
"fast_ms": 0.1711,
"reference_ms": 0.1413,
"speedup_vs_reference": 0.8257,
"share_of_suite_pct": 0.41
},
{
"category": "rust_kernel",
"name": "BETA",
"fast_ms": 0.0696,
"reference_ms": 162.9168,
"speedup_vs_reference": 2341.2971,
"share_of_suite_pct": 0.17
},
{
"category": "rust_kernel",
"name": "CORREL",
"fast_ms": 0.0555,
"reference_ms": 163.8589,
"speedup_vs_reference": 2950.1793,
"share_of_suite_pct": 0.13
},
{
"category": "rust_kernel",
"name": "TSF",
"fast_ms": 0.0414,
"reference_ms": 49.2846,
"speedup_vs_reference": 1189.9901,
"share_of_suite_pct": 0.1
},
{
"category": "rust_kernel",
"name": "LINEARREG",
"fast_ms": 0.0413,
"reference_ms": 47.5241,
"speedup_vs_reference": 1149.7853,
"share_of_suite_pct": 0.1
}
]
},
"simd_release": {
"metadata": {
"suite": "runtime_hotspots",
"runtime": {
"generated_at_utc": "2026-03-23T21:08:57.755423+00:00",
"python_version": "3.12.11",
"platform": "macOS-26.3.1-arm64-arm-64bit",
"machine": "arm64",
"processor": "arm"
},
"git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
"dirty": true,
"branch": "feat/performace-1.0.2"
},
"dataset": {
"price_bars": 20000,
"iv_bars": 50000,
"window": 252
}
},
"results": [
{
"category": "python_analysis",
"name": "iv_zscore",
"fast_ms": 28.9123,
"reference_ms": 909.2586,
"speedup_vs_reference": 31.4489,
"share_of_suite_pct": 69.98
},
{
"category": "python_analysis",
"name": "iv_rank",
"fast_ms": 10.862,
"reference_ms": 198.2076,
"speedup_vs_reference": 18.2478,
"share_of_suite_pct": 26.29
},
{
"category": "python_analysis",
"name": "iv_percentile",
"fast_ms": 0.9096,
"reference_ms": 78.1232,
"speedup_vs_reference": 85.889,
"share_of_suite_pct": 2.2
},
{
"category": "ffi_grouping",
"name": "feature_matrix",
"fast_ms": 0.2634,
"reference_ms": 0.2219,
"speedup_vs_reference": 0.8425,
"share_of_suite_pct": 0.64
},
{
"category": "ffi_grouping",
"name": "compute_many_close",
"fast_ms": 0.1618,
"reference_ms": 0.155,
"speedup_vs_reference": 0.9583,
"share_of_suite_pct": 0.39
},
{
"category": "rust_kernel",
"name": "BETA",
"fast_ms": 0.0696,
"reference_ms": 161.4576,
"speedup_vs_reference": 2320.3601,
"share_of_suite_pct": 0.17
},
{
"category": "rust_kernel",
"name": "CORREL",
"fast_ms": 0.0556,
"reference_ms": 162.6189,
"speedup_vs_reference": 2923.4861,
"share_of_suite_pct": 0.13
},
{
"category": "rust_kernel",
"name": "LINEARREG",
"fast_ms": 0.0416,
"reference_ms": 48.029,
"speedup_vs_reference": 1155.0143,
"share_of_suite_pct": 0.1
},
{
"category": "rust_kernel",
"name": "TSF",
"fast_ms": 0.0415,
"reference_ms": 47.55,
"speedup_vs_reference": 1145.783,
"share_of_suite_pct": 0.1
}
]
}
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"metadata": {
"suite": "streaming",
"runtime": {
"generated_at_utc": "2026-03-23T21:08:30.968886+00:00",
"python_version": "3.12.11",
"platform": "macOS-26.3.1-arm64-arm-64bit",
"machine": "arm64",
"processor": "arm"
},
"git": {
"commit": "2d5000262f0f1439546bd4872235aae0333880a4",
"dirty": true,
"branch": "feat/performace-1.0.2"
},
"dataset": {
"n_bars": 20000,
"seed": 2026
}
},
"results": [
{
"indicator": "StreamingSMA",
"inputs": "close",
"stream_total_ms": 0.8808,
"batch_total_ms": 0.0153,
"stream_ns_per_update": 44.04,
"batch_ns_per_bar": 0.77,
"updates_per_second": 22705779.59,
"stream_over_batch_ratio": 57.4469
},
{
"indicator": "StreamingEMA",
"inputs": "close",
"stream_total_ms": 0.8611,
"batch_total_ms": 0.0408,
"stream_ns_per_update": 43.06,
"batch_ns_per_bar": 2.04,
"updates_per_second": 23225431.81,
"stream_over_batch_ratio": 21.0884
},
{
"indicator": "StreamingRSI",
"inputs": "close",
"stream_total_ms": 0.9235,
"batch_total_ms": 0.0932,
"stream_ns_per_update": 46.17,
"batch_ns_per_bar": 4.66,
"updates_per_second": 21656740.75,
"stream_over_batch_ratio": 9.9035
},
{
"indicator": "StreamingATR",
"inputs": "hlc",
"stream_total_ms": 2.0074,
"batch_total_ms": 0.0935,
"stream_ns_per_update": 100.37,
"batch_ns_per_bar": 4.68,
"updates_per_second": 9963056.99,
"stream_over_batch_ratio": 21.4603
},
{
"indicator": "StreamingVWAP",
"inputs": "hlcv",
"stream_total_ms": 2.6068,
"batch_total_ms": 0.0223,
"stream_ns_per_update": 130.34,
"batch_ns_per_bar": 1.11,
"updates_per_second": 7672265.38,
"stream_over_batch_ratio": 116.9385
}
]
}
+2 -2
View File
@@ -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"
+50 -2
View File
@@ -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,
+2 -1
View File
@@ -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":
+81 -3
View File
@@ -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.
+187
View File
@@ -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())
+19
View File
@@ -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
+20
View File
@@ -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"])
+72 -1
View File
@@ -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(
Generated
+1 -1
View File
@@ -609,7 +609,7 @@ wheels = [
[[package]]
name = "ferro-ta"
version = "1.0.2"
version = "1.0.3"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",