feat: init the repo
This commit is contained in:
Vendored
+203
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
Batch API
|
||||
=========
|
||||
|
||||
.. automodule:: ferro_ta.batch
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Cycle
|
||||
=====
|
||||
|
||||
.. automodule:: ferro_ta.cycle
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,8 @@
|
||||
Exceptions and validation
|
||||
=========================
|
||||
|
||||
.. automodule:: ferro_ta.exceptions
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
:exclude-members: code, suggestion
|
||||
@@ -0,0 +1,7 @@
|
||||
Extended
|
||||
========
|
||||
|
||||
.. automodule:: ferro_ta.extended
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,19 @@
|
||||
API Reference
|
||||
=============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
exceptions
|
||||
overlap
|
||||
momentum
|
||||
volume
|
||||
volatility
|
||||
statistic
|
||||
price_transform
|
||||
pattern
|
||||
cycle
|
||||
math_ops
|
||||
extended
|
||||
streaming
|
||||
batch
|
||||
@@ -0,0 +1,7 @@
|
||||
Math Ops
|
||||
========
|
||||
|
||||
.. automodule:: ferro_ta.math_ops
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Momentum
|
||||
========
|
||||
|
||||
.. automodule:: ferro_ta.momentum
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Overlap Studies
|
||||
===============
|
||||
|
||||
.. automodule:: ferro_ta.overlap
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Pattern
|
||||
=======
|
||||
|
||||
.. automodule:: ferro_ta.pattern
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Price Transform
|
||||
===============
|
||||
|
||||
.. automodule:: ferro_ta.price_transform
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Statistic
|
||||
=========
|
||||
|
||||
.. automodule:: ferro_ta.statistic
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Streaming
|
||||
=========
|
||||
|
||||
.. automodule:: ferro_ta.streaming
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Volatility
|
||||
==========
|
||||
|
||||
.. automodule:: ferro_ta.volatility
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -0,0 +1,7 @@
|
||||
Volume
|
||||
======
|
||||
|
||||
.. automodule:: ferro_ta.volume
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
@@ -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.10–3.13 classifiers
|
||||
@@ -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).
|
||||
@@ -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 10–100× 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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 2–3× 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)]
|
||||
```
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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>`_.
|
||||
@@ -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-ta–specific 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.
|
||||
@@ -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:
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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/>
|
||||
@@ -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.
|
||||
@@ -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 34–39) |
|
||||
| **Utils** | Polars result: `pl.Series(name, result)` from NumPy buffer (no `.tolist()`) | `python/ferro_ta/_utils.py` (e.g. 254–258) |
|
||||
| **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 374–380). 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 > 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.
|
||||
@@ -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>".
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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/`
|
||||
@@ -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/
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user