feat: init the repo

This commit is contained in:
Pratik Bhadane
2026-03-23 23:34:28 +05:30
commit 7a5a220dfe
344 changed files with 75728 additions and 0 deletions
View File
+203
View File
@@ -0,0 +1,203 @@
# Agentic Workflow and Tools
ferro-ta provides stable tool wrappers and a workflow orchestrator that make
it easy to integrate with AI agents, LangChain, LlamaIndex, or any
framework that supports function calling.
---
## Overview
The agentic API consists of two modules:
| Module | Purpose |
|--------|---------|
| `ferro_ta.tools` | Stable, documented functions for agent wrapping |
| `ferro_ta.workflow` | End-to-end pipeline: indicators → strategy → alerts |
---
## `ferro_ta.tools` — Tool wrappers
```python
from ferro_ta.tools import compute_indicator, run_backtest, list_indicators, describe_indicator
import numpy as np
close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 200)) * 100
# Compute any indicator by name
sma = compute_indicator("SMA", close, timeperiod=20)
rsi = compute_indicator("RSI", close, timeperiod=14)
bb = compute_indicator("BBANDS", close, timeperiod=20) # returns dict
# Run a backtest
summary = run_backtest("rsi_30_70", close)
print(f"Final equity: {summary['final_equity']:.4f}")
print(f"Trades: {summary['n_trades']}")
# List all indicators
names = list_indicators() # sorted list of strings
# Describe an indicator (returns first paragraph of docstring)
desc = describe_indicator("RSI")
```
### Function signatures
```python
def compute_indicator(name: str, *args, **kwargs) -> ndarray | dict:
...
def run_backtest(strategy: str, close, commission_per_trade=0.0, slippage_bps=0.0, **kwargs) -> dict:
...
def list_indicators() -> list[str]:
...
def describe_indicator(name: str) -> str:
...
```
---
## `ferro_ta.workflow` — End-to-end pipeline
```python
from ferro_ta.workflow import Workflow
import numpy as np
rng = np.random.default_rng(42)
close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100
result = (
Workflow()
.add_indicator("sma_20", "SMA", timeperiod=20)
.add_indicator("rsi_14", "RSI", timeperiod=14)
.add_strategy("rsi_30_70")
.add_alert("rsi_14", level=30.0, direction=-1) # alert when RSI crosses below 30
.run(close)
)
print(result.keys())
# dict_keys(['sma_20', 'rsi_14', 'backtest', 'alert_rsi_14_30_-1'])
```
### Functional interface
```python
from ferro_ta.workflow import run_pipeline
result = run_pipeline(
close,
indicators={
"sma_20": {"name": "SMA", "timeperiod": 20},
"rsi_14": {"name": "RSI", "timeperiod": 14},
},
strategy="rsi_30_70",
alert_indicator="rsi_14",
alert_level=30.0,
alert_direction=-1,
)
```
---
## LangChain integration
Wrap the tools as LangChain `Tool` objects:
```python
from langchain.tools import Tool
from ferro_ta.tools import compute_indicator, run_backtest, list_indicators
import numpy as np
import json
def _compute_tool(input_str: str) -> str:
"""Parse JSON input and compute an indicator."""
args = json.loads(input_str)
name = args.pop("name")
close = np.asarray(args.pop("close"), dtype=np.float64)
result = compute_indicator(name, close, **args)
if isinstance(result, dict):
return json.dumps({k: v.tolist() for k, v in result.items()})
return json.dumps(result.tolist())
def _backtest_tool(input_str: str) -> str:
args = json.loads(input_str)
close = np.asarray(args.pop("close"), dtype=np.float64)
strategy = args.pop("strategy", "rsi_30_70")
summary = run_backtest(strategy, close, **args)
return json.dumps(summary)
tools = [
Tool(
name="compute_indicator",
func=_compute_tool,
description=(
'Compute a technical indicator. Input JSON: {"name": "SMA", '
'"close": [...], "timeperiod": 14}'
),
),
Tool(
name="run_backtest",
func=_backtest_tool,
description=(
'Run a backtest. Input JSON: {"strategy": "rsi_30_70", '
'"close": [...]}'
),
),
Tool(
name="list_indicators",
func=lambda _: json.dumps(list_indicators()),
description="List all available indicator names. No input required.",
),
]
```
---
## Scheduling
### Run once
```python
python examples/run_workflow.py
```
### Run every N minutes (cron)
Add to your crontab:
```
*/15 * * * * /usr/bin/python /path/to/examples/run_workflow.py >> /var/log/ferro_ta.log 2>&1
```
### Run on a schedule with `schedule` library
```python
import schedule
import time
def job():
import numpy as np
from ferro_ta.workflow import run_pipeline
# fetch latest prices here ...
close = np.ones(100) # replace with real data
result = run_pipeline(close, indicators={"rsi": {"name": "RSI", "timeperiod": 14}})
print(result)
schedule.every(15).minutes.do(job)
while True:
schedule.run_pending()
time.sleep(1)
```
---
## See also
- `ferro_ta.tools` — module source.
- `ferro_ta.workflow` — module source.
- `docs/mcp.md` — MCP server for Cursor/Claude integration.
- `ferro_ta.backtest` — backtest harness.
- `ferro_ta.registry` — indicator registry.
+7
View File
@@ -0,0 +1,7 @@
Batch API
=========
.. automodule:: ferro_ta.batch
:members:
:undoc-members:
:show-inheritance:
+7
View File
@@ -0,0 +1,7 @@
Cycle
=====
.. automodule:: ferro_ta.cycle
:members:
:undoc-members:
:show-inheritance:
+8
View File
@@ -0,0 +1,8 @@
Exceptions and validation
=========================
.. automodule:: ferro_ta.exceptions
:members:
:undoc-members:
:show-inheritance:
:exclude-members: code, suggestion
+7
View File
@@ -0,0 +1,7 @@
Extended
========
.. automodule:: ferro_ta.extended
:members:
:undoc-members:
:show-inheritance:
+19
View File
@@ -0,0 +1,19 @@
API Reference
=============
.. toctree::
:maxdepth: 1
exceptions
overlap
momentum
volume
volatility
statistic
price_transform
pattern
cycle
math_ops
extended
streaming
batch
+7
View File
@@ -0,0 +1,7 @@
Math Ops
========
.. automodule:: ferro_ta.math_ops
:members:
:undoc-members:
:show-inheritance:
+7
View File
@@ -0,0 +1,7 @@
Momentum
========
.. automodule:: ferro_ta.momentum
:members:
:undoc-members:
:show-inheritance:
+7
View File
@@ -0,0 +1,7 @@
Overlap Studies
===============
.. automodule:: ferro_ta.overlap
:members:
:undoc-members:
:show-inheritance:
+7
View File
@@ -0,0 +1,7 @@
Pattern
=======
.. automodule:: ferro_ta.pattern
:members:
:undoc-members:
:show-inheritance:
+7
View File
@@ -0,0 +1,7 @@
Price Transform
===============
.. automodule:: ferro_ta.price_transform
:members:
:undoc-members:
:show-inheritance:
+7
View File
@@ -0,0 +1,7 @@
Statistic
=========
.. automodule:: ferro_ta.statistic
:members:
:undoc-members:
:show-inheritance:
+7
View File
@@ -0,0 +1,7 @@
Streaming
=========
.. automodule:: ferro_ta.streaming
:members:
:undoc-members:
:show-inheritance:
+7
View File
@@ -0,0 +1,7 @@
Volatility
==========
.. automodule:: ferro_ta.volatility
:members:
:undoc-members:
:show-inheritance:
+7
View File
@@ -0,0 +1,7 @@
Volume
======
.. automodule:: ferro_ta.volume
:members:
:undoc-members:
:show-inheritance:
+176
View File
@@ -0,0 +1,176 @@
# Architecture
This document describes the internal layout of **ferro-ta** — how the Rust and
Python layers are organised, how they communicate, and what each component is
responsible for.
---
## Repository Layout
```
ferro-ta/
├── src/ # Root PyO3 crate (Python extension, _ferro_ta)
│ ├── lib.rs # Module registration — assembles all sub-modules
│ ├── overlap/ # SMA, EMA, WMA, DEMA, TEMA, KAMA, BBANDS, …
│ ├── momentum/ # RSI, STOCH, ADX, CCI, AROON, WILLR, MFI, …
│ ├── volatility/ # ATR, NATR, TRANGE
│ ├── volume/ # AD, ADOSC, OBV
│ ├── statistic/ # STDDEV, VAR, LINEARREG, BETA, CORREL, …
│ ├── price_transform/ # AVGPRICE, MEDPRICE, TYPPRICE, WCLPRICE
│ ├── pattern/ # 61 CDL candlestick patterns
│ ├── cycle/ # HT_TRENDLINE, HT_DCPERIOD, HT_DCPHASE, …
│ └── common.rs # Shared helpers (Wilder smoothing, etc.)
├── crates/
│ └── ferro_ta_core/ # Pure-Rust library (no PyO3 / numpy)
│ └── src/ # Used by fuzz targets and WASM binding
├── python/
│ └── ferro_ta/ # Python package
│ ├── __init__.py # Public API — re-exports + pandas/polars wraps
│ ├── _utils.py # _to_f64, pandas_wrap, polars_wrap, get_ohlcv
│ ├── overlap.py # Thin wrappers around _ferro_ta overlap functions
│ ├── momentum.py # … momentum
│ ├── volatility.py # … volatility
│ ├── volume.py # … volume
│ ├── statistic.py # … statistic
│ ├── price_transform.py # … price_transform
│ ├── pattern.py # … pattern (61 CDL functions)
│ ├── cycle.py # … cycle
│ ├── math_ops.py # ADD, SUB, MULT, DIV, SUM, MAX, MIN, math transforms
│ ├── extended.py # Extended indicators (VWAP, SUPERTREND, ICHIMOKU, …)
│ ├── streaming.py # Stateful streaming classes (StreamingSMA, …)
│ ├── batch.py # Batch execution API (batch_sma, batch_ema, …)
│ ├── pipeline.py # Pipeline / make_pipeline
│ ├── config.py # set_default / Config
│ ├── registry.py # Indicator registry (list_indicators, run)
│ ├── backtest.py # Simple backtest helpers
│ ├── gpu.py # CuPy-backed GPU PoC (SMA, EMA, RSI)
│ ├── exceptions.py # FerroTAError, FerroTAValueError, FerroTAInputError
│ ├── utils.py # Public re-export of get_ohlcv
│ └── py.typed # PEP 561 marker
├── fuzz/ # cargo-fuzz targets (fuzz_sma, fuzz_rsi, …)
├── wasm/ # wasm-pack / wasm-bindgen binding (uses ferro_ta_core)
├── benches/ # Rust criterion benchmarks
├── benchmarks/ # Python pytest-benchmark benchmarks
├── docs/ # Sphinx documentation source
└── tests/ # Python pytest test suite
```
---
## Two Rust Crates
ferro-ta has **two** Rust crates that serve different purposes:
### 1. Root crate (`src/`) — Python extension (`_ferro_ta`)
| Property | Value |
|----------------|---------------------------------------------------|
| Crate type | `cdylib` (compiled to a `.so` / `.pyd` file) |
| PyO3 / numpy | Yes — depends on `pyo3` and `numpy` |
| Depends on | `ta` crate (provides TA-Lib-compatible algorithms)|
| Used by | Python extension (`ferro_ta._ferro_ta`) |
Each category module (`src/overlap/`, `src/momentum/`, …) registers
`#[pyfunction]`s that accept `numpy` arrays (via `PyReadonlyArray1<f64>`)
and return `Vec<f64>` which PyO3 converts to a Python list/ndarray.
### 2. `crates/ferro_ta_core/` — Pure Rust library
| Property | Value |
|----------------|-------------------------------------------------------------------|
| Crate type | `lib` (not a Python extension) |
| PyO3 / numpy | No — pure Rust, no Python dependency |
| Depends on | Nothing outside `std` |
| Used by | `fuzz/` targets and `wasm/` binding |
`ferro_ta_core` provides the same indicator categories with a `&[f64]` API,
making it usable from WASM and fuzz targets without pulling in PyO3 or numpy.
> **Note:** The root crate and `ferro_ta_core` are *independent* implementations.
> They are not merged by design — merging them would require careful testing of
> both the Python and WASM/fuzz surfaces. If you want to share code, the
> recommended path is to make the root crate depend on `ferro_ta_core` and wrap
> its `&[f64]` API with PyO3 `#[pyfunction]`s; that is a future refactor.
---
## Python Binding Flow
```
User code
├── from ferro_ta import SMA # __init__.py re-export
│ │
│ └── python/ferro_ta/overlap.py::SMA
│ │
│ ├── _utils._to_f64(close) # convert to float64 ndarray
│ ├── check_timeperiod(n) # validate parameters
│ └── _ferro_ta.sma(arr, n) # call Rust extension
│ │
│ └── src/overlap/sma.rs # pure Rust computation
├── SMA(pd.Series(...)) # pandas_wrap intercepts first
│ │
│ ├── extracts .to_numpy(dtype=float64)
│ ├── calls SMA(ndarray)
│ └── wraps result in pd.Series(result, index=original_index)
└── SMA(pl.Series(...)) # polars_wrap intercepts first
├── extracts .cast(Float64).to_numpy()
├── calls SMA(ndarray)
└── wraps result in pl.Series(name, np.asarray(result))
```
Both `pandas_wrap` and `polars_wrap` are applied to every public name in
`__init__.py` so the same function transparently handles numpy arrays,
pandas Series, and polars Series.
---
## Extended Indicators, Streaming, and Batch
| Module | Implementation | Notes |
|---------------|-----------------------------|-------------------------------------------------------------|
| `extended.py` | Rust (`src/extended/`) | VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS, … |
| `streaming.py`| Rust re-export | Stateful classes (StreamingSMA, StreamingEMA, …) from `_ferro_ta`; no Python fallback |
| `batch.py` | Rust for 2-D SMA/EMA/RSI | `batch_sma`, `batch_ema`, `batch_rsi` call Rust batch functions; `batch_apply` is a Python loop for other indicators |
Streaming and batch 2-D paths are implemented in Rust for maximum performance.
The generic `batch_apply` remains for indicators that do not have a dedicated
Rust batch implementation (see `docs/performance.md`).
---
## Packaging and Build
- **Build backend:** [maturin](https://www.maturin.rs/) — compiles the root
crate and packages it alongside the Python source into a wheel.
- **`python-source = "python"`** in `pyproject.toml` tells maturin where the
Python package lives.
- **`module-name = "ferro_ta._ferro_ta"`** tells maturin to place the compiled
`.so` at `ferro_ta/_ferro_ta.so` inside the wheel.
- Wheels are built for Linux (manylinux), Windows, and macOS via CI on release.
---
## Where Validation Lives
Currently most validation (array length checks, `timeperiod` range checks) is
done in Python wrappers before the Rust call. A future improvement is to move
these checks into the `#[pyfunction]`s so that callers using the raw
`_ferro_ta` extension directly also get clear errors.
---
## Related Documents
- [`docs/performance.md`](performance.md) — when to use raw numpy vs pandas/polars,
how to avoid unnecessary conversion, batch performance notes.
- [`CONTRIBUTING.md`](../CONTRIBUTING.md) — development workflow, running tests,
adding a new indicator.
- [`CHANGELOG.md`](../CHANGELOG.md) — version history.
+42
View File
@@ -0,0 +1,42 @@
Batch Execution API
===================
The batch API lets you run indicators on multiple price series in a single
call. This reduces Python overhead compared to calling the 1-D function in a
loop and naturally maps to multi-asset / multi-symbol workflows.
All batch functions accept a 2-D array of shape ``(n_samples, n_series)`` and
return a 2-D array of the same shape. Passing a 1-D array falls back to the
single-series behaviour.
Usage
-----
.. code-block:: python
import numpy as np
from ferro_ta.batch import batch_sma, batch_ema, batch_rsi, batch_apply
# 100 bars, 5 symbols
close = np.random.rand(100, 5) + 50.0
sma = batch_sma(close, timeperiod=14) # shape (100, 5)
ema = batch_ema(close, timeperiod=14) # shape (100, 5)
rsi = batch_rsi(close, timeperiod=14) # shape (100, 5)
# Apply any indicator using batch_apply
from ferro_ta import MACD
# MACD returns a tuple so we wrap it
def macd_line(c, **kw):
return MACD(c, **kw)[0]
macd = batch_apply(close, macd_line) # shape (100, 5)
API Reference
-------------
.. automodule:: ferro_ta.batch
:no-index:
:members:
:undoc-members:
:show-inheritance:
+62
View File
@@ -0,0 +1,62 @@
Benchmarks
==========
The authoritative benchmark workflow is 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``
- Table generation from benchmark JSON: ``benchmarks/benchmark_table.py``
Run the cross-library 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):
.. list-table::
:header-rows: 1
* - Indicator
- Throughput
* - ``ADD``
- 1.9 G bars/s
* - ``CDLENGULFING``
- 454 M bars/s
* - ``EMA``
- 444 M bars/s
* - ``SMA``
- 259 M bars/s
* - ``RSI``
- 145 M bars/s
* - ``ATR``
- 70 M bars/s
* - ``MACD``
- 104 M bars/s
* - ``STOCH``
- 33 M bars/s
Multi-size and JSON output
--------------------------
To build the markdown comparison table from the JSON output:
.. code-block:: bash
uv run python benchmarks/benchmark_table.py
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.
+55
View File
@@ -0,0 +1,55 @@
Changelog
=========
0.1.0 (2024)
------------
**Candlestick Pattern Parity (61/61)**
- All 61 TA-Lib candlestick patterns implemented in Rust
- ``{-100, 0, 100}`` convention, consistent with TA-Lib
**Numerical Parity**
- 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
**Streaming / Incremental API**
- New :mod:`ferro_ta.streaming` module with bar-by-bar stateful classes
- ``StreamingSMA``, ``StreamingEMA``, ``StreamingRSI``, ``StreamingATR``, ``StreamingBBands``, ``StreamingMACD``, ``StreamingStoch``, ``StreamingVWAP``, ``StreamingSupertrend``
**Pandas Integration**
- All indicators transparently accept ``pandas.Series`` and return ``Series`` with original index preserved
- Multi-output functions return tuples of ``Series``
**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
+145
View File
@@ -0,0 +1,145 @@
# ferro-ta ↔ finta Compatibility
[finta](https://github.com/peerchemist/finta) implements over 80 financial
technical indicators as class methods on a single `TA` class, operating
entirely on Pandas DataFrames.
---
## Key architectural differences
| Aspect | ferro-ta | finta |
|--------|---------|-------|
| **Backend** | Rust/C + SIMD | Pure Pandas |
| **Input type** | NumPy array or list | OHLCV Pandas DataFrame (required) |
| **DatetimeIndex** | Not required | **Required** |
| **Column names** | Separate arrays | `open/high/low/close/volume` |
| **Output type** | NumPy array | Pandas Series or DataFrame |
| **NaN handling** | Pads warmup with NaN | Pads warmup with NaN |
| **Streaming** | Yes (StreamingXxx classes) | No |
| **Speed** | ~700× faster on ATR | Baseline (pure Pandas) |
---
## Required DataFrame format
finta requires a **Pandas DataFrame with a DatetimeIndex** and lowercase
column names:
```python
import pandas as pd
import numpy as np
df = pd.DataFrame({
"open": open_prices,
"high": high_prices,
"low": low_prices,
"close": close_prices,
"volume": volume_data, # required for volume indicators
}, index=pd.date_range("2020-01-01", periods=len(close_prices), freq="D"))
```
ferro-ta accepts raw NumPy arrays or Python lists — no DataFrame needed.
---
## Function signature mapping
finta uses a class-method API: `TA.INDICATOR(ohlcv_df, period, ...)`.
| Indicator | ferro-ta | finta |
|-----------|---------|-------|
| SMA | `SMA(close, timeperiod=20)` | `TA.SMA(df, 20)` |
| EMA | `EMA(close, timeperiod=20)` | `TA.EMA(df, 20)` |
| WMA | `WMA(close, timeperiod=14)` | `TA.WMA(df, 14)` |
| DEMA | `DEMA(close, timeperiod=30)` | `TA.DEMA(df, 30)` |
| TEMA | `TEMA(close, timeperiod=30)` | `TA.TEMA(df, 30)` |
| HMA | Not supported | `TA.HMA(df, 16)` |
| RSI | `RSI(close, timeperiod=14)` | `TA.RSI(df, 14)` |
| MACD | `MACD(close, 12, 26, 9)` → (macd, signal, hist) | `TA.MACD(df, 12, 26, 9)` → DataFrame with `MACD`/`SIGNAL` columns |
| BBANDS | `BBANDS(close, 20, 2.0, 2.0)` → (upper, mid, lower) | `TA.BBANDS(df, 20)` → DataFrame with `BB_UPPER`/`BB_MIDDLE`/`BB_LOWER` |
| ATR | `ATR(high, low, close, timeperiod=14)` | `TA.ATR(df, 14)` |
| TRUE RANGE | `TRANGE(high, low, close)` | `TA.TR(df)` |
| OBV | `OBV(close, volume)` | `TA.OBV(df)` |
| MFI | `MFI(high, low, close, volume, timeperiod=14)` | `TA.MFI(df, 14)` |
| CCI | `CCI(high, low, close, timeperiod=14)` | `TA.CCI(df, 14)` |
| STOCH | `STOCH(high, low, close, 5, 3, 3)` | `TA.STOCH(df, 14)` |
| WILLR | `WILLR(high, low, close, timeperiod=14)` | `TA.WILLIAMS(df, 14)` |
| ADX | `ADX(high, low, close, timeperiod=14)` | `TA.ADX(df, 14)` |
| AROON | `AROON(high, low, timeperiod=14)` → (up, down) | `TA.AROON(df, 14)` → DataFrame |
---
## Numerical accuracy
finta uses sample standard deviation (ddof=1) for Bollinger Bands while
ferro-ta follows the TA-Lib convention (population std, ddof=0). For a
window of 20 bars this creates a ~0.5% difference in band width.
For EMA-based indicators, finta seeds with the first data point while ferro-ta
follows TA-Lib (SMA of first `timeperiod` bars). Values converge after
~3× the period.
Cross-library correlation between ferro-ta and finta is ≥ 0.95 for all
indicators after discarding the warm-up period.
---
## Speed comparison
On 10,000 bars (median µs, Apple M-series):
| Indicator | ferro-ta | finta | ferro-ta speedup |
|-----------|--------:|-------:|----------------:|
| SMA | 16.7 | 178.1 | **10.7×** |
| MACD | 70.4 | 383.9 | **5.5×** |
| ATR | 51.4 | 1,247 | **24×** |
On 100,000 bars:
| Indicator | ferro-ta | finta | ferro-ta speedup |
|-----------|--------:|--------:|----------------:|
| SMA | 126.2 | 699.7 | **5.6×** |
| MACD | 465.9 | 1,470.8 | **3.2×** |
| ATR | 478.5 | 6,782 | **14×** |
finta's ATR scales especially poorly because it relies on Pandas `.apply()`
with a lambda, which cannot be vectorised.
---
## Migration guide
```python
# FROM finta
import pandas as pd
from finta import TA
ohlcv = pd.DataFrame(...) # must have DatetimeIndex + open/high/low/close/volume
sma = TA.SMA(ohlcv, 20) # returns Pandas Series
macd_df = TA.MACD(ohlcv, 12, 26, 9) # returns DataFrame with MACD/SIGNAL cols
bb_df = TA.BBANDS(ohlcv, 20) # returns DataFrame with BB_UPPER/MIDDLE/LOWER
# TO ferro-ta (NumPy arrays — no DataFrame required)
import ferro_ta
import numpy as np
close = ohlcv["close"].values
sma = ferro_ta.SMA(close, timeperiod=20)
macd, signal, hist = ferro_ta.MACD(close, fastperiod=12, slowperiod=26, signalperiod=9)
upper, middle, lower = ferro_ta.BBANDS(close, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)
```
---
## Known limitations
- finta cannot process raw NumPy arrays — a properly formatted DataFrame with
DatetimeIndex is always required.
- `TA.MACD` only returns `MACD` and `SIGNAL` columns; the histogram must be
computed manually as `MACD - SIGNAL`.
- Several finta indicators use non-standard formulas that may not match TA-Lib
conventions (e.g. STOCH uses a fixed 14-period window regardless of the
`fastk_period` argument).
+108
View File
@@ -0,0 +1,108 @@
# Compatibility: ferro-ta vs pandas-ta
ferro-ta provides indicators that match [pandas-ta](https://github.com/twopirllc/pandas-ta)
results to within numerical tolerance. This guide explains how to migrate from
pandas-ta and how to run the cross-library validation tests.
## Installation
```bash
pip install ferro-ta
# Optional: install pandas-ta to run comparison tests
pip install pandas-ta
```
## API Comparison
### pandas-ta style (accessor)
```python
import pandas as pd
import pandas_ta as ta
close = pd.Series([...])
sma = close.ta.sma(length=20)
ema = close.ta.ema(length=14)
rsi = close.ta.rsi(length=14)
```
### ferro-ta equivalent
```python
import numpy as np
import ferro_ta as ft
close = np.array([...])
sma = ft.SMA(close, timeperiod=20)
ema = ft.EMA(close, timeperiod=14)
rsi = ft.RSI(close, timeperiod=14)
```
> **Note**: ferro-ta operates on NumPy arrays. If you have a `pd.Series`, pass
> it directly — ferro-ta will convert it automatically.
## Indicator Mapping
| pandas-ta | ferro-ta | Notes |
|---|---|---|
| `ta.sma(length=N)` | `ft.SMA(close, timeperiod=N)` | Exact match |
| `ta.ema(length=N)` | `ft.EMA(close, timeperiod=N)` | Tail convergence within 1e-6 |
| `ta.wma(length=N)` | `ft.WMA(close, timeperiod=N)` | Exact match |
| `ta.rsi(length=N)` | `ft.RSI(close, timeperiod=N)` | Tail convergence |
| `ta.macd(fast, slow, signal)` | `ft.MACD(close, fastperiod, slowperiod, signalperiod)` | Tail convergence |
| `ta.bbands(length=N, std=2)` | `ft.BBANDS(close, timeperiod=N, nbdevup=2, nbdevdn=2)` | Exact match |
| `ta.stoch(high, low, close)` | `ft.STOCH(high, low, close, ...)` | Tail convergence |
| `ta.cci(high, low, close, length=N)` | `ft.CCI(high, low, close, timeperiod=N)` | Exact match |
| `ta.mom(length=N)` | `ft.MOM(close, timeperiod=N)` | Exact match |
| `ta.roc(length=N)` | `ft.ROC(close, timeperiod=N)` | Exact match |
| `ta.trima(length=N)` | `ft.TRIMA(close, timeperiod=N)` | Exact match |
| `ta.hma(length=N)` | `ft.HT_MA(close, timeperiod=N)` | Hull MA variant |
| `ta.ichimoku(...)` | `ft.ICHIMOKU(high, low, close)` | Tenkan/Kijun match |
| `ta.kc(high, low, close, ...)` | `ft.KELTNER(high, low, close, ...)` | Tail convergence |
## Batch Execution
ferro-ta supports running many indicators at once via the batch API:
```python
import numpy as np
import ferro_ta as ft
data = np.random.randn(1000, 50) # 50 instruments × 1000 bars
# Run SMA(20) across all 50 instruments in one call
results = ft.batch_compute(data, "SMA", timeperiod=20)
```
## Running the Cross-Library Tests
Cross-library comparison tests live in `tests/integration/test_vs_pandas_ta.py`.
They are automatically **skipped** when pandas-ta is not installed.
```bash
# Install pandas-ta first
pip install pandas-ta
# Run comparison tests
pytest tests/integration/test_vs_pandas_ta.py -v
```
## Known Differences
- **Seeding period**: EMA results during the first `timeperiod` bars may differ
due to different initialization strategies (SMA seed vs EMA seed). Results
converge after the seeding window.
- **MACD signal line**: The signal EMA is seeded from the first valid MACD value.
Exact match begins after 2× `slowperiod` bars.
- **STOCH smoothing**: ferro-ta defaults match TA-Lib (SMA slowk, SMA slowd).
pandas-ta uses different defaults; pass matching parameters explicitly.
## Performance Comparison
ferro-ta is 10100× faster than pandas-ta for large arrays because the core
computation is written in Rust:
```bash
# Run the benchmark
pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json
```
+104
View File
@@ -0,0 +1,104 @@
# Compatibility: ferro-ta vs ta (Bukosabino)
ferro-ta provides indicators that match [ta](https://github.com/bukosabino/ta)
(Bukosabino's library) results to within numerical tolerance. This guide
explains how to migrate from `ta` and how to run the cross-library validation
tests.
## Installation
```bash
pip install ferro-ta
# Optional: install ta to run comparison tests
pip install ta
```
## API Comparison
### ta style
```python
import pandas as pd
from ta.momentum import RSIIndicator, StochasticOscillator
from ta.volatility import AverageTrueRange, BollingerBands
from ta.trend import SMAIndicator, EMAIndicator, MACD, CCIIndicator
from ta.volume import OnBalanceVolumeIndicator
from ta.others import DailyReturnIndicator
close = pd.Series([...])
high = pd.Series([...])
low = pd.Series([...])
volume = pd.Series([...])
rsi = RSIIndicator(close, window=14).rsi()
sma = SMAIndicator(close, window=20).sma_indicator()
ema = EMAIndicator(close, window=14).ema_indicator()
```
### ferro-ta equivalent
```python
import numpy as np
import ferro_ta as ft
close = np.array([...])
high = np.array([...])
low = np.array([...])
volume = np.array([...])
rsi = ft.RSI(close, timeperiod=14)
sma = ft.SMA(close, timeperiod=20)
ema = ft.EMA(close, timeperiod=14)
```
> **Note**: ferro-ta operates on NumPy arrays. If you have a `pd.Series`, pass
> it directly — ferro-ta will convert it automatically.
## Indicator Mapping
| ta | ferro-ta | Notes |
|---|---|---|
| `SMAIndicator(close, window=N).sma_indicator()` | `ft.SMA(close, timeperiod=N)` | Exact match |
| `EMAIndicator(close, window=N).ema_indicator()` | `ft.EMA(close, timeperiod=N)` | Tail convergence |
| `BollingerBands(close, window=N, window_dev=2)` | `ft.BBANDS(close, timeperiod=N, nbdevup=2, nbdevdn=2)` | Exact match |
| `RSIIndicator(close, window=N).rsi()` | `ft.RSI(close, timeperiod=N)` | Tail convergence |
| `MACD(close, window_slow, window_fast, window_sign)` | `ft.MACD(close, fastperiod, slowperiod, signalperiod)` | Tail convergence |
| `StochasticOscillator(high, low, close, window, smooth_window)` | `ft.STOCH(high, low, close, ...)` | Tail convergence |
| `AverageTrueRange(high, low, close, window=N)` | `ft.ATR(high, low, close, timeperiod=N)` | Tail convergence |
| `WilliamsRIndicator(high, low, close, lbp=N)` | `ft.WILLR(high, low, close, timeperiod=N)` | Exact match |
| `OnBalanceVolumeIndicator(close, volume)` | `ft.OBV(close, volume)` | Exact match |
| `CCIIndicator(high, low, close, window=N)` | `ft.CCI(high, low, close, timeperiod=N)` | Exact match |
## Running the Cross-Library Tests
Cross-library comparison tests live in `tests/integration/test_vs_ta.py`.
They are automatically **skipped** when `ta` is not installed.
```bash
# Install ta first
pip install ta
# Run comparison tests
pytest tests/integration/test_vs_ta.py -v
```
## Known Differences
- **EMA seeding**: `ta` uses pandas `ewm` with `adjust=True` by default, which
produces different warm-up values. Results converge after `2 × timeperiod` bars.
- **ATR**: `ta` uses a simple rolling mean for ATR by default; ferro-ta uses
Wilder's smoothing (same as TA-Lib). Values converge after the warm-up window.
- **STOCH**: `ta` and ferro-ta use different default smoothing periods. Pass
matching `window` / `smooth_window` values to get tail convergence.
## Performance Comparison
ferro-ta is significantly faster than `ta` for large arrays because the core
computation is written in Rust:
```bash
pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json
```
`ta` is a pure-Python/pandas library; ferro-ta processes 100k-bar arrays
in microseconds vs milliseconds for pandas-based implementations.
+27
View File
@@ -0,0 +1,27 @@
# Compatibility: ferro-ta vs TA-Lib
See the full migration guide at [docs/migration_talib.rst](../migration_talib.rst).
ferro-ta is designed as a **drop-in replacement** for TA-Lib (`talib` Python package) for the most commonly used indicators.
## Quick Reference
```python
# TA-Lib
import talib
result = talib.SMA(close, timeperiod=14)
# ferro-ta (identical API)
import ferro_ta
result = ferro_ta.SMA(close, timeperiod=14)
```
Full migration guide including all indicator mappings, known differences, and step-by-step migration: [migration_talib.rst](../migration_talib.rst)
## Running Cross-Library Tests
```bash
# Requires TA-Lib C library + talib Python package
pip install TA-Lib
pytest tests/integration/test_vs_talib.py -v
```
+140
View File
@@ -0,0 +1,140 @@
# ferro-ta ↔ Tulipy Compatibility
[Tulipy](https://github.com/cirla/tulipy) is the Python binding for
[Tulip Indicators](https://tulipindicators.org/) — 104 technical analysis
functions written in pure ANSI C99, designed for absolute speed with zero
external dependencies.
---
## Key architectural differences
| Aspect | ferro-ta | Tulipy |
|--------|---------|--------|
| **Backend** | Rust/C + SIMD | ANSI C99 |
| **Input type** | NumPy array or list | `np.float64` contiguous array |
| **Output length** | Same as input (NaN-padded) | Truncated (lookback bars shorter) |
| **NaN handling** | Pads warmup with NaN | Strips warmup entirely |
| **Multi-output** | Returns tuple | Returns tuple |
| **Pandas support** | Yes (via `ArrayLike`) | No |
| **Streaming** | Yes (StreamingXxx classes) | No |
---
## Output length difference
Tulipy **truncates** output instead of NaN-padding. When comparing results
you must align by the **trailing** elements:
```python
import tulipy as ti
import ferro_ta
import numpy as np
close = np.ascontiguousarray(np.random.randn(100).cumsum() + 100, dtype=np.float64)
ti_sma = ti.sma(close, period=20) # len = 81
ft_sma = ferro_ta.SMA(close, timeperiod=20) # len = 100 (19 leading NaN)
# Align: compare last 81 values
n = len(ti_sma)
assert np.allclose(ti_sma, ft_sma[-n:][np.isfinite(ft_sma[-n:])], atol=1e-8)
```
---
## Function signature mapping
Tulipy uses lowercase function names. The `period` argument is always a
positional-or-keyword integer.
| Indicator | ferro-ta | Tulipy |
|-----------|---------|--------|
| SMA | `SMA(close, timeperiod=20)` | `sma(close, period=20)` |
| EMA | `EMA(close, timeperiod=20)` | `ema(close, period=20)` |
| WMA | `WMA(close, timeperiod=14)` | `wma(close, period=14)` |
| RSI | `RSI(close, timeperiod=14)` | `rsi(close, period=14)` |
| MACD | `MACD(close, 12, 26, 9)` | `macd(close, short_period=12, long_period=26, signal_period=9)` |
| BBANDS | `BBANDS(close, timeperiod=20, nbdevup=2.0, nbdevdn=2.0)` → (upper, mid, lower) | `bbands(close, period=20, stddev=2.0)` → (lower, mid, upper) ⚠️ reversed! |
| ATR | `ATR(high, low, close, timeperiod=14)` | `atr(high, low, close, period=14)` |
| OBV | `OBV(close, volume)` | `obv(close, volume)` |
| CCI | `CCI(high, low, close, timeperiod=14)` | `cci(high, low, close, period=14)` |
| WILLR | `WILLR(high, low, close, timeperiod=14)` | `willr(high, low, close, period=14)` |
| STOCH | `STOCH(high, low, close, 5, 3, 3)` | `stoch(high, low, close, ...)` |
| HMA | Not supported | `hma(close, period=14)` |
| DEMA | `DEMA(close, timeperiod=30)` | `dema(close, period=30)` |
| TEMA | `TEMA(close, timeperiod=30)` | `tema(close, period=30)` |
| AROON | `AROONOSC(high, low, timeperiod=14)` | `aroonosc(high, low, period=14)` |
| MFI | `MFI(high, low, close, volume, timeperiod=14)` | `mfi(high, low, close, volume, period=14)` |
| TRANGE | `TRANGE(high, low, close)` | `tr(high, low, close)` |
⚠️ **BBANDS tuple order**: Tulipy returns `(lower, middle, upper)`;
ferro-ta and TA-Lib return `(upper, middle, lower)`.
---
## Memory requirements
Tulipy requires **strictly contiguous** `np.float64` arrays. Passing a
Pandas Series slice or a non-contiguous array causes an error:
```python
# Wrong — may be a non-contiguous view
close = df["close"].values
ti.sma(close, period=20) # may raise ValueError
# Correct — explicit contiguous cast
close = np.ascontiguousarray(df["close"].values, dtype=np.float64)
ti.sma(close, period=20) # always works
```
ferro-ta accepts any `ArrayLike` and handles the conversion internally.
---
## Numerical accuracy
Tulipy and ferro-ta agree closely for SMA, WMA, and other non-recursive
indicators (differences < 1e-8). For EMA-based indicators the first
`timeperiod` values differ due to initialisation seed choice:
- **Tulipy**: uses the first data value as the EMA seed.
- **ferro-ta**: follows TA-Lib convention (SMA of first `timeperiod` bars).
Values converge after approximately 23× the `timeperiod`.
---
## Speed comparison
On 10,000 bars (median µs, Apple M-series):
| Indicator | ferro-ta | Tulipy | Winner |
|-----------|--------:|-------:|--------|
| SMA | 16.7 | 21.2 | ferro-ta |
| MACD | 70.4 | 30.2 | Tulipy |
| ATR | 51.4 | 27.6 | Tulipy |
Tulipy's C99 implementation excels for recursive indicators (ATR, MACD).
ferro-ta is faster for sliding-window indicators (SMA) thanks to SIMD
vectorisation.
---
## Migration guide
```python
# FROM Tulipy
import tulipy as ti
import numpy as np
close = np.ascontiguousarray(close_series.values, dtype=np.float64)
sma_values = ti.sma(close, period=20) # length: n - 19
# TO ferro-ta (drop-in, same numeric result in the tail)
import ferro_ta
sma_values = ferro_ta.SMA(close, timeperiod=20) # length: n (19 leading NaN)
# Strip warmup if needed:
sma_values = sma_values[~np.isnan(sma_values)]
```
+68
View File
@@ -0,0 +1,68 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
import sys
# 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)
try:
import ferro_ta # noqa: F401
except ImportError:
sys.path.insert(0, os.path.abspath("../python"))
# -- Project information -------------------------------------------------------
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", "0.1.0")
version = release
# -- General configuration ----------------------------------------------------
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.viewcode",
"sphinx.ext.napoleon", # Google / NumPy-style docstrings
"sphinx.ext.autosummary",
"sphinx.ext.intersphinx",
]
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"numpy": ("https://numpy.org/doc/stable", None),
"pandas": ("https://pandas.pydata.org/docs", None),
}
templates_path = ["_templates"]
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# -- Options for HTML output --------------------------------------------------
html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]
html_title = "ferro-ta Documentation"
html_short_title = "ferro-ta"
# -- autodoc ------------------------------------------------------------------
autodoc_default_options = {
"members": True,
"undoc-members": True,
"show-inheritance": True,
}
autodoc_typehints = "description"
napoleon_google_docstring = False
napoleon_numpy_docstring = True
# Suppress autodoc import warnings for modules that can't be loaded without
# the compiled Rust extension (_ferro_ta). These are expected when building
# docs without the wheel; the documented API is still accurate.
# Also suppress duplicate object descriptions that arise when Rust-backed
# streaming classes (defined in ferro_ta._ferro_ta) are re-exported through
# ferro_ta.streaming — autodoc sees them in both modules.
suppress_warnings = [
"autodoc.import_object",
"ref.doc",
"py.duplicate",
]
+91
View File
@@ -0,0 +1,91 @@
Contributing
============
Thank you for your interest in contributing to ferro-ta!
This page summarises how to get started. The full details are in
`CONTRIBUTING.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/CONTRIBUTING.md>`_
at the repository root.
.. contents::
:local:
:depth: 2
Development setup
-----------------
Prerequisites: Rust stable toolchain, Python 3.10+, and ``maturin``.
.. code-block:: bash
git clone https://github.com/pratikbhadane24/ferro-ta.git
cd ferro-ta
pip install maturin numpy pytest pytest-cov
maturin develop --release
pytest tests/
Adding a new indicator
-----------------------
1. **Rust** — implement the function in the appropriate ``src/<module>/``
directory (e.g. ``src/overlap/mod.rs`` and ``src/overlap/sma.rs``). Follow
the existing patterns: slice inputs, ``Vec<f64>`` output, leading NaN for
warm-up bars, a ``#[pyfunction]`` decorator, and registration in the
module's ``register(m)`` function.
2. **Python** — add a thin wrapper in the matching ``python/ferro_ta/*.py``
module using the ``_to_f64`` helper. Export it in ``__all__``.
3. **Re-export** — add the function to ``python/ferro_ta/__init__.py``'s
``__all__`` list and import block.
4. **Type stub** — add a type annotation to ``python/ferro_ta/__init__.pyi``.
5. **Tests** — add at least one test class in ``tests/test_ferro_ta.py``
covering output length, NaN count, and a known-value check.
6. **README** — add a row to the appropriate accuracy table.
Code style
----------
- Rust: ``cargo fmt`` (enforced in CI) and ``cargo clippy -- -D warnings``
- Python: PEP 8; function names in UPPER_CASE to match TA-Lib convention.
- All public Python functions should have NumPy-style docstrings.
Running tests
-------------
.. code-block:: bash
# Python tests
pytest tests/ -v
# Rust format check
cargo fmt --check
# Rust lints
cargo clippy --release -- -D warnings
# Optional: TA-Lib comparison tests (requires ta-lib installed)
pytest tests/test_vs_talib.py -v
Type checking
-------------
The package is typed (PEP 561). To run mypy::
pip install mypy numpy
mypy python/ferro_ta --ignore-missing-imports
Questions
---------
Open a GitHub Issue or Discussion. For security vulnerabilities see
`SECURITY.md <https://github.com/pratikbhadane24/ferro-ta/blob/main/SECURITY.md>`_.
+79
View File
@@ -0,0 +1,79 @@
Error Handling and Validation
=============================
ferro-ta uses a consistent error model so you can catch and handle failures in a
predictable way.
Exception hierarchy
-------------------
All ferro-taspecific exceptions inherit from :exc:`ferro_ta.FerroTAError` and the
corresponding built-in type so that existing ``except ValueError`` code keeps
working:
- **FerroTAError** — base for all ferro-ta exceptions
- **FerroTAValueError** — invalid parameter values (e.g. ``timeperiod < 1``,
``fastperiod >= slowperiod`` for MACD). Inherits from :exc:`ValueError`.
- **FerroTAInputError** — invalid input arrays (mismatched lengths, wrong shape,
or opt-in strict checks). Inherits from :exc:`ValueError`.
Example:
.. code-block:: python
from ferro_ta import SMA, FerroTAValueError, FerroTAInputError
try:
SMA(close, timeperiod=0)
except FerroTAValueError as e:
print(e) # "timeperiod must be >= 1, got 0"
try:
SMA(open_arr, timeperiod=5) # if open_arr has different length
except FerroTAInputError as e:
print(e)
Validation in wrappers
----------------------
Every indicator wrapper validates parameters and inputs before calling the Rust
engine:
- **Period parameters** (e.g. ``timeperiod``, ``fastperiod``, ``slowperiod``) are
checked with :func:`ferro_ta.exceptions.check_timeperiod` and must be >= 1
(or >= 2 where the algorithm requires it, e.g. MAVP ``minperiod``).
- **Multiple arrays** (e.g. open, high, low, close, volume) are checked with
:func:`ferro_ta.exceptions.check_equal_length` so all have the same length.
Any error raised by the Rust extension (e.g. invalid value or bad array) is
re-raised as :exc:`FerroTAValueError` or :exc:`FerroTAInputError` with the same
message, so you can rely on the ferro-ta exception hierarchy.
NaN and Inf
-----------
By default, ferro-ta **propagates** NaN and Inf in input arrays: output values
that depend on a NaN/Inf input will themselves be NaN/Inf. No exception is
raised for NaN or Inf in the input.
If you need strict behaviour (no NaN/Inf), call
:func:`ferro_ta.exceptions.check_finite` on your arrays before passing them to
an indicator.
Empty and short arrays
----------------------
Indicators that require a minimum number of bars (e.g. SMA with ``timeperiod=5``
needs at least 5 elements) may return an array of NaN or raise if the Rust layer
rejects the input. You can use :func:`ferro_ta.exceptions.check_min_length` to
enforce a minimum length before calling an indicator.
Helper reference
----------------
- :func:`ferro_ta.exceptions.check_timeperiod` — raise if a period parameter is below minimum
- :func:`ferro_ta.exceptions.check_equal_length` — raise if supplied arrays have different lengths
- :func:`ferro_ta.exceptions.check_finite` — raise if an array contains NaN or Inf (opt-in strict)
- :func:`ferro_ta.exceptions.check_min_length` — raise if an array is shorter than required
See the :mod:`ferro_ta.exceptions` API for full signatures and examples.
+10
View File
@@ -0,0 +1,10 @@
Extended Indicators
===================
Extended indicators go beyond the TA-Lib standard set.
.. automodule:: ferro_ta.extended
:no-index:
:members:
:undoc-members:
:show-inheritance:
+134
View File
@@ -0,0 +1,134 @@
# GPU Backend (PyTorch)
This document describes the optional GPU-accelerated backend for **ferro-ta** powered
by [PyTorch](https://pytorch.org/).
---
## Goals
- Offer a drop-in GPU path for a small subset of indicators (SMA, EMA, RSI) for users
who process very large arrays (millions of bars or thousands of symbols in parallel).
- Keep the default install CPU-only: no GPU dependency unless the user opts in.
- Maintain API transparency: `torch.Tensor` in → `torch.Tensor` out;
`numpy.ndarray` in → `numpy.ndarray` out.
- Support both **CUDA** (NVIDIA) and **MPS** (Apple Silicon).
---
## Supported Indicators
| Indicator | Module | Notes |
|---|---|---|
| `sma` | `ferro_ta.gpu` | cumsum-based O(n) rolling mean; native PyTorch |
| `ema` | `ferro_ta.gpu` | SMA-seeded; recurrence on CPU for numerical fidelity |
| `rsi` | `ferro_ta.gpu` | diffs on GPU; Wilder smoothing on CPU |
All other ferro-ta indicators fall back to the CPU path automatically when called
through the top-level `ferro_ta` namespace.
---
## Installation
**Default (CPU-only):**
```bash
pip install ferro-ta
```
**With GPU support (PyTorch):**
```bash
pip install "ferro-ta[gpu]"
```
This installs `torch>=2.0`. For CUDA or MPS, install the appropriate PyTorch build
from [pytorch.org](https://pytorch.org/get-started/locally/):
```bash
# CUDA 12.x (example)
pip install torch --index-url https://download.pytorch.org/whl/cu121
# Apple Silicon (MPS) — often included in default pip install
pip install torch
```
---
## Usage
```python
import torch
from ferro_ta.gpu import sma, ema, rsi
# Build a tensor on GPU (CUDA or MPS on Apple Silicon)
close_gpu = torch.tensor(
[44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33],
device="cuda", # or device="mps" on Apple Silicon
dtype=torch.float64,
)
# GPU-accelerated SMA — result is also a torch.Tensor
sma_out = sma(close_gpu, timeperiod=5)
print(type(sma_out)) # <class 'torch.Tensor'>
print(sma_out.cpu().numpy()) # same values as CPU SMA
# RSI on GPU
rsi_out = rsi(close_gpu, timeperiod=5)
# Fall back to CPU automatically when input is numpy
import numpy as np
close_cpu = np.array([44.34, 44.09, 44.15, 43.61, 44.33])
sma_cpu = sma(close_cpu, timeperiod=3)
print(type(sma_cpu)) # <class 'numpy.ndarray'>
```
---
## Limitations
1. **Only 3 indicators supported.** SMA, EMA, RSI. The full set of 160+ indicators
falls back to the CPU path. Adding more GPU indicators is planned for future work.
2. **Transfer overhead.** Moving data from CPU RAM to GPU memory and back dominates for
small arrays (< ~100k elements). The GPU path is faster only when data is already
on the device or for very large arrays.
3. **float64.** PyTorch tensors are supported; dtype conversion is performed
automatically for integer inputs.
4. **EMA and RSI recurrence is on CPU.** To guarantee exact Wilder-smoothing parity
with the CPU implementation, the recurrence loop runs on the CPU after computing
diffs/seeds on the GPU. A future release may implement a fully native GPU kernel.
5. **No OOM handling.** For extremely large arrays the GPU may run out of memory;
no graceful fallback is implemented.
---
## Benchmarks
Measured on an NVIDIA RTX 3080 (10 GB VRAM) with CUDA 12.2, Python 3.11,
PyTorch 2.x. Array size: **1,000,000 elements**.
| Indicator | CPU (NumPy/Rust) | GPU (PyTorch) | Speedup | Notes |
|---|---|---|---|---|
| `sma` (period 30) | 0.4 ms | 0.9 ms | 0.4× | Transfer overhead dominates |
| `ema` (period 30) | 0.6 ms | 1.2 ms | 0.5× | Recurrence on CPU; no GPU gain |
| `rsi` (period 14) | 1.1 ms | 1.4 ms | 0.8× | Diffs on GPU; recurrence on CPU |
> **Key finding:** For 1M-element arrays, the GPU path is **not faster** than the
> optimised Rust/CPU path due to the cost of host↔device memory transfers. The GPU
> path is most useful when (a) data is already on the GPU, or (b) the same kernel
> is launched many times without re-transferring data.
The benchmark script is in `benchmarks/bench_gpu.py`.
---
## Future Work
- Implement fully native GPU kernels for EMA and RSI to avoid CPU round-trips.
- Extend to batch operations (running 1000+ symbols in parallel on GPU).
- Add optional RAPIDS cuDF or Polars GPU integration for dataframe-level workflows.
+83
View File
@@ -0,0 +1,83 @@
ferro-ta Documentation
=====================
.. toctree::
:maxdepth: 2
:caption: Contents
quickstart
migration_talib
pandas_api
error_handling
api/index
streaming
extended
batch
benchmarks
plugins
changelog
contributing
Overview
--------
**ferro-ta** is a fast Technical Analysis library — a drop-in alternative to TA-Lib
powered by Rust and PyO3.
Features:
- 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
- Pure Rust core library (``crates/ferro_ta_core``) — no PyO3 / numpy dependency
- Streaming / bar-by-bar API for live trading
- Transparent pandas.Series support
- Math operators and transforms
- Type stubs (.pyi) for IDE auto-completion
- WASM binding for browser/Node.js use
- Options/IV helpers (IV rank, IV percentile, IV z-score) — see `Options/IV Helpers <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/options-volatility.md>`_
- 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
Installation
~~~~~~~~~~~~
.. code-block:: bash
pip install ferro-ta
Quick Start
~~~~~~~~~~~
.. code-block:: python
import numpy as np
from ferro_ta import SMA, EMA, RSI, MACD, BBANDS
close = np.array([10.0, 11.0, 12.0, 13.0, 14.0, 13.5, 12.5])
print(SMA(close, timeperiod=3))
# Batch: run SMA on 5 symbols at once
from ferro_ta.batch import batch_sma
data = np.random.rand(100, 5)
result = batch_sma(data, timeperiod=10)
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.
- `Rust-First Policy <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/rust_first.md>`_ — all compute logic belongs in Rust; how to add new indicators.
- `Out-of-Core Execution <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/out-of-core.md>`_ — chunked processing and Dask integration.
- `Options/IV Helpers <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/options-volatility.md>`_ — IV rank, IV percentile, IV z-score.
- `Agentic Workflow <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/agentic.md>`_ — tools.py, workflow.py, LangChain integration.
- `MCP Server <https://github.com/pratikbhadane24/ferro-ta/blob/main/docs/mcp.md>`_ — run ferro-ta as an MCP server in Cursor/Claude.
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
+125
View File
@@ -0,0 +1,125 @@
# MCP Server — Connect ferro-ta in Cursor
ferro-ta ships an MCP (Model Context Protocol) server that exposes
indicators and backtest tools to AI agents. This guide shows how to run
the server and connect it to Cursor or any MCP-compatible client.
---
## Installation
The MCP server requires no additional dependencies beyond ferro_ta itself.
For the full MCP SDK integration (recommended), install the optional extra:
```bash
pip install "ferro-ta[mcp]"
```
or install the `mcp` package separately:
```bash
pip install "mcp>=1.0"
```
---
## Running the server
```bash
python -m ferro_ta.mcp
```
The server listens on stdin/stdout using JSON-RPC 2.0 (the MCP protocol).
---
## Connect in Cursor
1. Open Cursor settings (Command Palette → "Open User Settings (JSON)").
2. Find or create the `mcpServers` section:
```json
{
"mcpServers": {
"ferro-ta": {
"command": "python",
"args": ["-m", "ferro_ta.mcp"],
"description": "ferro_ta — Technical Analysis MCP server"
}
}
}
```
3. Reload Cursor (Command Palette → "Developer: Reload Window").
4. The ferro-ta tools will appear in the Tools panel.
### Workspace-level config
You can also add the config to your project's `.cursor/mcp.json`:
```json
{
"mcpServers": {
"ferro-ta": {
"command": "python",
"args": ["-m", "ferro_ta.mcp"]
}
}
}
```
---
## Example prompts
Once connected, you can ask Claude (or any MCP-enabled AI) things like:
> "Compute RSI(14) on this price series: [100, 102, 101, 105, 108, 104, 107]"
> "Run a backtest with the rsi_30_70 strategy on [100, 101, 99, 103, 106, 102, 108, 105, 109, 112, 108, 111]"
> "List all available ferro_ta indicators"
> "What does the SMA indicator do?"
---
## Available tools
| Tool | Description |
|------|-------------|
| `sma` | Simple Moving Average |
| `ema` | Exponential Moving Average |
| `rsi` | Relative Strength Index |
| `macd` | MACD line, signal, histogram |
| `backtest` | Vectorized backtest (rsi_30_70, sma_crossover, macd_crossover) |
| `list_indicators` | List all registered indicators |
| `describe_indicator` | Describe a named indicator |
---
## Programmatic use (Python client)
You can also use the MCP handlers directly in Python without the server:
```python
from ferro_ta.mcp import handle_list_tools, handle_call_tool
import numpy as np
# List tools
tools = handle_list_tools()
print([t["name"] for t in tools["tools"]])
# Call RSI
close = list(np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 50)) * 100)
result = handle_call_tool("rsi", {"close": close, "timeperiod": 14})
print(result)
```
---
## See also
- `ferro_ta.mcp` — module source.
- `ferro_ta.tools` — underlying tool functions.
- `docs/agentic.md` — LangChain and workflow integration.
+168
View File
@@ -0,0 +1,168 @@
Migration from TA-Lib
=====================
ferro-ta is designed as a drop-in replacement for `ta-lib` (the Python
`talib` package) for the most-commonly used indicators. This guide explains
the differences so you can migrate existing code with confidence.
.. contents::
:local:
:depth: 2
Import changes
--------------
TA-Lib uses a single flat namespace::
import talib
result = talib.SMA(close, timeperiod=14)
ferro-ta exposes the same names at the top level **and** in sub-modules::
# Option A — top-level (most concise, mirrors talib)
from ferro_ta import SMA, EMA, RSI
result = SMA(close, timeperiod=14)
# Option B — sub-modules
from ferro_ta.overlap import SMA
from ferro_ta.momentum import RSI
Multi-output functions return a **tuple** in both libraries::
# talib
upper, middle, lower = talib.BBANDS(close)
# ferro_ta
upper, middle, lower = ferro_ta.BBANDS(close)
Input / output conventions
--------------------------
Both libraries accept NumPy ``float64`` arrays. ferro-ta also accepts any
array-like (Python list, ``float32``, pandas Series) and converts
automatically.
- **Leading NaN values** — both libraries emit ``NaN`` for the "warm-up"
period at the start of an array. The number of ``NaN`` values is identical
for all indicators marked **Exact** or **Close** in the accuracy table.
- **Output length** — always equal to input length, matching TA-Lib.
- **Pandas Series** — ferro-ta transparently preserves the original index when
a ``pd.Series`` is passed as input.
Accuracy levels
---------------
.. list-table::
:header-rows: 1
* - Symbol
- Meaning
* - ✅ **Exact**
- Values match TA-Lib to floating-point precision.
* - ✅ **Close**
- Values converge to TA-Lib after the warm-up window (EMA-seed
differences resolve within ~50 bars for typical periods).
* - ⚠️ **Corr**
- Strong correlation (> 0.95) but not numerically identical (e.g.
MAMA uses the same algorithm but slightly different initialization).
* - ⚠️ **Shape**
- Same output shape and NaN structure; absolute values differ (e.g. SAR
reversal history can diverge due to floating-point accumulation).
All overlap, momentum, volume, volatility, statistic, and price-transform
functions are **Exact** or **Close**. The only remaining **Corr / Shape**
functions are MAMA, SAR, SAREXT, and the six HT_* cycle indicators — see
the roadmap for details.
Known behavioural differences
------------------------------
EMA / DEMA / TEMA / T3 / MACD
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
TA-Lib seeds the first EMA value with a simple moving average. ferro-ta uses
the same seeding, so values converge after the warm-up period. For a 14-period
EMA on typical market data, convergence is complete by bar ~60.
RSI
~~~
ferro-ta uses the same Wilder smoothing seed as TA-Lib (SMA seed for the first
``timeperiod`` bars) and produces **Exact** results.
SAR / SAREXT
~~~~~~~~~~~~
Parabolic SAR reversal history can diverge in rare edge-cases due to
floating-point accumulation differences. Output shapes (NaN count, length)
match exactly.
HT_* cycle indicators
~~~~~~~~~~~~~~~~~~~~~
The Hilbert Transform cycle indicators (``HT_DCPERIOD``, ``HT_DCPHASE``,
``HT_PHASOR``, ``HT_SINE``, ``HT_TRENDLINE``, ``HT_TRENDMODE``) use the
same Ehlers algorithm as TA-Lib but may differ slightly in floating-point
accumulation. All six share a 63-bar lookback matching TA-Lib.
OBV
~~~
ferro-ta OBV starts accumulation from zero at bar 0 (same as TA-Lib for most
data sets). If your TA-Lib OBV shows an offset this is usually due to a
starting volume difference in the input data.
Before / after example
-----------------------
.. code-block:: python
# --- Before (ta-lib) ---
import numpy as np
import talib
close = np.random.rand(200).cumsum() + 100.0
high = close + 0.5
low = close - 0.5
sma = talib.SMA(close, timeperiod=14)
ema = talib.EMA(close, timeperiod=14)
rsi = talib.RSI(close, timeperiod=14)
upper, mid, lower = talib.BBANDS(close, timeperiod=20)
macd, signal, hist = talib.MACD(close)
atr = talib.ATR(high, low, close, timeperiod=14)
# --- After (ferro_ta) ---
import numpy as np
from ferro_ta import SMA, EMA, RSI, BBANDS, MACD, ATR
close = np.random.rand(200).cumsum() + 100.0
high = close + 0.5
low = close - 0.5
sma = SMA(close, timeperiod=14)
ema = EMA(close, timeperiod=14)
rsi = RSI(close, timeperiod=14)
upper, mid, lower = BBANDS(close, timeperiod=20)
macd, signal, hist = MACD(close)
atr = ATR(high, low, close, timeperiod=14)
Only the import line changes for the most common indicators.
Extended (non-TA-Lib) indicators
---------------------------------
ferro-ta additionally provides indicators not in TA-Lib::
from ferro_ta import (
VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS,
KELTNER_CHANNELS, HULL_MA, CHANDELIER_EXIT, VWMA, CHOPPINESS_INDEX,
)
See :doc:`extended` for full API documentation.
+101
View File
@@ -0,0 +1,101 @@
# Options and Implied Volatility
ferro-ta provides optional helpers for implied volatility (IV) analysis
via the `ferro_ta.options` module. This document describes the scope,
data format, dependency strategy, and limitations.
---
## Scope
The `ferro_ta.options` module focuses on **IV series analysis**:
- **IV rank** — where today's IV sits relative to the min/max over a look-back window.
- **IV percentile** — fraction of observations over a look-back window at or below today's IV.
- **IV z-score** — how many standard deviations today's IV is above the rolling mean.
These functions accept any 1-D IV series (e.g. VIX daily closes, single-name
30-day IV, etc.) and return rolling statistics.
**Out of scope (for now):** Black-Scholes pricing, Greeks, option chain
parsing, synthetic forward construction, dividend adjustment. For full
option-pricing functionality consider `py_vollib`, `mibian`, or similar.
---
## Data format
All functions accept a 1-D NumPy array (or any array-like) of IV values.
IV values are typically in **percentage points** (e.g. VIX = 20 means 20%
annualised volatility), but the helpers are unit-agnostic — they only
compare values within the rolling window.
```python
import numpy as np
from ferro_ta.options import iv_rank, iv_percentile, iv_zscore
# VIX-like daily close series
iv = np.array([18.5, 22.3, 19.1, 25.0, 30.2, 27.8, 21.4, 19.0])
rank = iv_rank(iv, window=5) # rolling IV rank in [0, 1]
pct = iv_percentile(iv, window=5) # rolling IV percentile in [0, 1]
z = iv_zscore(iv, window=5) # rolling z-score
```
---
## Dependency strategy
The `ferro_ta.options` module uses **only NumPy** (already a core dependency).
No additional packages are required for the helpers described here.
For advanced option analytics (Black-Scholes, volatility surface
interpolation), install the optional extra:
```bash
pip install "ferro-ta[options]"
```
This may install additional packages in the future (e.g. `py_vollib`).
---
## API reference
### `iv_rank(iv_series, window=252)`
Rolling IV rank.
```
rank_t = (IV_t - min(IV[t-window+1:t+1])) / (max(IV[t-window+1:t+1]) - min(IV[t-window+1:t+1]))
```
Returns values in [0, 1]. NaN for the first `window - 1` bars.
### `iv_percentile(iv_series, window=252)`
Rolling IV percentile: fraction of the *window* bars whose IV was at or
below the current value.
### `iv_zscore(iv_series, window=252)`
Rolling z-score: `(IV_t - rolling_mean) / rolling_std`.
---
## Limitations
- All functions use **O(n × window)** time complexity (pure Python loops).
For large windows or series consider vectorised alternatives.
- No option chain support; the module assumes IV series as input.
- Streaming (bar-by-bar) versions of these functions are not yet
implemented. For live use, maintain a rolling buffer and call the
functions on the buffer at each bar.
---
## See also
- `ferro_ta.options` — module source.
- `ferro_ta.statistic` — general statistical functions (STDDEV, VAR, CORREL, etc.).
- `ferro_ta.volatility` — price-based volatility indicators (ATR, NATR).
+169
View File
@@ -0,0 +1,169 @@
# Out-of-Core and Distributed Execution
ferro-ta is designed to work efficiently on large datasets that do not fit
in memory by supporting **chunked execution** with warm-up overlap. This
document explains the problem, the recommended approach, and current
limitations.
---
## Problem statement
Technical analysis indicators are typically stateful: they require a
look-back window of historical bars to produce a valid value. When a price
dataset is larger than available memory (e.g. tick data, multiple years of
1-second bars), or when it needs to be processed in a distributed cluster
(Spark, Dask), the data must be split into chunks.
The challenges are:
1. **Warm-up / border effects** — the first `period - 1` bars of each chunk
will produce NaN because the indicator has not yet accumulated enough
history.
2. **Partition stitching** — after computing an indicator on each partition
independently, the partial results must be assembled into a single
coherent output.
3. **Indicators that need full history** — some indicators (e.g. Hilbert
Transform cycle indicators) cannot be decomposed into partitions; they
require the full series.
---
## Chunk boundaries and warm-up overlap
The `ferro_ta.chunked` module provides Rust-backed helpers for chunk-based
execution:
```python
from ferro_ta.chunked import make_chunk_ranges, trim_overlap, stitch_chunks, chunk_apply
from ferro_ta import SMA
import numpy as np
data = np.random.rand(1_000_000) # large price series
period = 20
overlap = period - 1 # warm-up bars needed
ranges = make_chunk_ranges(len(data), chunk_size=50_000, overlap=overlap)
chunks_out = []
for start, end in ranges:
chunk = data[start:end]
out = SMA(chunk, timeperiod=period)
chunks_out.append(out)
result = stitch_chunks(chunks_out, overlap=overlap)
```
### Key concepts
| Concept | Description |
|---------|-------------|
| `chunk_size` | Number of bars per chunk (excluding overlap). |
| `overlap` | Warm-up bars prepended to each chunk from the previous chunk. |
| `trim_overlap` | Strips the warm-up prefix from a chunk result. |
| `stitch_chunks` | Concatenates trimmed chunk results into the final output. |
| `chunk_apply` | Convenience wrapper: runs a callable on each chunk and stitches. |
---
## Options for distributed / out-of-core execution
### Option A: Chunked pandas with overlap (single-machine, recommended)
Use `chunk_apply` or `make_chunk_ranges` + manual loop. Suitable for
datasets up to ~10 GB that fit on a single machine with streaming reads.
```python
from ferro_ta.chunked import chunk_apply
from ferro_ta import EMA
result = chunk_apply(data, EMA, chunk_size=100_000, overlap=50, timeperiod=50)
```
### Option B: Dask `map_partitions` (distributed)
Dask can partition a large array and apply a function to each partition.
To handle warm-up correctly, use overlapping partitions via
`dask.array.overlap.overlap`:
```python
import dask.array as da
from dask.array.overlap import overlap as da_overlap
from ferro_ta import SMA
x = da.from_array(price_array, chunks=100_000)
depth = 20 - 1 # warm-up depth
x_ov = da_overlap(x, depth={0: depth}, boundary={0: "none"})
result = x_ov.map_blocks(lambda blk: SMA(blk, timeperiod=20))
# trim overlap from each block
result_trimmed = da.map_blocks(
lambda blk: blk[depth:],
result,
dtype=float,
)
```
### Option C: Apache Spark (brief)
Spark does not natively support overlapping windows for time-series
indicators. You would need to:
1. Repartition data by time range with explicit padding.
2. Apply the indicator via a Pandas UDF.
3. Filter out warm-up rows in a post-processing step.
This approach is feasible but complex. For most use-cases, Dask
(Option B) is simpler.
---
## Recommended path
| Scale | Recommendation |
|-------|---------------|
| Single machine, fits in RAM | Use ferro_ta directly on the full array. |
| Single machine, does not fit in RAM | `chunk_apply` with overlap (Option A). |
| Multi-machine cluster | Dask `map_partitions` with `dask.array.overlap` (Option B). |
---
## Which indicators are safe for partition-wise execution
Indicators that depend only on a fixed-length window are **safe** for
chunked/partition-wise execution (with correct overlap):
- All overlap studies: SMA, EMA, WMA, DEMA, TEMA, BBANDS, etc.
- Momentum: RSI, MACD, STOCH, ADX, CCI, WILLR, etc.
- Volatility: ATR, NATR.
- Most volume indicators: OBV, AD (cumulative; use `stitch_chunks` carefully).
Indicators that are **not safe** for partition-wise execution without
special handling:
- Hilbert Transform cycle indicators (`HT_*`) — require full history.
- Adaptive indicators with unbounded look-back (e.g. KAMA with long
adaptation period).
- Streaming state-machine indicators when state must be preserved across
chunks (use `ferro_ta.streaming` classes instead).
---
## Limitations
- **Volume-weighted indicators** (e.g. VWAP, OBV) accumulate across all
bars; resetting at chunk boundaries changes their semantics. Use
`streaming.StreamingVWAP` for bar-by-bar accumulation instead.
- **SAR and MAMA** have path-dependent state; chunk results will differ
from full-series results unless the prior state is passed across chunks.
- Current `chunk_apply` does not propagate indicator state across chunks;
all indicators restart at each chunk boundary (modulo the overlap
warm-up).
---
## See also
- `ferro_ta.chunked` — API reference for chunk helpers.
- `ferro_ta.streaming` — Stateful streaming classes for live bar-by-bar use.
- Dask documentation: <https://docs.dask.org/en/stable/>
+46
View File
@@ -0,0 +1,46 @@
Pandas API contract
===================
**Contract**
- All indicators accept ``pandas.Series`` (or 1-D DataFrame columns) and return
``pandas.Series`` — or a **tuple of Series** for multi-output functions (e.g. MACD, BBANDS)
— with the **original index preserved**.
- Default OHLCV column names for DataFrames are ``open``, ``high``, ``low``, ``close``, ``volume``.
- To use different column names, use :func:`ferro_ta.utils.get_ohlcv` to extract arrays/Series
with configurable column names, then call the indicator.
**Single Series**
.. code-block:: python
import pandas as pd
from ferro_ta import SMA, RSI
close = pd.Series([44.34, 44.09, 44.15], index=pd.date_range("2024-01-01", periods=3))
sma = SMA(close, timeperiod=2) # returns pd.Series with same index
**DataFrame with OHLCV (configurable column names)**
.. code-block:: python
import pandas as pd
from ferro_ta import ATR, RSI
from ferro_ta.utils import get_ohlcv
df = pd.DataFrame({
"Open": [1, 2, 3], "High": [1.1, 2.1, 3.1],
"Low": [0.9, 1.9, 2.9], "Close": [1.05, 2.05, 3.05],
}, index=pd.date_range("2024-01-01", periods=3, freq="D"))
o, h, l, c, v = get_ohlcv(df, open_col="Open", high_col="High",
low_col="Low", close_col="Close", volume_col=None)
atr = ATR(h, l, c, timeperiod=2) # index preserved
rsi = RSI(c, timeperiod=2) # index preserved
**Multi-output**
Functions like ``MACD`` and ``BBANDS`` return a tuple of ``pandas.Series``, all with the same index as the input.
**See also**
- :mod:`ferro_ta.utils`:func:`get_ohlcv` for DataFrame OHLCV extraction.
+302
View File
@@ -0,0 +1,302 @@
# Performance Guide
This document explains the performance characteristics of **ferro-ta** and gives
practical advice on how to get the best speed from the library.
---
## Quick Summary
| Use case | Recommended API | Notes |
|---------------------------------------|----------------------------------------|----------------------------------------|
| Fast path — NumPy arrays | Pass `np.ndarray` (float64, C-order) | Zero overhead; no conversion needed |
| pandas users | Pass `pd.Series`; result is `pd.Series`| Small overhead for index wrapping |
| polars users | Pass `pl.Series`; result is `pl.Series`| Small overhead for type conversion |
| Raw Rust access (expert) | `from ferro_ta._ferro_ta import sma` | Bypasses all Python wrappers |
| Multiple series at once | `batch_sma`, `batch_ema`, `batch_rsi` | One Python call for all columns |
**Recorded baseline and roadmap:** Performance roadmap and trade-offs are tracked
in [PERFORMANCE_ROADMAP.md](../PERFORMANCE_ROADMAP.md). For reproducible benchmark
inputs/results and methodology, use [benchmarks/README.md](../benchmarks/README.md)
and regenerate with `python benchmarks/bench_vs_talib.py --json benchmark_vs_talib.json`.
---
## The Rust Core Is Fast; Overhead Is in Python
The Rust extension (`_ferro_ta`) is compiled with full optimisations and is very
fast. The bottlenecks for most users are in the Python wrapping layer:
1. **Array conversion**`_to_f64` converts any array-like to a contiguous
`float64` NumPy array. If your input is already a C-contiguous `float64`
ndarray the fast path returns it without any copy or allocation.
2. **pandas wrapping**`pandas_wrap` extracts the NumPy array from a
`pd.Series`, calls the Rust function, and wraps the result back into a
`pd.Series` with the original index. The wrapping itself is cheap but adds
a small constant overhead per call.
3. **polars wrapping**`polars_wrap` converts a `pl.Series` to NumPy and back.
The result is now built from the NumPy buffer directly (`pl.Series(name,
np.asarray(result))`), which avoids the O(n) `.tolist()` conversion of
earlier versions.
4. **Batch**`batch_sma`/`batch_ema`/`batch_rsi` use Rust-side batch functions
for 2-D input (single GIL release for all columns). The generic
`batch_apply` runs any indicator in a Python loop over columns; use the
dedicated batch functions when available.
---
## The Fast Path: Pass Contiguous float64 NumPy Arrays
The cheapest way to call any indicator is to pass a C-contiguous `float64`
NumPy array. `_to_f64` detects this case and returns the array as-is:
```python
import numpy as np
from ferro_ta import SMA
# Already float64 and C-contiguous — _to_f64 is a no-op (zero copy)
close = np.random.rand(10_000).astype(np.float64)
result = SMA(close, timeperiod=20)
```
If your array is in a different dtype or order, `_to_f64` will create a new
array. You can force the fast path once and reuse the result:
```python
close_f64 = np.ascontiguousarray(close, dtype=np.float64) # one-time conversion
result = SMA(close_f64, timeperiod=20) # no copy inside _to_f64
```
---
## Raw Numpy-Only API (No Wrapper Overhead)
If you want zero Python overhead — no pandas/polars wrapping, no validation —
you can import functions directly from the compiled extension:
```python
from ferro_ta._ferro_ta import sma, ema, rsi # raw Rust functions
import numpy as np
close = np.random.rand(10_000).astype(np.float64)
result = sma(close, 20) # returns a NumPy array (PyArray1<f64> from PyO3)
```
> **Warning:** The raw `_ferro_ta` API is internal and may change between
> versions. It does *not* validate inputs — passing an empty array or a wrong
> type will raise an obscure error from PyO3. Use it only if you have
> profiled a bottleneck and need the absolute minimum overhead.
For a stable raw API with the same functions, use the `ferro_ta.raw` submodule
(no pandas/polars wrapping or validation).
---
## pandas Series
```python
import pandas as pd
from ferro_ta import SMA
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0], index=pd.date_range("2024-01-01", periods=5))
result = SMA(s, timeperiod=3)
# result is a pd.Series with the same DatetimeIndex
```
Overhead compared to a raw numpy call: one `pd.Series.to_numpy()` call (cheap)
plus one `pd.Series(result, index=...)` call (cheap). For large arrays this
is negligible; for very tight loops (millions of calls per second) prefer numpy.
---
## polars Series
```python
import polars as pl
from ferro_ta import SMA
s = pl.Series("close", [1.0, 2.0, 3.0, 4.0, 5.0])
result = SMA(s, timeperiod=3)
# result is a pl.Series named "close"
```
Overhead: one `.cast(Float64).to_numpy()` call plus one `pl.Series(name,
np.asarray(result))` call. The result is built from the numpy buffer
(zero-copy where polars allows it) rather than going through `.tolist()`.
---
## Batch Execution
Use the batch API when you have many series (e.g., one column per symbol):
```python
import numpy as np
from ferro_ta.batch import batch_sma, batch_ema, batch_rsi, batch_apply
data = np.random.rand(252, 500).astype(np.float64) # 252 bars × 500 symbols
sma_out = batch_sma(data, timeperiod=20) # shape (252, 500)
rsi_out = batch_rsi(data, timeperiod=14)
```
`batch_apply` lets you run any indicator on a 2-D array:
```python
from ferro_ta import ATR
from ferro_ta.batch import batch_apply
ohlcv = np.random.rand(252, 100, 3).astype(np.float64) # not directly supported
# For indicators that take multiple arrays use a manual loop instead
```
For 2-D input, `batch_sma`/`batch_ema`/`batch_rsi` use Rust-side batch
functions (single GIL release for all columns). Use `batch_apply` for other
indicators that do not have a dedicated Rust batch implementation.
---
## Streaming (Bar-by-Bar)
```python
from ferro_ta.streaming import StreamingSMA
sma = StreamingSMA(period=20)
for bar in live_feed:
value = sma.update(bar.close)
if value is not None:
print(f"SMA(20) = {value:.4f}")
```
The streaming classes are implemented in Rust (PyO3 `#[pyclass]` in
`_ferro_ta`) and re-exported from `ferro_ta.streaming`. They are suitable for
live trading at typical bar rates with minimal Python overhead.
---
## Extended Indicators
`VWAP`, `SUPERTREND`, `ICHIMOKU`, `DONCHIAN`, `PIVOT_POINTS`, `KELTNER_CHANNELS`,
`HULL_MA`, `CHANDELIER_EXIT`, `VWMA`, and `CHOPPINESS_INDEX` are implemented in
Rust (`src/extended/mod.rs`). The Python module `ferro_ta/extended.py` is a thin
wrapper with validation and `_to_f64`; all computation runs in the extension.
---
## Tips for Best Performance
1. **Pre-convert once.** If you call multiple indicators on the same array,
convert it to `float64` + C-contiguous once:
```python
close = np.ascontiguousarray(raw_close, dtype=np.float64)
```
2. **Avoid repeated dtype conversions.** Passing a `float32` or `int` array
triggers a copy every call.
3. **Use batch functions for multiple symbols.** For SMA, EMA, and RSI use
`batch_sma`/`batch_ema`/`batch_rsi` (Rust-side loop, single GIL release).
The generic `batch_apply` runs a Python loop over columns; use it only for
indicators that do not have a dedicated Rust batch.
4. **Avoid wrapping in very tight loops.** If you call an indicator millions
of times per second (e.g., in a simulation) use the raw `_ferro_ta` API
and manage conversion yourself.
5. **Profile before optimising.** Use `cProfile` or `py-spy` to find the
actual bottleneck before assuming a particular layer is slow.
---
## Performance Improvements (implemented)
The following improvements are already in place. See
[docs/plans/2026-03-08-production-grade.md](plans/2026-03-08-production-grade.md)
for history and commits.
| Area | Improvement | Where |
|-------------|----------------------------------------------------------------|-------|
| **Utils** | `_to_f64` fast path: no copy for 1-D C-contiguous float64 | `python/ferro_ta/_utils.py` (lines 3439) |
| **Utils** | Polars result: `pl.Series(name, result)` from NumPy buffer (no `.tolist()`) | `python/ferro_ta/_utils.py` (e.g. 254258) |
| **Raw API** | `ferro_ta.raw` — bypass pandas/polars and validation | `python/ferro_ta/raw.py` |
| **Batch** | Rust batch for SMA/EMA/RSI — single GIL release for 2-D | `src/batch/mod.rs`, `python/ferro_ta/batch.py` |
| **Streaming** | All streaming classes in Rust (PyO3) | `src/streaming/mod.rs` |
| **Extended** | All extended indicators (incl. SUPERTREND) in Rust | `src/extended/mod.rs`, `python/ferro_ta/extended.py` wraps Rust |
---
## Known Bottlenecks and Possible Improvements
Maintainer-facing list of slower paths and optional improvements. Update as
bottlenecks are fixed or deferred.
**Backtest** (`python/ferro_ta/backtest.py`):
- Equity with commission uses an O(n) Python loop (lines 374380). Could
vectorize (e.g. cumsum of commission events) or move to a small Rust helper.
- When both slippage and commission are used, `position_changed` is computed
twice; compute once and reuse.
- Built-in strategies do redundant `np.asarray(..., dtype=np.float64)` if
callers already pass contiguous float64; minor.
**Batch** (`python/ferro_ta/batch.py`):
- `batch_apply` runs a Python loop over columns (one Python call per column).
Use `batch_sma`/`batch_ema`/`batch_rsi` when possible.
- No fast path for already 2-D C-contiguous float64 in batch_sma/ema/rsi
(unlike `_to_f64` for 1-D); could avoid a potential copy.
**Options** (`python/ferro_ta/options.py`):
- `iv_rank`, `iv_percentile`, `iv_zscore` use Python loops over windows
(O(n) iterations with per-window NumPy). Could move to Rust or vectorize.
See also `docs/options-volatility.md`.
**Features** (`python/ferro_ta/features.py`):
- With `nan_policy="fill"` and no pandas, a Python loop fills NaN per column.
- Indicators are run in a Python loop (one call per indicator); no bulk API.
**Signals** (`python/ferro_ta/signals.py`):
- `compose(..., method="rank")` uses a list comprehension over columns (one
Python round-trip per column). Could add a Rust batch rank for 2-D input.
**Other**:
- **dsl.py**: Some code paths use Python loops over bars.
- **gpu.py**: Fallback SMA/EMA/RSI use Python loops when GPU is not used.
- **tools.py / viz.py**: `.tolist()` for JSON/Plotly; acceptable for I/O.
- **Validation**: `check_equal_length`, `check_timeperiod` run in Python;
cost is small; moving to Rust is deferred (see production-grade plan).
- **pandas_wrap / polars_wrap**: Per-call overhead; use `ferro_ta.raw` when
minimising overhead.
---
## Benchmarking and comparison
For cross-library speed, run:
`pytest benchmarks/test_speed.py --benchmark-only --benchmark-json=benchmarks/results.json`.
To convert benchmark JSON into a markdown table:
`python benchmarks/benchmark_table.py`.
For focused TA-Lib comparison on the same data/parameters, run
`python benchmarks/bench_vs_talib.py` (requires `pip install ta-lib`).
Results are reported as speedup = TA-Lib time / ferro_ta time (values &gt; 1 mean
ferro_ta is faster). Speedup depends on indicator and data size.
---
## Related Documents
- [`docs/architecture.md`](architecture.md) — how the Rust/Python layers are
organised and how they communicate.
- [`benchmarks/test_speed.py`](../benchmarks/test_speed.py) —
Authoritative cross-library speed benchmarks (pytest-benchmark).
- [`benchmarks/benchmark_table.py`](../benchmarks/benchmark_table.py) —
Render speed tables from `benchmarks/results.json`.
- [`crates/ferro_ta_core/benches/indicators.rs`](../crates/ferro_ta_core/benches/indicators.rs) —
Rust Criterion benchmarks for the pure core (run with `cargo bench -p ferro_ta_core`).
- [`benchmarks/bench_vs_talib.py`](../benchmarks/bench_vs_talib.py) — speed comparison vs
TA-Lib (same data and parameters); run with `python benchmarks/bench_vs_talib.py` (requires
`ta-lib`). See README “Performance vs TA-Lib” for methodology and a comparison table.
- [`benchmarks/check_vs_talib_regression.py`](../benchmarks/check_vs_talib_regression.py) —
CI guardrail script for detecting severe benchmark regressions from JSON artifacts.
+85
View File
@@ -0,0 +1,85 @@
# Plugin Catalog
A curated list of ferro-ta plugins and community extensions.
> **Note:** This catalog is community-maintained and provided on a best-effort
> basis. Plugins are not endorsed or audited by the ferro-ta maintainers.
> Verify each plugin before use in production.
---
## How to Add Your Plugin
1. Verify that your plugin works with the current ferro-ta release.
2. Open a pull request adding a row to the table below. Include:
- **Name**: package name (PyPI or GitHub)
- **Description**: one-line summary of what the plugin adds
- **Install**: `pip install ...` command
- **Link**: GitHub or PyPI URL
**Listing criteria:**
- Has a README or documentation describing what it does.
- Works with the current or previous minor version of ferro-ta.
- Is publicly available (PyPI or GitHub).
---
## Known Plugins
| Name | Description | Install | Link |
|------|-------------|---------|------|
| *(none yet — be the first!)* | | | |
---
## Reference Implementation
The `examples/custom_indicator.py` file in the ferro-ta repository serves as
the canonical reference for building a plugin. See [Writing a plugin](plugins.rst)
for the full guide.
```python
# Minimal plugin example
from ferro_ta.registry import register
from ferro_ta import RSI, SMA
def SMOOTH_RSI(close, timeperiod=14, smooth=3):
"""Smoothed RSI: RSI of RSI values."""
return SMA(RSI(close, timeperiod=timeperiod), timeperiod=smooth)
register("SMOOTH_RSI", SMOOTH_RSI)
```
---
## Publishing Your Plugin to PyPI
1. **Implement** your indicator(s) following the [plugin contract](plugins.rst).
2. **Package** with pyproject.toml using the `ferro_ta.plugins` entry point:
```toml
[project]
name = "ferro-ta-myplugin"
version = "0.1.0"
dependencies = ["ferro_ta>=0.1.0"]
[project.entry-points."ferro_ta.plugins"]
auto_register = "ferro_ta_myplugin:register_all"
```
3. **Publish** to PyPI:
```bash
pip install build twine
python -m build
twine upload dist/*
```
4. **Submit a PR** to add your plugin to this catalog.
---
## Removal Requests
If you are the maintainer of a listed plugin and want it removed, open a
GitHub issue with the title "Plugin catalog removal: <name>".
+91
View File
@@ -0,0 +1,91 @@
Writing a plugin
================
The plugin registry lets you register custom indicator functions and call them by name
alongside built-in indicators. This page describes the **plugin contract**, how to
register and run plugins, and a full example.
Plugin contract
---------------
A plugin is a **callable** (function or callable object) that satisfies:
1. **Signature**
- At least one positional argument that is array-like (e.g. ``close``, ``high``, ``low``).
- Optional ``*args`` and ``**kwargs`` for parameters (e.g. ``timeperiod=14``).
- :func:`ferro_ta.registry.run` forwards all ``*args`` and ``**kwargs`` to the callable.
2. **Return type**
- A single ``numpy.ndarray``, or
- A tuple of ``numpy.ndarray`` (for multi-output indicators).
- Output length should match input length (same number of bars); document any exception.
3. **Behaviour**
- The callable may use ``ferro_ta`` internally (e.g. call :func:`ferro_ta.RSI` and then apply another transformation).
- Plugins run with the caller's privileges; there is no sandboxing.
Validation
----------
:func:`ferro_ta.registry.register` checks that the provided object is callable. If not,
it raises ``TypeError``. No strict signature check is performed at registration time
so that valid plugins (e.g. with default arguments) are not rejected.
Step-by-step
------------
1. **Write a function** that accepts at least one array-like and returns one or more
arrays of the same length as the first argument.
2. **Register it** with :func:`ferro_ta.registry.register`:
.. code-block:: python
from ferro_ta.registry import register
register("MY_INDICATOR", my_indicator_function)
3. **Call it by name** with :func:`ferro_ta.registry.run`:
.. code-block:: python
from ferro_ta.registry import run
result = run("MY_INDICATOR", close, timeperiod=14)
4. **List all indicators** (built-in and registered) with :func:`ferro_ta.registry.list_indicators`.
Full example
------------
The following plugin computes a smoothed RSI (RSI of RSI, or "double RSI") and is
included in the repo as ``examples/custom_indicator.py``:
.. code-block:: python
"""Example plugin: smoothed RSI (RSI applied to RSI values)."""
import numpy as np
from ferro_ta.registry import register, run, list_indicators
from ferro_ta import RSI, SMA
def SMOOTH_RSI(close, timeperiod=14, smooth=3):
"""Smoothed RSI: RSI then SMA of the RSI series."""
rsi = RSI(close, timeperiod=timeperiod)
return SMA(rsi, timeperiod=smooth)
if __name__ == "__main__":
register("SMOOTH_RSI", SMOOTH_RSI)
close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 44.61, 44.33])
out = run("SMOOTH_RSI", close, timeperiod=5, smooth=2)
print("SMOOTH_RSI:", out)
assert "SMOOTH_RSI" in list_indicators()
API reference
-------------
- :func:`ferro_ta.registry.register` — Register a callable under a name.
- :func:`ferro_ta.registry.unregister` — Remove a registered indicator.
- :func:`ferro_ta.registry.get` — Return the callable for a name.
- :func:`ferro_ta.registry.run` — Look up by name and call with given args/kwargs.
- :func:`ferro_ta.registry.list_indicators` — Sorted list of all registered names.
- :exc:`ferro_ta.registry.FerroTARegistryError` — Raised when a name is not found.
See :mod:`ferro_ta.registry` for full docstrings.
+103
View File
@@ -0,0 +1,103 @@
Quick Start
===========
Installation
------------
.. code-block:: bash
pip install ferro-ta
# For Pandas support:
pip install ferro-ta pandas
# For benchmarks:
pip install ferro-ta pytest-benchmark
Basic Usage
-----------
All functions accept NumPy arrays and return NumPy arrays:
.. code-block:: python
import numpy as np
from ferro_ta import SMA, EMA, RSI, MACD, BBANDS, ATR
close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33])
high = close + 0.5
low = close - 0.5
# Single output
sma = SMA(close, timeperiod=5)
ema = EMA(close, timeperiod=5)
rsi = RSI(close, timeperiod=5)
atr = ATR(high, low, close, timeperiod=5)
# Multi output
upper, middle, lower = BBANDS(close, timeperiod=5)
macd_line, signal, histogram = MACD(close)
Pandas Integration
------------------
All functions transparently accept ``pandas.Series`` and preserve the index:
.. code-block:: python
import pandas as pd
from ferro_ta import SMA, BBANDS
idx = pd.date_range("2024-01-01", periods=10, freq="D")
close = pd.Series([44.34, 44.09, 44.15, 43.61, 44.33,
44.83, 45.10, 45.15, 43.61, 44.33], index=idx)
sma = SMA(close, timeperiod=3) # → pd.Series, same index
upper, mid, lower = BBANDS(close, timeperiod=3) # → tuple of pd.Series
Streaming / Live Trading
------------------------
Use the :mod:`ferro_ta.streaming` module for bar-by-bar processing:
.. code-block:: python
from ferro_ta.streaming import StreamingSMA, StreamingRSI, StreamingATR
sma = StreamingSMA(period=5)
rsi = StreamingRSI(period=14)
atr = StreamingATR(period=14)
for bar in live_feed:
current_sma = sma.update(bar.close)
current_rsi = rsi.update(bar.close)
current_atr = atr.update(bar.high, bar.low, bar.close)
Extended Indicators
-------------------
.. code-block:: python
from ferro_ta import VWAP, SUPERTREND, ICHIMOKU, DONCHIAN, PIVOT_POINTS
import numpy as np
high = np.array([...])
low = np.array([...])
close = np.array([...])
vol = np.array([...])
# VWAP
vwap = VWAP(high, low, close, vol)
rolling_vwap = VWAP(high, low, close, vol, timeperiod=14)
# Supertrend
st_line, direction = SUPERTREND(high, low, close, timeperiod=7, multiplier=3.0)
# Ichimoku Cloud
tenkan, kijun, senkou_a, senkou_b, chikou = ICHIMOKU(high, low, close)
# Donchian Channels
dc_upper, dc_mid, dc_lower = DONCHIAN(high, low, timeperiod=20)
# Pivot Points
pivot, r1, s1, r2, s2 = PIVOT_POINTS(high, low, close, method="classic")
+213
View File
@@ -0,0 +1,213 @@
# Rust-First Architecture Policy
> **Rule:** All non-trivial computation and processing logic MUST be
> implemented in Rust and exposed to Python via PyO3. Python is the
> **interface layer** only.
---
## Rationale
ferro-ta is built on the insight that Python is excellent as a glue layer
(validation, type dispatch, pandas/polars wrapping) but poor as a compute
engine (GIL, interpreter overhead, per-call allocation). Every Python loop
over data is a performance regression.
This policy formalises what the codebase already does for standard TA-Lib
indicators and extends it to all new and existing indicators.
---
## The Boundary
```
Python layer (thin) Rust layer (thick)
───────────────────────────── ────────────────────────────────────
ferro_ta/overlap.py ────▶ src/overlap/mod.rs
ferro_ta/momentum.py ────▶ src/momentum/mod.rs
ferro_ta/streaming.py ────▶ src/streaming/mod.rs (PyO3 classes)
ferro_ta/extended.py ────▶ src/extended/mod.rs
ferro_ta/math_ops.py ────▶ src/math_ops/mod.rs
ferro_ta/batch.py ────▶ src/batch/mod.rs
ferro_ta/pattern.py ────▶ src/pattern/mod.rs
... ────▶ ...
```
**Python layer responsibilities (ONLY):**
- Input validation (`check_equal_length`, `check_timeperiod`)
- `_to_f64()` conversion (already has fast path for contiguous float64)
- pandas/polars wrapping (via `pandas_wrap` / `polars_wrap` decorators)
- Re-exporting and documentation
**Rust layer responsibilities (EVERYTHING ELSE):**
- All loops over data
- All rolling window computations
- All stateful streaming state machines
- All mathematical transformations applied bar-by-bar
- All batch operations
---
## Implementation Rules
### Rule 1: New indicators go in Rust first
When adding a new indicator:
1. Implement the algorithm in `src/<category>/mod.rs` (or a new category
module if the category does not exist).
2. Register the function in `src/lib.rs` via `<category>::register(m)?`.
3. Write a thin Python wrapper in `python/ferro_ta/<category>.py` that:
- Validates inputs
- Calls `_to_f64()` on array arguments
- Calls the Rust function
- Wraps the result for pandas/polars if the output is a `np.ndarray`
4. Export from `python/ferro_ta/__init__.py` via the usual `__all__` +
`pandas_wrap` / `polars_wrap` pattern.
**Do not write the algorithm in Python first and port it later.** Porting is
expensive; getting it right in Rust first is cheaper.
### Rule 2: Porting Python algorithms to Rust
If you find a Python loop that iterates over data (e.g., `for i in range(n):`)
or a pure-Python rolling window computation, it is a porting candidate.
Priority order:
1. Hot paths called from batch or streaming contexts.
2. Any loop where `n` can be 10,000+.
3. Loops inside extended indicators.
When porting:
- The Python function becomes a thin wrapper that calls the Rust function.
- There is no Python fallback; the extension must be built. If the Rust call
fails, the function is allowed to fail (no silent fallback to Python).
### Rule 3: No raw NumPy loops in indicator logic
The following patterns are **forbidden** in indicator implementation code:
```python
# ❌ Forbidden: Python loop over data
for i in range(n):
result[i] = compute(data[i - period : i])
# ❌ Forbidden: nested Python loop in rolling window
for i in range(timeperiod - 1, n):
result[i] = data[i + 1 - timeperiod : i + 1].max()
```
The following are **allowed** in Python wrappers only:
```python
# ✓ Allowed: vectorised NumPy (no loop)
result = np.cumsum(data)
# ✓ Allowed: scalar operations (no loop over n)
tp = (high + low + close) / 3.0
```
### Rule 4: Streaming classes are Rust PyO3 classes
Streaming (bar-by-bar stateful) classes **must** be `#[pyclass]` types
implemented in `src/streaming/mod.rs`. Python should import and re-export
them — never re-implement them.
Template for a new streaming class:
```rust
#[pyclass(module = "ferro_ta._ferro_ta")]
pub struct StreamingMyIndicator {
period: usize,
// ... state fields
}
#[pymethods]
impl StreamingMyIndicator {
#[new]
pub fn new(period: usize) -> PyResult<Self> { ... }
pub fn update(&mut self, value: f64) -> f64 { ... }
pub fn reset(&mut self) { ... }
#[getter]
pub fn period(&self) -> usize { self.period }
}
```
Then in `src/streaming/mod.rs::register()`:
```rust
m.add_class::<StreamingMyIndicator>()?;
```
And in `python/ferro_ta/streaming.py`:
```python
from ferro_ta._ferro_ta import StreamingMyIndicator # noqa: F401
```
### Rule 5: Batch operations are Rust functions
Batch functions that process multiple time-series at once must be implemented
in `src/batch/mod.rs`. They accept 2-D numpy arrays and loop over columns
entirely in Rust (one GIL release covers all columns).
### Rule 6: Document the Rust location
Every Python wrapper docstring must note that the algorithm is in Rust:
```python
def MY_INDICATOR(close, timeperiod=14):
"""My Indicator.
...
Notes
-----
Implemented in Rust — see ``src/my_category/my_indicator.rs``.
"""
```
---
## What Belongs in Python Only
Some things are **intentionally** in Python and should stay there:
| Thing | Why it stays in Python |
|---|---|
| `pandas_wrap` / `polars_wrap` decorators | Pandas/polars are Python libraries; zero-copy Rust wrappers are not practical here |
| `_to_f64` fast path check | One Python branch beats a PyO3 round-trip for the already-valid case |
| `check_equal_length`, `check_timeperiod` | Negligible overhead vs indicator computation; keeps Rust functions focused |
| `Pipeline`, `Config` | Orchestration logic — Python is appropriate |
| `gpu.py` (CuPy PoC) | CuPy is Python-native; Rust cannot talk to GPU without CUDA bindings |
| `backtest.py` helpers | High-level orchestration |
---
## Current Status (as of 2026-03-08)
| Module | Logic location |
|---|---|
| `overlap.py` | ✅ Rust (`src/overlap/`) |
| `momentum.py` | ✅ Rust (`src/momentum/`) |
| `volatility.py` | ✅ Rust (`src/volatility/`) |
| `statistic.py` | ✅ Rust (`src/statistic/`) |
| `volume.py` | ✅ Rust (`src/volume/`) |
| `price_transform.py` | ✅ Rust (`src/price_transform/`) |
| `pattern.py` | ✅ Rust (`src/pattern/`) |
| `cycle.py` | ✅ Rust (`src/cycle/`) |
| `batch.py` | ✅ Rust (`src/batch/`) |
| `streaming.py` | ✅ Rust (`src/streaming/`) — all 9 classes |
| `extended.py` | ✅ Rust (`src/extended/`) — all 10 indicators |
| `math_ops.py` (rolling) | ✅ Rust (`src/math_ops/`) — SUM/MAX/MIN/MAXINDEX/MININDEX |
| `math_ops.py` (element-wise) | ✅ NumPy wrappers (no loops — vectorised by NumPy's C core) |
| `gpu.py` | ⚠️ CuPy (Python/CUDA — intentional, see above) |
| `pipeline.py` | ✅ Orchestration only (no indicator loops) |
| `config.py` | ✅ Configuration only |
| `backtest.py` | ✅ Orchestration only |
---
## Checklist for New Indicator PRs
- [ ] Algorithm implemented in `src/<category>/mod.rs`
- [ ] `cargo fmt --check` passes
- [ ] `cargo clippy --release -- -D warnings` passes
- [ ] Python wrapper is **thin** (validation + `_to_f64` + Rust call)
- [ ] No Python loops over data
- [ ] Docstring notes "Implemented in Rust"
- [ ] Registered in `src/lib.rs` and exported from `__init__.py`
- [ ] Tests added in `tests/`
+105
View File
@@ -0,0 +1,105 @@
# API Stability Policy
This document describes which parts of **ferro-ta** are considered stable, which
are experimental, and what the deprecation process is.
---
## Stability Tiers
### Stable
The following are considered **stable** and will not change in incompatible ways
without a major version bump (i.e., following [Semantic Versioning 2.0.0]):
- All indicator functions exported from `ferro_ta.*` by name (e.g. `ferro_ta.SMA`,
`ferro_ta.RSI`, `ferro_ta.BBANDS`).
- Sub-module imports: `from ferro_ta.overlap import SMA` etc.
- Function signatures: positional array arguments and `timeperiod` / other keyword
arguments documented in the docstrings.
- Return types: single `np.ndarray` or tuple of `np.ndarray` as documented.
- Exception classes: `FerroTAError`, `FerroTAValueError`, `FerroTAInputError`.
- Utility helpers: `ferro_ta.utils.get_ohlcv`, `ferro_ta._utils.get_ohlcv`.
- `pandas_wrap` / `polars_wrap` behaviour: `pd.Series` in → `pd.Series` out;
`pl.Series` in → `pl.Series` out.
- Registry API: `ferro_ta.registry.register`, `run`, `get`, `list_indicators`.
- Pipeline API: `ferro_ta.pipeline.Pipeline`, `make_pipeline`.
- Config API: `ferro_ta.config.set_default`, `ferro_ta.config.Config`.
### Experimental
The following are **experimental** and may change in minor releases:
- **`ferro_ta.raw`** — direct access to the compiled Rust extension; function
signatures follow the Rust layer and may change when the Rust layer changes.
- **`ferro_ta.batch`** internals — the Python↔Rust dispatch logic may change as
the Rust batch API evolves.
- **`ferro_ta.streaming`** — the streaming class API (especially the `reset()`
method and internal buffer access) may evolve; the `update()` method signature
is stable.
- **`ferro_ta.extended`** — extended indicators (VWAP, SUPERTREND, etc.) are
considered stable in return shape and semantics, but implementation details
(e.g. whether computation is in Python or Rust) may change.
- **`ferro_ta.backtest`** — the backtest helpers are convenience utilities and
may be refactored.
- **`ferro_ta.gpu`** — the CuPy GPU backend is an experimental proof-of-concept.
### Internal / Private
Names prefixed with `_` (e.g. `_ferro_ta`, `_utils`, `_to_f64`) are internal
and may change at any time without notice. Do not rely on them in user code.
---
## Versioning
ferro-ta follows [Semantic Versioning 2.0.0]:
| Change type | Version bump |
|----------------------------------------|--------------|
| Breaking API change (removed indicator, renamed parameter, changed return type) | **MAJOR** |
| New indicators, new sub-modules, new features (backward-compatible) | **MINOR** |
| Bug fixes, performance improvements, docs, dependency bumps | **PATCH** |
The current version (`0.1.x`) is pre-stable — **breaking changes are possible
in minor releases**. When the project reaches 1.0.0, the full SemVer
contract kicks in.
---
## Deprecation Policy
Before removing or renaming any **stable** API:
1. The deprecated name/function is kept for at least **one minor release** after
the deprecation notice.
2. A `DeprecationWarning` is raised when the deprecated API is used.
3. The deprecation and removal are documented in `CHANGELOG.md` under
`### Deprecated` and `### Removed`.
Example timeline:
- `0.2.0``OLD_NAME` deprecated, `DeprecationWarning` added; `NEW_NAME` available.
- `0.3.0``OLD_NAME` removed.
---
## What is NOT covered
- The Rust ABI of the compiled extension (`_ferro_ta.so` / `_ferro_ta.pyd`).
Only the Python-level API is covered by this policy.
- Numerical precision beyond what is documented (exact TA-Lib matches for listed
indicators, "correlated" for Wilder-seeded indicators).
- Performance characteristics — we may change the implementation to be faster
(e.g. moving a Python loop to Rust) without a version bump.
---
## Requesting Stability Guarantees
If you depend on an experimental API and would like it promoted to stable, please
open an issue on GitHub explaining your use case. We will consider promoting
experimental APIs to stable when they have been in use long enough to be confident
in their design.
[Semantic Versioning 2.0.0]: https://semver.org/
+12
View File
@@ -0,0 +1,12 @@
Streaming API
=============
The :mod:`ferro_ta.streaming` module provides stateful, bar-by-bar indicator computation
for live/real-time trading. Each class maintains an internal buffer and returns ``NaN``
during the warmup period.
.. automodule:: ferro_ta.streaming
:no-index:
:members:
:undoc-members:
:show-inheritance: