feat: init the repo
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
ferro_ta.pipeline — Indicator Pipeline and Composition API.
|
||||
|
||||
Build reusable pipelines that apply one or more indicators to price arrays
|
||||
in a single call. A :class:`Pipeline` collects named steps, runs them in
|
||||
order, and returns the results as a dictionary.
|
||||
|
||||
This module is designed for:
|
||||
|
||||
- Backtesting workflows that need multiple indicators computed on the same data.
|
||||
- Feature engineering for machine-learning pipelines.
|
||||
- Batch scenarios where you want all indicator values in one dictionary.
|
||||
|
||||
Usage
|
||||
-----
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta.tools.pipeline import Pipeline
|
||||
>>> from ferro_ta import SMA, EMA, RSI
|
||||
>>>
|
||||
>>> close = np.array([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10,
|
||||
... 45.15, 43.61, 44.33, 44.83, 45.10, 45.15, 43.61, 44.33])
|
||||
>>>
|
||||
>>> pipe = (
|
||||
... Pipeline()
|
||||
... .add("sma_10", SMA, timeperiod=10)
|
||||
... .add("ema_10", EMA, timeperiod=10)
|
||||
... .add("rsi_14", RSI, timeperiod=14)
|
||||
... )
|
||||
>>> results = pipe.run(close)
|
||||
>>> print(list(results.keys()))
|
||||
['sma_10', 'ema_10', 'rsi_14']
|
||||
>>> results["sma_10"].shape
|
||||
(15,)
|
||||
|
||||
Chaining convenience
|
||||
--------------------
|
||||
:meth:`Pipeline.add` returns ``self`` so calls can be chained.
|
||||
|
||||
The :func:`make_pipeline` function is a convenience wrapper:
|
||||
|
||||
>>> from ferro_ta.tools.pipeline import make_pipeline
|
||||
>>> pipe = make_pipeline(sma_5=(SMA, {"timeperiod": 5}),
|
||||
... rsi_14=(RSI, {"timeperiod": 14}))
|
||||
>>> results = pipe.run(close)
|
||||
|
||||
Multi-output indicators
|
||||
-----------------------
|
||||
For indicators that return tuples (e.g. BBANDS, MACD) you can pass an
|
||||
optional ``output_keys`` argument to unpack the tuple into named keys:
|
||||
|
||||
>>> from ferro_ta import BBANDS, MACD
|
||||
>>> pipe = (
|
||||
... Pipeline()
|
||||
... .add("bb", BBANDS, output_keys=["bb_upper", "bb_mid", "bb_lower"],
|
||||
... timeperiod=5, nbdevup=2.0, nbdevdn=2.0)
|
||||
... .add("macd", MACD, output_keys=["macd", "signal", "hist"],
|
||||
... fastperiod=3, slowperiod=5, signalperiod=2)
|
||||
... )
|
||||
>>> results = pipe.run(close)
|
||||
>>> list(results.keys())
|
||||
['bb_upper', 'bb_mid', 'bb_lower', 'macd', 'signal', 'hist']
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import ArrayLike
|
||||
|
||||
from ferro_ta._utils import _to_f64
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal step type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Step:
|
||||
"""A single pipeline step (one indicator call)."""
|
||||
|
||||
__slots__ = ("name", "func", "kwargs", "output_keys")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
func: Callable[..., Any],
|
||||
kwargs: dict[str, Any],
|
||||
output_keys: Optional[list[str]],
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.func = func
|
||||
self.kwargs = kwargs
|
||||
self.output_keys = output_keys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""A reusable indicator pipeline.
|
||||
|
||||
A Pipeline stores a sequence of named indicator steps and can be applied
|
||||
to one or more data arrays. Calling :meth:`run` returns a dictionary
|
||||
mapping step names to result arrays.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
steps : list of (name, func, kwargs, output_keys), optional
|
||||
Pre-built steps (rarely needed; prefer :meth:`add`).
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA, RSI
|
||||
>>> from ferro_ta.tools.pipeline import Pipeline
|
||||
>>> close = np.arange(1.0, 20.0)
|
||||
>>> results = Pipeline().add("sma5", SMA, timeperiod=5).run(close)
|
||||
>>> results["sma5"].shape
|
||||
(19,)
|
||||
"""
|
||||
|
||||
def __init__(self, steps: Optional[list[_Step]] = None) -> None:
|
||||
self._steps: list[_Step] = list(steps) if steps else []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add(
|
||||
self,
|
||||
name: str,
|
||||
func: Callable[..., Any],
|
||||
output_keys: Optional[list[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> Pipeline:
|
||||
"""Add an indicator step to the pipeline.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Key under which the result is stored in the output dict.
|
||||
For multi-output indicators with *output_keys*, this argument
|
||||
is ignored (the output_keys are used instead).
|
||||
func : callable
|
||||
Indicator function (e.g. ``SMA``, ``RSI``, ``BBANDS``).
|
||||
output_keys : list of str, optional
|
||||
For multi-output indicators that return a tuple (e.g. BBANDS,
|
||||
MACD), supply the names for each output. If not provided and
|
||||
the indicator returns a tuple, the results are stored as
|
||||
``name_0``, ``name_1``, … .
|
||||
**kwargs
|
||||
Keyword arguments forwarded to *func* (e.g. ``timeperiod=14``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Pipeline
|
||||
Returns ``self`` for chaining.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If *name* is already used by an existing step (and no
|
||||
*output_keys* are supplied).
|
||||
TypeError
|
||||
If *func* is not callable.
|
||||
"""
|
||||
if not callable(func):
|
||||
raise TypeError(f"func must be callable, got {type(func).__name__}")
|
||||
|
||||
# Check for duplicate names (only when output_keys is not given)
|
||||
existing = self._output_names()
|
||||
if output_keys:
|
||||
for key in output_keys:
|
||||
if key in existing:
|
||||
raise ValueError(f"Duplicate output key '{key}' in pipeline")
|
||||
else:
|
||||
if name in existing:
|
||||
raise ValueError(
|
||||
f"A step named '{name}' already exists. "
|
||||
"Use a different name or remove the existing step first."
|
||||
)
|
||||
|
||||
self._steps.append(_Step(name, func, kwargs, output_keys))
|
||||
return self
|
||||
|
||||
def remove(self, name: str) -> Pipeline:
|
||||
"""Remove the step identified by *name* (or *output_keys* containing *name*).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Step name or one of the output keys.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Pipeline
|
||||
Returns ``self`` for chaining.
|
||||
|
||||
Raises
|
||||
------
|
||||
KeyError
|
||||
If no step with the given name is found.
|
||||
"""
|
||||
for i, step in enumerate(self._steps):
|
||||
if step.name == name or (step.output_keys and name in step.output_keys):
|
||||
del self._steps[i]
|
||||
return self
|
||||
raise KeyError(f"No step named '{name}' in pipeline")
|
||||
|
||||
def steps(self) -> list[str]:
|
||||
"""Return a list of step names (or output keys for multi-output steps)."""
|
||||
return self._output_names()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Execution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self, close: ArrayLike, **extra: Any) -> dict[str, np.ndarray]:
|
||||
"""Apply all pipeline steps to *close* and return results.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
close : array-like
|
||||
Primary input array (close prices). For indicators that need
|
||||
additional arrays (e.g. high/low/volume), pass them as keyword
|
||||
arguments (see *extra*).
|
||||
**extra
|
||||
Additional arrays (e.g. ``high=…``, ``low=…``, ``volume=…``).
|
||||
Each step's kwargs are merged with *extra* on a per-call basis;
|
||||
step-level kwargs take precedence.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict of str → numpy.ndarray
|
||||
Mapping from output name to result array.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA, ATR
|
||||
>>> from ferro_ta.tools.pipeline import Pipeline
|
||||
>>> n = 20
|
||||
>>> close = np.random.rand(n) + 10
|
||||
>>> high = close + 0.5
|
||||
>>> low = close - 0.5
|
||||
>>> pipe = (
|
||||
... Pipeline()
|
||||
... .add("sma", SMA, timeperiod=5)
|
||||
... )
|
||||
>>> out = pipe.run(close)
|
||||
>>> out["sma"].shape
|
||||
(20,)
|
||||
"""
|
||||
close_arr = _to_f64(close)
|
||||
output: dict[str, np.ndarray] = {}
|
||||
|
||||
for step in self._steps:
|
||||
# Build merged kwargs: extra is the base; step-level kwargs override
|
||||
merged = dict(extra)
|
||||
merged.update(step.kwargs)
|
||||
|
||||
result = step.func(close_arr, **merged)
|
||||
|
||||
if isinstance(result, tuple):
|
||||
if step.output_keys:
|
||||
if len(step.output_keys) != len(result):
|
||||
raise ValueError(
|
||||
f"Step '{step.name}': output_keys has {len(step.output_keys)} "
|
||||
f"entries but the function returned {len(result)} values."
|
||||
)
|
||||
for key, arr in zip(step.output_keys, result):
|
||||
output[key] = np.asarray(arr, dtype=np.float64)
|
||||
else:
|
||||
for i, arr in enumerate(result):
|
||||
output[f"{step.name}_{i}"] = np.asarray(arr, dtype=np.float64)
|
||||
else:
|
||||
output[step.name] = np.asarray(result, dtype=np.float64)
|
||||
|
||||
return output
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _output_names(self) -> list[str]:
|
||||
names: list[str] = []
|
||||
for step in self._steps:
|
||||
if step.output_keys:
|
||||
names.extend(step.output_keys)
|
||||
else:
|
||||
names.append(step.name)
|
||||
return names
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._steps)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
step_str = ", ".join(self._output_names())
|
||||
return f"Pipeline([{step_str}])"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Convenience factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_pipeline(**named_steps: tuple[Callable[..., Any], dict[str, Any]]) -> Pipeline:
|
||||
"""Build a :class:`Pipeline` from keyword arguments.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
**named_steps
|
||||
Each keyword argument is a step: ``name=(func, kwargs_dict)``.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Pipeline
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> import numpy as np
|
||||
>>> from ferro_ta import SMA, RSI
|
||||
>>> from ferro_ta.tools.pipeline import make_pipeline
|
||||
>>> pipe = make_pipeline(sma_5=(SMA, {"timeperiod": 5}),
|
||||
... rsi_14=(RSI, {"timeperiod": 14}))
|
||||
>>> results = pipe.run(np.arange(1.0, 25.0))
|
||||
>>> sorted(results.keys())
|
||||
['rsi_14', 'sma_5']
|
||||
"""
|
||||
pipe = Pipeline()
|
||||
for name, step in named_steps.items():
|
||||
func, kwargs = step
|
||||
pipe.add(name, func, **kwargs)
|
||||
return pipe
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Pipeline",
|
||||
"make_pipeline",
|
||||
]
|
||||
Reference in New Issue
Block a user