扩展指标

This commit is contained in:
2026-07-09 05:08:16 +08:00
commit 308c46ab9a
537 changed files with 152299 additions and 0 deletions
@@ -0,0 +1,29 @@
"""
ferro_ta.tools — Developer tools, visualisation, alerting, and workflow utilities.
Sub-modules
-----------
* :mod:`ferro_ta.tools.tools` — General-purpose utility helpers (compute_indicator, run_backtest, …)
* :mod:`ferro_ta.tools.viz` — Charting and visualisation API (matplotlib)
* :mod:`ferro_ta.tools.dashboard`— Interactive Streamlit/Dash dashboard helpers
* :mod:`ferro_ta.tools.alerts` — Alert manager and threshold checks
* :mod:`ferro_ta.tools.dsl` — Strategy expression DSL
* :mod:`ferro_ta.tools.pipeline` — Indicator pipeline builder
* :mod:`ferro_ta.tools.workflow` — Workflow automation helpers
* :mod:`ferro_ta.tools.api_info` — API discovery helpers (:func:`indicators`, :func:`info`)
* :mod:`ferro_ta.tools.gpu` — GPU-accelerated indicator support (requires PyTorch)
Example usage::
from ferro_ta.tools import compute_indicator, run_backtest, list_indicators
from ferro_ta.tools.alerts import check_cross
"""
# Re-export the stable public API from tools.tools.
# tools/tools.py has no ferro_ta module-level imports, so this is safe.
from ferro_ta.tools.tools import ( # noqa: F401
compute_indicator,
describe_indicator,
list_indicators,
run_backtest,
)
@@ -0,0 +1,432 @@
"""
ferro_ta.alerts — Alerts and notification hooks.
================================================
Provides an ``AlertManager`` for registering conditions (threshold crossings,
series cross-overs) and dispatching events to callbacks and/or webhooks.
Supports both **backtest** mode (collect alerts in a list for analysis) and
**live** mode (invoke callbacks or POST to webhook URLs on each condition fire).
Quick start
-----------
>>> import numpy as np
>>> from ferro_ta.tools.alerts import AlertManager
>>> np.random.seed(0)
>>> close = 100 + np.cumsum(np.random.randn(200) * 0.5)
>>> from ferro_ta import RSI
>>> rsi = RSI(close, timeperiod=14)
>>> am = AlertManager()
>>> am.add_threshold_condition("rsi_oversold", rsi, level=30, direction=-1)
>>> am.add_threshold_condition("rsi_overbought", rsi, level=70, direction=1)
>>> fired = am.run_backtest()
>>> print(fired)
API
---
AlertManager
Registry for conditions and callbacks. Use ``add_threshold_condition``
or ``add_cross_condition`` to register conditions, then call
``run_backtest()`` to evaluate all conditions at once.
check_threshold(series, level, direction)
Low-level: return int8 mask — 1 where *series* crosses *level*.
check_cross(fast, slow)
Low-level: return int8 mask — 1 (cross up), -1 (cross down), 0 (no cross).
collect_alert_bars(mask)
Low-level: return indices where *mask* is non-zero.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, Optional
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import check_cross as _rust_check_cross
from ferro_ta._ferro_ta import check_threshold as _rust_check_threshold
from ferro_ta._ferro_ta import collect_alert_bars as _rust_collect_alert_bars
from ferro_ta._utils import _to_f64
_log = logging.getLogger(__name__)
__all__ = [
"AlertEvent",
"AlertManager",
"check_threshold",
"check_cross",
"collect_alert_bars",
]
# ---------------------------------------------------------------------------
# Low-level wrappers
# ---------------------------------------------------------------------------
def check_threshold(
series: ArrayLike,
level: float,
direction: int,
) -> NDArray[np.int8]:
"""Fire an alert when *series* crosses a threshold *level*.
Parameters
----------
series : array-like — indicator values (e.g. RSI close prices)
level : float — threshold value
direction : int
``1`` → fire when *series* crosses **above** *level*.
``-1`` → fire when *series* crosses **below** *level*.
Returns
-------
numpy.ndarray of int8 — 1 at the bar where the crossing occurs, 0 elsewhere.
"""
return np.asarray(
_rust_check_threshold(_to_f64(series), float(level), int(direction)),
dtype=np.int8,
)
def check_cross(
fast: ArrayLike,
slow: ArrayLike,
) -> NDArray[np.int8]:
"""Detect cross-over / cross-under events between two series.
Parameters
----------
fast : array-like — the "fast" series (e.g. short SMA)
slow : array-like — the "slow" series (e.g. long SMA)
Returns
-------
numpy.ndarray of int8:
``1`` at bars where *fast* crosses **above** *slow* (bullish).
``-1`` at bars where *fast* crosses **below** *slow* (bearish).
``0`` elsewhere.
"""
return np.asarray(
_rust_check_cross(_to_f64(fast), _to_f64(slow)),
dtype=np.int8,
)
def collect_alert_bars(mask: ArrayLike) -> NDArray[np.int64]:
"""Return bar indices where *mask* is non-zero (condition fired).
Parameters
----------
mask : array-like of int8 — output of ``check_threshold`` or ``check_cross``
Returns
-------
numpy.ndarray of int64 — indices of fired bars (ascending order)
"""
m = np.asarray(mask, dtype=np.int8)
return np.asarray(_rust_collect_alert_bars(m), dtype=np.int64)
# ---------------------------------------------------------------------------
# AlertEvent
# ---------------------------------------------------------------------------
class AlertEvent:
"""A single alert event.
Attributes
----------
condition_id : str — user-supplied condition name
bar_index : int — bar index where the condition fired
value : float or None — optional series value at the fired bar
payload : dict — extra metadata (e.g. symbol, direction)
"""
__slots__ = ("condition_id", "bar_index", "value", "payload")
def __init__(
self,
condition_id: str,
bar_index: int,
value: Optional[float] = None,
payload: Optional[dict[str, Any]] = None,
) -> None:
self.condition_id = condition_id
self.bar_index = bar_index
self.value = value
self.payload = payload or {}
def __repr__(self) -> str:
return (
f"AlertEvent(condition_id={self.condition_id!r}, "
f"bar_index={self.bar_index}, value={self.value})"
)
def to_dict(self) -> dict[str, Any]:
"""Return event as a plain dict (suitable for JSON serialisation)."""
return {
"condition_id": self.condition_id,
"bar_index": self.bar_index,
"value": self.value,
**self.payload,
}
# ---------------------------------------------------------------------------
# Internal dataclass for condition storage
# ---------------------------------------------------------------------------
@dataclass
class _AlertCondition:
"""Internal representation of a registered alert condition."""
kind: str # "threshold" or "cross"
condition_id: str
series_a: np.ndarray # primary series (or fast series for cross)
series_b: Optional[np.ndarray] # slow series for cross, else None
level: Optional[float] # threshold level (threshold only)
direction: Optional[int] # +1 / -1 (threshold) or None (cross)
callback: Optional[Callable[..., Any]]
webhook_url: Optional[str]
extra_payload: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# AlertManager
# ---------------------------------------------------------------------------
class AlertManager:
"""Registry for alert conditions.
Supports both **backtest** mode (collect events in a list) and
**live** mode (dispatch via callback and/or webhook).
Parameters
----------
symbol : str, optional
Symbol name included in every event payload.
live : bool
If ``True``, ``run_live()`` is used and callbacks/webhooks are invoked
immediately. In backtest mode (``live=False``, default) no external
calls are made unless ``force_live=True`` in ``run_backtest()``.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.alerts import AlertManager
>>> from ferro_ta import RSI, SMA
>>> close = np.cumprod(1 + np.random.randn(100) * 0.01) * 100
>>> rsi = RSI(close)
>>> sma20 = SMA(close, 20)
>>> sma50 = SMA(close, 50)
>>> am = AlertManager(symbol="BTC")
>>> am.add_threshold_condition("rsi_os", rsi, level=30, direction=-1)
>>> am.add_cross_condition("sma_x", sma20, sma50)
>>> events = am.run_backtest()
>>> for ev in events:
... print(ev)
"""
def __init__(
self,
symbol: str = "",
live: bool = False,
) -> None:
self._symbol = symbol
self._live = live
self._conditions: list[_AlertCondition] = []
# ------------------------------------------------------------------
# Registration
# ------------------------------------------------------------------
def add_threshold_condition(
self,
condition_id: str,
series: ArrayLike,
level: float,
direction: int,
callback: Optional[Callable[[AlertEvent], None]] = None,
webhook_url: Optional[str] = None,
**extra_payload: Any,
) -> None:
"""Register a threshold crossing condition.
Parameters
----------
condition_id : str — unique name for this condition
series : array-like — the indicator / price series to watch
level : float — threshold level
direction : int — ``1`` (cross above) or ``-1`` (cross below)
callback : callable, optional — ``callback(event)`` invoked on fire
webhook_url : str, optional — HTTP POST target (live mode only)
**extra_payload : extra keys merged into ``AlertEvent.payload``
"""
self._conditions.append(
_AlertCondition(
kind="threshold",
condition_id=condition_id,
series_a=np.asarray(series, dtype=np.float64),
series_b=None,
level=float(level),
direction=int(direction),
callback=callback,
webhook_url=webhook_url,
extra_payload=dict(extra_payload),
)
)
def add_cross_condition(
self,
condition_id: str,
fast: ArrayLike,
slow: ArrayLike,
callback: Optional[Callable[[AlertEvent], None]] = None,
webhook_url: Optional[str] = None,
**extra_payload: Any,
) -> None:
"""Register a series cross-over / cross-under condition.
Parameters
----------
condition_id : str — unique name for this condition
fast : array-like — the "fast" series
slow : array-like — the "slow" series
callback : callable, optional — ``callback(event)`` invoked on fire
webhook_url : str, optional — HTTP POST target (live mode only)
**extra_payload : extra keys merged into ``AlertEvent.payload``
"""
self._conditions.append(
_AlertCondition(
kind="cross",
condition_id=condition_id,
series_a=np.asarray(fast, dtype=np.float64),
series_b=np.asarray(slow, dtype=np.float64),
level=None,
direction=None,
callback=callback,
webhook_url=webhook_url,
extra_payload=dict(extra_payload),
)
)
# ------------------------------------------------------------------
# Evaluation
# ------------------------------------------------------------------
def run_backtest(
self,
force_live: bool = False,
) -> list[AlertEvent]:
"""Evaluate all registered conditions in batch (backtest mode).
No callbacks or webhooks are invoked unless ``force_live=True``.
Parameters
----------
force_live : bool
If ``True``, invoke callbacks and webhooks even in backtest mode.
Returns
-------
list of :class:`AlertEvent` — all events that fired, sorted by bar
index (then condition_id for ties).
"""
events: list[AlertEvent] = []
do_live = self._live or force_live
for cond in self._conditions:
if cond.kind == "threshold":
mask = _rust_check_threshold(
np.ascontiguousarray(cond.series_a, dtype=np.float64),
float(cond.level), # type: ignore[arg-type]
int(cond.direction), # type: ignore[arg-type]
)
bars = _rust_collect_alert_bars(mask)
for bar_idx in bars:
ev = AlertEvent(
condition_id=cond.condition_id,
bar_index=int(bar_idx),
value=float(cond.series_a[int(bar_idx)]),
payload={
"symbol": self._symbol,
"direction": int(cond.direction), # type: ignore[arg-type]
**cond.extra_payload,
},
)
events.append(ev)
if do_live:
self._dispatch(ev, cond.callback, cond.webhook_url)
elif cond.kind == "cross":
mask = _rust_check_cross(
np.ascontiguousarray(cond.series_a, dtype=np.float64),
np.ascontiguousarray(cond.series_b, dtype=np.float64), # type: ignore[arg-type]
)
bars = _rust_collect_alert_bars(mask)
for bar_idx in bars:
cross_dir = int(mask[int(bar_idx)])
ev = AlertEvent(
condition_id=cond.condition_id,
bar_index=int(bar_idx),
value=float(cond.series_a[int(bar_idx)]),
payload={
"symbol": self._symbol,
"direction": cross_dir,
**cond.extra_payload,
},
)
events.append(ev)
if do_live:
self._dispatch(ev, cond.callback, cond.webhook_url)
events.sort(key=lambda e: (e.bar_index, e.condition_id))
return events
# ------------------------------------------------------------------
# Dispatch helpers
# ------------------------------------------------------------------
@staticmethod
def _dispatch(
event: AlertEvent,
callback: Optional[Callable[[AlertEvent], None]],
webhook_url: Optional[str],
) -> None:
"""Invoke callback and/or HTTP POST to webhook."""
if callback is not None:
try:
callback(event)
except Exception as exc: # noqa: BLE001
_log.warning("Alert callback raised an exception: %s", exc)
if webhook_url:
AlertManager._post_webhook(webhook_url, event.to_dict())
@staticmethod
def _post_webhook(url: str, payload: dict[str, Any]) -> None:
"""HTTP POST *payload* as JSON to *url* (best-effort, no retry)."""
import urllib.error
import urllib.request
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={"Content-type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5):
pass
except (urllib.error.URLError, OSError, ValueError) as exc:
_log.warning("Webhook POST to %s failed: %s", url, exc)
@@ -0,0 +1,300 @@
"""
ferro_ta.api_info — API discovery helpers.
Provides :func:`indicators`, :func:`methods`, :func:`about`, and :func:`info`
for exploring the ferro_ta public API without reading source code.
Usage
-----
>>> import ferro_ta
>>> ferro_ta.indicators() # all indicators, sorted
>>> ferro_ta.indicators(category="momentum") # filter by category
>>> ferro_ta.methods() # public callables across modules
>>> ferro_ta.about()["version"] # package metadata summary
>>> ferro_ta.info(ferro_ta.SMA) # parameter docs for SMA
API
---
indicators(category=None) — Return list of dicts describing every indicator.
methods(category=None) — Return list of public callables across modules.
about() — Return package/version/module summary metadata.
info(func_or_name) — Return a dict with full signature/docstring info.
"""
from __future__ import annotations
import importlib
import inspect
from typing import Any
__all__ = ["indicators", "methods", "about", "info"]
# ---------------------------------------------------------------------------
# Category → module mapping used by indicators()
# ---------------------------------------------------------------------------
_CATEGORY_MODULES: dict[str, str] = {
"overlap": "ferro_ta.indicators.overlap",
"momentum": "ferro_ta.indicators.momentum",
"volume": "ferro_ta.indicators.volume",
"volatility": "ferro_ta.indicators.volatility",
"statistic": "ferro_ta.indicators.statistic",
"price_transform": "ferro_ta.indicators.price_transform",
"pattern": "ferro_ta.indicators.pattern",
"cycle": "ferro_ta.indicators.cycle",
"math_ops": "ferro_ta.indicators.math_ops",
"extended": "ferro_ta.indicators.extended",
"batch": "ferro_ta.data.batch",
"streaming": "ferro_ta.data.streaming",
"resampling": "ferro_ta.data.resampling",
"aggregation": "ferro_ta.data.aggregation",
"signals": "ferro_ta.analysis.signals",
"portfolio": "ferro_ta.analysis.portfolio",
"features": "ferro_ta.analysis.features",
"alerts": "ferro_ta.tools.alerts",
"crypto": "ferro_ta.analysis.crypto",
"regime": "ferro_ta.analysis.regime",
}
_METHOD_MODULES: dict[str, str] = {
"top_level": "ferro_ta",
**_CATEGORY_MODULES,
"options": "ferro_ta.analysis.options",
"futures": "ferro_ta.analysis.futures",
"backtest": "ferro_ta.analysis.backtest",
"options_strategy": "ferro_ta.analysis.options_strategy",
"derivatives_payoff": "ferro_ta.analysis.derivatives_payoff",
"attribution": "ferro_ta.analysis.attribution",
"cross_asset": "ferro_ta.analysis.cross_asset",
"tools": "ferro_ta.tools.tools",
"viz": "ferro_ta.tools.viz",
}
def _iter_module_callables(
module_name: str,
) -> list[tuple[str, Any]]:
"""Import *module_name* and return its ``__all__`` callables."""
try:
mod = importlib.import_module(module_name)
except Exception:
return []
names = getattr(mod, "__all__", [])
result = []
for name in names:
obj = getattr(mod, name, None)
if callable(obj):
result.append((name, obj))
return result
def indicators(category: str | None = None) -> list[dict[str, Any]]:
"""Return a list of all ferro_ta indicators with metadata.
Each entry is a dict with the following keys:
- ``"name"`` (str): The indicator name, e.g. ``"SMA"``.
- ``"category"`` (str): The category / sub-module, e.g. ``"overlap"``.
- ``"module"`` (str): The fully qualified module name.
- ``"doc"`` (str): First line of the docstring, or ``""`` if absent.
- ``"params"`` (list[str]): Names of the function's parameters.
Parameters
----------
category : str | None
If given, only return indicators from that category. Must be one of
the keys in :data:`ferro_ta.api_info._CATEGORY_MODULES`.
Returns
-------
list[dict[str, Any]]
Sorted alphabetically by ``"name"``.
Examples
--------
>>> import ferro_ta
>>> all_inds = ferro_ta.indicators()
>>> len(all_inds) > 50
True
>>> overlap_inds = ferro_ta.indicators(category="overlap")
>>> any(d["name"] == "SMA" for d in overlap_inds)
True
"""
cats: dict[str, str] = (
{category: _CATEGORY_MODULES[category]}
if category is not None
else _CATEGORY_MODULES
)
result: list[dict[str, Any]] = []
seen: set[str] = set()
for cat, mod_name in cats.items():
for name, func in _iter_module_callables(mod_name):
if name in seen:
continue
seen.add(name)
doc = inspect.getdoc(func) or ""
first_line = doc.splitlines()[0] if doc else ""
try:
sig = inspect.signature(func)
params = list(sig.parameters.keys())
except (ValueError, TypeError):
params = []
result.append(
{
"name": name,
"category": cat,
"module": mod_name,
"doc": first_line,
"params": params,
}
)
result.sort(key=lambda d: d["name"])
return result
def methods(category: str | None = None) -> list[dict[str, Any]]:
"""Return public callables across ferro_ta modules.
Parameters
----------
category : str | None
Optional key from :data:`_METHOD_MODULES`, such as ``"top_level"``,
``"options"``, ``"futures"``, or ``"batch"``.
"""
cats: dict[str, str] = (
{category: _METHOD_MODULES[category]}
if category is not None
else _METHOD_MODULES
)
result: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for cat, mod_name in cats.items():
for name, func in _iter_module_callables(mod_name):
key = (mod_name, name)
if key in seen:
continue
seen.add(key)
doc = inspect.getdoc(func) or ""
first_line = doc.splitlines()[0] if doc else ""
try:
sig = inspect.signature(func)
params = list(sig.parameters.keys())
except (ValueError, TypeError):
params = []
result.append(
{
"name": name,
"category": cat,
"module": mod_name,
"doc": first_line,
"params": params,
}
)
result.sort(key=lambda d: (d["category"], d["name"]))
return result
def about() -> dict[str, Any]:
"""Return a small metadata summary for the installed ferro_ta package."""
import ferro_ta # noqa: PLC0415
top_level_exports = sorted(getattr(ferro_ta, "__all__", []))
return {
"name": "ferro-ta",
"version": getattr(ferro_ta, "__version__", "0+unknown"),
"top_level_export_count": len(top_level_exports),
"indicator_count": len(indicators()),
"method_count": len(methods()),
"categories": sorted(_METHOD_MODULES.keys()),
"top_level_exports": top_level_exports,
}
def info(func_or_name: Any) -> dict[str, Any]:
"""Return detailed information about an indicator function.
Parameters
----------
func_or_name : callable | str
The indicator function (e.g. ``ferro_ta.SMA``) or its name as a
string (e.g. ``"SMA"``).
Returns
-------
dict[str, Any]
Dictionary with the following keys:
- ``"name"`` (str)
- ``"module"`` (str)
- ``"signature"`` (str): Full ``inspect.signature`` string.
- ``"doc"`` (str): Full docstring.
- ``"params"`` (dict[str, dict]): Mapping of parameter name →
``{"default": ..., "kind": str}`` for each parameter.
Raises
------
ValueError
If *func_or_name* is a string that does not match any indicator.
Examples
--------
>>> import ferro_ta
>>> d = ferro_ta.info(ferro_ta.SMA)
>>> d["name"]
'SMA'
>>> "close" in d["params"]
True
"""
if isinstance(func_or_name, str):
import ferro_ta # noqa: PLC0415
func = getattr(ferro_ta, func_or_name, None)
if func is None:
raise ValueError(
f"No indicator named {func_or_name!r} found in ferro_ta. "
"Use ferro_ta.indicators() to list all available indicators."
)
else:
func = func_or_name
name = getattr(func, "__name__", repr(func))
module = getattr(func, "__module__", "")
doc = inspect.getdoc(func) or ""
try:
sig = inspect.signature(func)
sig_str = str(sig)
params = {}
for pname, param in sig.parameters.items():
kind_map = {
inspect.Parameter.POSITIONAL_ONLY: "positional_only",
inspect.Parameter.POSITIONAL_OR_KEYWORD: "positional_or_keyword",
inspect.Parameter.VAR_POSITIONAL: "var_positional",
inspect.Parameter.KEYWORD_ONLY: "keyword_only",
inspect.Parameter.VAR_KEYWORD: "var_keyword",
}
params[pname] = {
"default": (
param.default
if param.default is not inspect.Parameter.empty
else None
),
"has_default": param.default is not inspect.Parameter.empty,
"kind": kind_map.get(param.kind, "unknown"),
}
except (ValueError, TypeError):
sig_str = "()"
params = {}
return {
"name": name,
"module": module,
"signature": sig_str,
"doc": doc,
"params": params,
}
@@ -0,0 +1,345 @@
"""
ferro_ta.dashboard — Interactive dashboards and exploration helpers.
===================================================================
Optional helpers for interactive exploration in Jupyter notebooks (via
ipywidgets) and a Streamlit template. All widgets are optional: if ipywidgets
or streamlit are not installed, a clear ``ImportError`` is raised with install
instructions.
Functions
---------
indicator_widget(close, indicator_fn, param_name, param_range)
Create an ipywidgets slider that updates an indicator plot in real time.
backtest_widget(close, strategy_fn, param_name, param_range)
Create an ipywidgets slider that re-runs a backtest and shows equity curve.
streamlit_app()
Launch a minimal Streamlit dashboard (call from a ``streamlit run`` script).
Notes
-----
To install optional dependencies::
pip install ferro-ta[dashboard] # installs ipywidgets
pip install streamlit # for Streamlit app
Only the Python layer is in this module — all heavy computation delegated to
existing ferro-ta indicator and backtest functions.
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from typing import Any, Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
__all__ = [
"indicator_widget",
"backtest_widget",
"streamlit_app",
]
# ---------------------------------------------------------------------------
# Jupyter / ipywidgets helpers
# ---------------------------------------------------------------------------
def indicator_widget(
close: ArrayLike,
indicator_fn: Callable[..., Any],
param_name: str,
param_range: Sequence[int],
title: str = "Indicator",
) -> Any:
"""Create an interactive Jupyter widget with a parameter slider.
Renders a ``matplotlib`` chart with the close price overlaid by the
indicator output. Dragging the slider updates the chart in real time.
Parameters
----------
close : array-like — close price series
indicator_fn : callable — indicator function, e.g. ``ferro_ta.SMA``.
Signature: ``fn(close, **{param_name: value}) -> ndarray``.
param_name : str — name of the integer parameter to vary (e.g. ``'timeperiod'``).
param_range : sequence of int — values to iterate over (e.g. ``range(5, 51)``).
title : str — chart title.
Returns
-------
ipywidgets ``Output`` widget — display it in a Jupyter cell.
Requires
--------
``ipywidgets``, ``matplotlib``
Examples
--------
>>> from ferro_ta import SMA
>>> from ferro_ta.tools.dashboard import indicator_widget
>>> w = indicator_widget(close, SMA, 'timeperiod', range(5, 51))
>>> display(w) # in a Jupyter cell
"""
try:
import ipywidgets as widgets
import matplotlib.pyplot as plt
except ImportError as exc:
raise ImportError(
"indicator_widget requires ipywidgets and matplotlib.\n"
"Install with: pip install ipywidgets matplotlib"
) from exc
c = np.asarray(close, dtype=np.float64)
param_values = list(param_range)
out = widgets.Output()
def update(change: Any) -> None:
value = change["new"]
with out:
out.clear_output(wait=True)
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(c, label="Close", alpha=0.5)
ind_out = indicator_fn(c, **{param_name: value})
if isinstance(ind_out, tuple):
for arr in ind_out:
ax.plot(np.asarray(arr, dtype=np.float64), alpha=0.8)
else:
ax.plot(
np.asarray(ind_out, dtype=np.float64),
label=f"{indicator_fn.__name__}({param_name}={value})",
)
ax.set_title(f"{title}{param_name}={value}")
ax.legend()
plt.tight_layout()
plt.show()
slider = widgets.IntSlider(
value=param_values[len(param_values) // 2],
min=min(param_values),
max=max(param_values),
step=1,
description=param_name,
continuous_update=False,
)
slider.observe(update, names="value")
update({"new": slider.value})
return widgets.VBox([slider, out])
def backtest_widget(
close: ArrayLike,
strategy: Union[str, Callable[..., Any]] = "rsi_30_70",
param_name: str = "timeperiod",
param_range: Sequence[int] = range(5, 30),
title: str = "Backtest",
) -> Any:
"""Create an interactive Jupyter widget that re-runs a backtest on slider change.
Parameters
----------
close : array-like — close prices
strategy : str or callable — backtest strategy (see ``ferro_ta.backtest.backtest``).
param_name : str — strategy parameter name to vary.
param_range: sequence of int — parameter values to iterate.
title : str — chart title.
Returns
-------
ipywidgets ``VBox`` widget.
Requires
--------
``ipywidgets``, ``matplotlib``
"""
try:
import ipywidgets as widgets
import matplotlib.pyplot as plt
except ImportError as exc:
raise ImportError(
"backtest_widget requires ipywidgets and matplotlib.\n"
"Install with: pip install ipywidgets matplotlib"
) from exc
from ferro_ta.analysis.backtest import backtest
c = np.asarray(close, dtype=np.float64)
param_values = list(param_range)
out = widgets.Output()
def update(change: Any) -> None:
value = change["new"]
with out:
out.clear_output(wait=True)
result = backtest(c, strategy=strategy, **{param_name: value})
fig, axes = plt.subplots(2, 1, figsize=(12, 6), sharex=True)
axes[0].plot(c, label="Close", alpha=0.7)
axes[0].set_title(f"{title}{param_name}={value}")
axes[0].legend()
axes[1].plot(result.equity, label="Equity", color="green")
axes[1].axhline(1.0, color="gray", linestyle="--", alpha=0.5)
axes[1].set_title(
f"Equity (trades={result.n_trades}, final={result.final_equity:.3f})"
)
axes[1].legend()
plt.tight_layout()
plt.show()
slider = widgets.IntSlider(
value=param_values[len(param_values) // 2],
min=min(param_values),
max=max(param_values),
step=1,
description=param_name,
continuous_update=False,
)
slider.observe(update, names="value")
update({"new": slider.value})
return widgets.VBox([slider, out])
# ---------------------------------------------------------------------------
# Streamlit app template
# ---------------------------------------------------------------------------
def streamlit_app() -> None:
"""Run a minimal Streamlit TA dashboard.
Call this function from a Python script and run with::
streamlit run your_script.py
The dashboard provides:
- A file uploader for OHLCV CSV data (or uses synthetic data as fallback).
- An indicator selector (SMA, EMA, RSI, MACD, Bollinger Bands).
- A parameter slider.
- A price + indicator chart.
- A backtest panel (RSI strategy) with equity curve.
Requires
--------
``streamlit``, ``matplotlib`` or ``plotly`` (optional)
Examples
--------
Create a file ``ta_dashboard.py``::
from ferro_ta.tools.dashboard import streamlit_app
streamlit_app()
Then run::
streamlit run ta_dashboard.py
"""
try:
import streamlit as st
except ImportError as exc:
raise ImportError(
"streamlit_app requires streamlit.\nInstall with: pip install streamlit"
) from exc
import ferro_ta as ft
from ferro_ta.analysis.backtest import backtest
st.title("ferro-ta Interactive Dashboard")
# ---- Data ----
st.sidebar.header("Data")
uploaded = st.sidebar.file_uploader("Upload OHLCV CSV", type=["csv"])
if uploaded is not None:
try:
import pandas as pd
df = pd.read_csv(uploaded)
cols = {c.lower(): c for c in df.columns}
close = df[cols["close"]].values.astype(np.float64)
except (ImportError, KeyError, ValueError) as e:
st.error(f"Could not read CSV: {e}")
close = _synthetic_close()
else:
st.info(
"Using synthetic data. Upload a CSV with a 'close' column to use real data."
)
close = _synthetic_close()
n = len(close)
st.sidebar.write(f"Bars loaded: {n}")
# ---- Indicator ----
st.sidebar.header("Indicator")
indicator_name = st.sidebar.selectbox(
"Indicator", ["SMA", "EMA", "RSI", "MACD", "BBANDS"]
)
timeperiod = st.sidebar.slider("Period", min_value=2, max_value=200, value=20)
st.subheader(f"Price + {indicator_name}({timeperiod})")
try:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(close, label="Close", alpha=0.5)
if indicator_name == "SMA":
ax.plot(np.asarray(ft.SMA(close, timeperiod=timeperiod)), label="SMA")
elif indicator_name == "EMA":
ax.plot(np.asarray(ft.EMA(close, timeperiod=timeperiod)), label="EMA")
elif indicator_name == "RSI":
fig2, ax2 = plt.subplots(figsize=(12, 2))
ax2.plot(
np.asarray(ft.RSI(close, timeperiod=timeperiod)),
label="RSI",
color="orange",
)
ax2.axhline(30, color="green", linestyle="--", alpha=0.5)
ax2.axhline(70, color="red", linestyle="--", alpha=0.5)
ax2.set_title("RSI")
st.pyplot(fig2)
elif indicator_name == "MACD":
macd, signal, hist = ft.MACD(close)
ax.plot(np.asarray(macd), label="MACD")
ax.plot(np.asarray(signal), label="Signal")
elif indicator_name == "BBANDS":
upper, middle, lower = ft.BBANDS(close, timeperiod=timeperiod)
ax.plot(np.asarray(upper), label="Upper", linestyle="--")
ax.plot(np.asarray(middle), label="Middle")
ax.plot(np.asarray(lower), label="Lower", linestyle="--")
ax.legend()
st.pyplot(fig)
except (ImportError, ValueError, RuntimeError) as e:
st.error(f"Error computing indicator: {e}")
# ---- Backtest panel ----
st.subheader("Backtest (RSI 30/70 strategy)")
if st.button("Run Backtest"):
result = backtest(close, strategy="rsi_30_70", timeperiod=timeperiod)
try:
import matplotlib.pyplot as plt
fig3, ax3 = plt.subplots(figsize=(12, 3))
ax3.plot(result.equity, color="green", label="Equity")
ax3.axhline(1.0, color="gray", linestyle="--")
ax3.set_title(
f"Equity trades={result.n_trades} final={result.final_equity:.4f}"
)
ax3.legend()
st.pyplot(fig3)
except ImportError:
st.write(
f"Final equity: {result.final_equity:.4f} trades: {result.n_trades}"
)
def _synthetic_close(n: int = 500) -> NDArray:
"""Generate a synthetic close price series for the dashboard demo."""
rng = np.random.default_rng(42)
return np.cumprod(1 + rng.normal(0, 0.01, n)) * 100.0
+525
View File
@@ -0,0 +1,525 @@
"""
ferro_ta.dsl — Strategy expression DSL.
A small domain-specific language that lets users define rule-based trading
strategies as strings (e.g. ``"RSI(14) < 30 and close > SMA(20)"``) and
evaluate them to produce a boolean or integer signal series.
This module provides:
- :func:`parse_expression` — validate and compile an expression string.
- :func:`evaluate` — evaluate a compiled expression against OHLCV data.
- :class:`Strategy` — convenience wrapper around parse + evaluate.
The expression grammar supports:
- Indicator calls: ``RSI(14)``, ``SMA(20)``, ``BBANDS(20, 2)``
- Price series references: ``close``, ``open``, ``high``, ``low``, ``volume``
- Comparison operators: ``<``, ``>``, ``<=``, ``>=``, ``==``, ``!=``
- Logical connectives: ``and``, ``or``, ``not``
- Cross-above/below helpers: ``cross_above(a, b)``, ``cross_below(a, b)``
- Parentheses for grouping
Evaluating an expression returns a 1-D integer array of 1 (signal on) and 0
(signal off), with leading ``0`` values during indicator warm-up.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.dsl import Strategy
>>> rng = np.random.default_rng(0)
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 100)) * 100
>>> ohlcv = {"close": close}
>>> strat = Strategy("RSI(14) < 30")
>>> signal = strat.evaluate(ohlcv)
>>> signal.shape
(100,)
>>> set(signal.tolist()).issubset({0, 1})
True
"""
from __future__ import annotations
import re
from collections.abc import Callable
from typing import Any, Optional
import numpy as np
from numpy.typing import NDArray
from ferro_ta._utils import _to_f64
from ferro_ta.core.registry import run as _registry_run
__all__ = [
"parse_expression",
"evaluate",
"Strategy",
]
# ---------------------------------------------------------------------------
# Supported indicator / function names (resolved via registry)
# ---------------------------------------------------------------------------
_PRICE_KEYS = {"close", "open", "high", "low", "volume"}
# ---------------------------------------------------------------------------
# Expression AST (minimal)
# ---------------------------------------------------------------------------
class _Expr:
"""Abstract expression node."""
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
raise NotImplementedError
class _PriceRef(_Expr):
def __init__(self, name: str) -> None:
self.name = name
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
if self.name not in ctx:
raise ValueError(f"Price series '{self.name}' not found in OHLCV data.")
return ctx[self.name]
class _IndicatorCall(_Expr):
def __init__(
self,
name: str,
args: list[float],
output_index: int = 0,
) -> None:
self.name = name
self.args = args
self.output_index = output_index
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
close = ctx.get("close")
high = ctx.get("high")
low = ctx.get("low")
volume = ctx.get("volume")
if close is None:
raise ValueError("'close' series is required to evaluate indicator calls.")
kwargs: dict[str, Any] = {}
if self.args:
# Heuristic: first numeric arg → timeperiod
kwargs["timeperiod"] = int(self.args[0])
# Additional args passed as extra kwargs are not supported in this
# simple DSL; only the first param is used as timeperiod.
# Try different signatures
result = None
for positional in [
[close],
[high, low, close] if high is not None and low is not None else None,
[high, low, close, volume]
if volume is not None and high is not None
else None,
]:
if positional is None:
continue
try:
result = _registry_run(self.name, *positional, **kwargs)
break
except Exception:
continue
if result is None:
raise ValueError(
f"Cannot evaluate indicator '{self.name}' with available data."
)
if isinstance(result, tuple):
arr = result[self.output_index]
else:
arr = result
return np.asarray(arr, dtype=np.float64)
class _Comparison(_Expr):
_OPS: dict[str, Callable[[Any, Any], Any]] = {
"<": lambda a, b: a < b,
">": lambda a, b: a > b,
"<=": lambda a, b: a <= b,
">=": lambda a, b: a >= b,
"==": lambda a, b: a == b,
"!=": lambda a, b: a != b,
}
def __init__(self, left: _Expr, op: str, right: _Expr) -> None:
self.left = left
self.op = op
self.right = right
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
lv = self.left.eval(ctx)
rv = self.right.eval(ctx)
fn = self._OPS[self.op]
result = fn(lv, rv)
return result.astype(np.int32)
class _Logic(_Expr):
def __init__(self, op: str, operands: list[_Expr]) -> None:
self.op = op # 'and' | 'or'
self.operands = operands
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
result = self.operands[0].eval(ctx).astype(bool)
for operand in self.operands[1:]:
v = operand.eval(ctx).astype(bool)
if self.op == "and":
result = result & v
else:
result = result | v
return result.astype(np.int32)
class _Not(_Expr):
def __init__(self, operand: _Expr) -> None:
self.operand = operand
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
return (~self.operand.eval(ctx).astype(bool)).astype(np.int32)
class _CrossFunc(_Expr):
def __init__(self, direction: str, a: _Expr, b: _Expr) -> None:
self.direction = direction # 'above' | 'below'
self.a = a
self.b = b
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
av = self.a.eval(ctx).astype(np.float64)
bv = self.b.eval(ctx).astype(np.float64)
n = len(av)
result = np.zeros(n, dtype=np.int32)
if self.direction == "above":
for i in range(1, n):
if av[i] > bv[i] and av[i - 1] <= bv[i - 1]:
result[i] = 1
else:
for i in range(1, n):
if av[i] < bv[i] and av[i - 1] >= bv[i - 1]:
result[i] = 1
return result
class _Scalar(_Expr):
def __init__(self, value: float) -> None:
self.value = value
def eval(self, ctx: dict[str, NDArray[np.float64]]) -> NDArray:
return np.array([self.value])
# ---------------------------------------------------------------------------
# Tokeniser
# ---------------------------------------------------------------------------
_TOKEN_SPEC = [
("NUMBER", r"-?\d+\.?\d*"),
("AND", r"\band\b"),
("OR", r"\bor\b"),
("NOT", r"\bnot\b"),
("IDENT", r"[A-Za-z_][A-Za-z0-9_]*"),
("OP", r"<=|>=|==|!=|<|>"),
("LPAREN", r"\("),
("RPAREN", r"\)"),
("COMMA", r","),
("SKIP", r"\s+"),
]
_TOKEN_RE = re.compile(
"|".join(f"(?P<{name}>{pattern})" for name, pattern in _TOKEN_SPEC)
)
def _tokenise(expr: str) -> list[tuple[str, str]]:
tokens: list[tuple[str, str]] = []
for m in _TOKEN_RE.finditer(expr):
kind = m.lastgroup
value = m.group()
if kind == "SKIP" or kind is None:
continue
tokens.append((kind, value))
# Check for unmatched characters
matched_len = sum(len(m.group()) for m in _TOKEN_RE.finditer(expr))
if matched_len != len(expr.replace(" ", "").replace("\t", "").replace("\n", "")):
# rough check; just skip
pass
return tokens
# ---------------------------------------------------------------------------
# Recursive-descent parser
# ---------------------------------------------------------------------------
class _Parser:
def __init__(self, tokens: list[tuple[str, str]]) -> None:
self.tokens = tokens
self.pos = 0
def peek(self) -> Optional[tuple[str, str]]:
if self.pos < len(self.tokens):
return self.tokens[self.pos]
return None
def consume(self, kind: Optional[str] = None) -> tuple[str, str]:
tok = self.peek()
if tok is None:
raise ValueError("Unexpected end of expression.")
if kind and tok[0] != kind:
raise ValueError(f"Expected {kind}, got {tok[0]!r} ({tok[1]!r}).")
self.pos += 1
return tok
def parse(self) -> _Expr:
expr = self.parse_or()
if self.peek() is not None:
raise ValueError(
f"Unexpected token at position {self.pos}: {self.peek()!r}"
)
return expr
def parse_or(self) -> _Expr:
left = self.parse_and()
operands = [left]
while self.peek() and self.peek()[0] == "OR": # type: ignore[index]
self.consume("OR")
operands.append(self.parse_and())
return operands[0] if len(operands) == 1 else _Logic("or", operands)
def parse_and(self) -> _Expr:
left = self.parse_not()
operands = [left]
while self.peek() and self.peek()[0] == "AND": # type: ignore[index]
self.consume("AND")
operands.append(self.parse_not())
return operands[0] if len(operands) == 1 else _Logic("and", operands)
def parse_not(self) -> _Expr:
if self.peek() and self.peek()[0] == "NOT": # type: ignore[index]
self.consume("NOT")
return _Not(self.parse_not())
return self.parse_comparison()
def parse_comparison(self) -> _Expr:
left = self.parse_atom()
tok = self.peek()
if tok and tok[0] == "OP":
op = tok[1]
self.consume("OP")
right = self.parse_atom()
return _Comparison(left, op, right)
return left
def parse_atom(self) -> _Expr:
tok = self.peek()
if tok is None:
raise ValueError("Unexpected end of expression in atom.")
if tok[0] == "NUMBER":
self.consume("NUMBER")
return _Scalar(float(tok[1]))
if tok[0] == "LPAREN":
self.consume("LPAREN")
expr = self.parse_or()
self.consume("RPAREN")
return expr
if tok[0] == "NOT":
self.consume("NOT")
return _Not(self.parse_comparison())
if tok[0] == "IDENT":
name = tok[1]
self.consume("IDENT")
# Check if followed by '('
if self.peek() and self.peek()[0] == "LPAREN": # type: ignore[index]
self.consume("LPAREN")
# Parse comma-separated args
args: list[float] = []
sub_exprs: list[_Expr] = []
while self.peek() and self.peek()[0] != "RPAREN": # type: ignore[index]
t = self.peek()
if t and t[0] == "NUMBER":
self.consume("NUMBER")
args.append(float(t[1]))
elif t and t[0] == "IDENT":
# nested indicator or price ref used as sub-expression
sub_exprs.append(self.parse_atom())
if self.peek() and self.peek()[0] == "COMMA": # type: ignore[index]
self.consume("COMMA")
self.consume("RPAREN")
name_upper = name.upper()
if name_upper == "CROSS_ABOVE":
if len(sub_exprs) < 2:
raise ValueError("cross_above requires two arguments.")
return _CrossFunc("above", sub_exprs[0], sub_exprs[1])
if name_upper == "CROSS_BELOW":
if len(sub_exprs) < 2:
raise ValueError("cross_below requires two arguments.")
return _CrossFunc("below", sub_exprs[0], sub_exprs[1])
return _IndicatorCall(name_upper, args)
else:
# Price reference or bare indicator name
name_lower = name.lower()
if name_lower in _PRICE_KEYS:
return _PriceRef(name_lower)
# Treat as indicator with no args
return _IndicatorCall(name.upper(), [])
raise ValueError(f"Unexpected token: {tok!r}")
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def parse_expression(expr: str) -> _Expr:
"""Parse and compile an expression string into an AST.
Parameters
----------
expr : str
Strategy expression, e.g. ``"RSI(14) < 30 and close > SMA(20)"``.
Returns
-------
Compiled expression object (internal type).
Raises
------
ValueError
If the expression cannot be parsed.
Examples
--------
>>> from ferro_ta.tools.dsl import parse_expression
>>> ast = parse_expression("RSI(14) < 30")
>>> ast is not None
True
"""
if not isinstance(expr, str) or not expr.strip():
raise ValueError("expr must be a non-empty string.")
tokens = _tokenise(expr.strip())
parser = _Parser(tokens)
return parser.parse()
def evaluate(
expr: Any,
ohlcv: Any,
*,
close_col: str = "close",
high_col: str = "high",
low_col: str = "low",
open_col: str = "open",
volume_col: str = "volume",
) -> NDArray[np.int32]:
"""Evaluate a strategy expression against OHLCV data.
Parameters
----------
expr : str or compiled expression
Either a strategy expression string or the result of
:func:`parse_expression`.
ohlcv : dict of arrays, pandas.DataFrame, or array-like
OHLCV data. At minimum ``close`` is required for indicator-only
expressions.
Returns
-------
numpy.ndarray of dtype int32 (values 0 or 1), same length as input.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.dsl import evaluate
>>> rng = np.random.default_rng(1)
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 60)) * 100
>>> signal = evaluate("RSI(14) < 40", {"close": close})
>>> set(signal.tolist()).issubset({0, 1})
True
"""
if isinstance(expr, str):
ast = parse_expression(expr)
else:
ast = expr
# Build context dict
def _extract(col: str, key: str) -> Optional[NDArray]:
try:
import pandas as pd
if isinstance(ohlcv, pd.DataFrame) and col in ohlcv.columns:
return _to_f64(ohlcv[col].to_numpy())
except ImportError:
pass
if isinstance(ohlcv, dict) and key in ohlcv:
return _to_f64(ohlcv[key])
return None
ctx: dict[str, NDArray[np.float64]] = {}
for col, key in [
(close_col, "close"),
(high_col, "high"),
(low_col, "low"),
(open_col, "open"),
(volume_col, "volume"),
]:
val = _extract(col, key)
if val is not None:
ctx[key] = val
if "close" not in ctx and isinstance(ohlcv, np.ndarray):
ctx["close"] = _to_f64(ohlcv)
result = ast.eval(ctx)
# Broadcast scalar to full length
n = len(ctx.get("close", np.array([])))
if result.shape == (1,) and n > 0:
result = np.broadcast_to(result, (n,)).copy()
# Convert to int32 signal while avoiding warnings when casting NaN/inf.
# For numeric indicator outputs, treat non-finite values as "no signal" (0).
if np.issubdtype(result.dtype, np.floating):
result = np.nan_to_num(result, nan=0.0, posinf=0.0, neginf=0.0)
return result.astype(np.int32)
class Strategy:
"""Convenience class for defining and evaluating a strategy expression.
Parameters
----------
expr : str
Strategy expression string.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.dsl import Strategy
>>> rng = np.random.default_rng(42)
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 100)) * 100
>>> strat = Strategy("RSI(14) < 30")
>>> signal = strat.evaluate({"close": close})
>>> signal.shape
(100,)
"""
def __init__(self, expr: str) -> None:
self.expr_str = expr
self._ast = parse_expression(expr)
def evaluate(self, ohlcv: Any, **kwargs: Any) -> NDArray[np.int32]:
"""Evaluate this strategy on *ohlcv* data."""
return evaluate(self._ast, ohlcv, **kwargs)
def __repr__(self) -> str:
return f"Strategy({self.expr_str!r})"
+224
View File
@@ -0,0 +1,224 @@
"""
ferro_ta.gpu — Optional GPU-accelerated indicator backend via PyTorch.
When the caller passes a PyTorch Tensor as input, the GPU path is used and the
result is returned as a PyTorch Tensor. When a NumPy array (or plain Python
sequence) is passed, the standard CPU path is used — there is **no behaviour
change** for existing CPU-only code.
Install the optional GPU extra to enable this feature:
pip install "ferro-ta[gpu]"
Or install PyTorch manually:
pip install torch
Usage
-----
>>> import torch
>>> from ferro_ta.tools.gpu import sma, ema, rsi
>>>
>>> close_gpu = torch.tensor([44.34, 44.09, 44.15, 43.61, 44.33, 44.83, 45.10], device='cuda') # or 'mps'
>>> result = sma(close_gpu, timeperiod=3)
>>> type(result) # torch.Tensor
>>> result_cpu = result.cpu().numpy()
See ``docs/gpu-backend.md`` for design notes, limitations, and benchmark data.
"""
from __future__ import annotations
from typing import Any, cast
import numpy as np
# ---------------------------------------------------------------------------
# PyTorch detection
# ---------------------------------------------------------------------------
try:
import torch as _torch
_TORCH_AVAILABLE = True
except ImportError:
_torch = None # type: ignore[assignment]
_TORCH_AVAILABLE = False
def _is_torch(arr: object) -> bool:
"""Return True when *arr* is a PyTorch Tensor."""
return (
_TORCH_AVAILABLE is True
and _torch is not None
and isinstance(arr, _torch.Tensor)
)
def _to_cpu(arr: object) -> np.ndarray:
"""Convert a PyTorch Tensor to a NumPy array; pass NumPy arrays through."""
if _is_torch(arr):
return cast(Any, arr).cpu().numpy()
return np.asarray(arr, dtype=np.float64)
def _to_gpu(arr: np.ndarray, device: Any = None) -> Any:
"""Move a NumPy array to the GPU (returns torch.Tensor)."""
assert _torch is not None
return _torch.tensor(arr, device=device)
# ---------------------------------------------------------------------------
# GPU implementations
# ---------------------------------------------------------------------------
def _sma_gpu(close, timeperiod: int):
"""SMA on a PyTorch Tensor using cumsum-based rolling mean."""
if _torch is None:
raise RuntimeError("PyTorch is not installed")
torch = _torch
n = close.shape[0]
result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device)
if timeperiod < 1 or n < timeperiod:
return result
# cumsum-based O(n) rolling sum
cs = torch.cumsum(close, dim=0)
# window sum for index i: cs[i] - cs[i - timeperiod] (i >= timeperiod-1)
win = cs[timeperiod - 1 :]
win = win.clone()
win[1:] -= cs[: len(win) - 1]
result[timeperiod - 1 :] = win / timeperiod
return result
def _ema_gpu(close, timeperiod: int):
"""EMA on a PyTorch Tensor — SMA-seeded, element-wise loop in Python/PyTorch."""
if _torch is None:
raise RuntimeError("PyTorch is not installed")
torch = _torch
n = close.shape[0]
result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device)
if timeperiod < 1 or n < timeperiod:
return result
k = 2.0 / (timeperiod + 1.0)
# Seed with SMA of first window (already on GPU)
seed = float(torch.mean(close[:timeperiod]).item())
result[timeperiod - 1] = seed
# Recurrence on CPU for numerical correctness then move back
close_cpu = close.cpu().numpy()
res_cpu = np.full(n, np.nan)
res_cpu[timeperiod - 1] = seed
prev = seed
for i in range(timeperiod, n):
val = float(close_cpu[i]) * k + prev * (1.0 - k)
res_cpu[i] = val
prev = val
return torch.tensor(res_cpu, dtype=close.dtype, device=close.device)
def _rsi_gpu(close, timeperiod: int):
"""RSI on a PyTorch Tensor — compute diffs on GPU, finish on CPU."""
if _torch is None:
raise RuntimeError("PyTorch is not installed")
torch = _torch
n = close.shape[0]
result = torch.full((n,), float("nan"), dtype=close.dtype, device=close.device)
if timeperiod < 1 or n <= timeperiod:
return result
# Compute price diffs on GPU
diffs = torch.diff(close).cpu().numpy() # (n-1,) numpy array
# CPU recurrence (Wilder smoothing)
res_cpu = np.full(n, np.nan)
avg_gain = np.mean(np.maximum(diffs[:timeperiod], 0.0))
avg_loss = np.mean(np.maximum(-diffs[:timeperiod], 0.0))
rs = avg_gain / avg_loss if avg_loss != 0.0 else np.inf
res_cpu[timeperiod] = 100.0 - 100.0 / (1.0 + rs)
for i in range(timeperiod + 1, n):
d = diffs[i - 1]
gain = d if d > 0.0 else 0.0
loss = -d if d < 0.0 else 0.0
avg_gain = (avg_gain * (timeperiod - 1) + gain) / timeperiod
avg_loss = (avg_loss * (timeperiod - 1) + loss) / timeperiod
rs = avg_gain / avg_loss if avg_loss != 0.0 else np.inf
res_cpu[i] = 100.0 - 100.0 / (1.0 + rs)
return torch.tensor(res_cpu, dtype=close.dtype, device=close.device)
# ---------------------------------------------------------------------------
# Public API — PyTorch in → PyTorch out; NumPy in → NumPy out
# ---------------------------------------------------------------------------
def sma(close, timeperiod: int = 30):
"""Simple Moving Average — GPU-accelerated when *close* is a PyTorch Tensor.
Parameters
----------
close : numpy.ndarray or torch.Tensor
Close price array.
timeperiod : int, default 30
Look-back window.
Returns
-------
numpy.ndarray or torch.Tensor
Same type as *close*. First ``timeperiod - 1`` values are NaN.
"""
if _is_torch(close):
if not close.is_floating_point():
close = close.float()
return _sma_gpu(close, timeperiod)
# CPU fallback
from ferro_ta import SMA # noqa: PLC0415
return SMA(np.asarray(close, dtype=np.float64), timeperiod=timeperiod)
def ema(close, timeperiod: int = 30):
"""Exponential Moving Average — GPU-accelerated when *close* is a PyTorch Tensor.
Parameters
----------
close : numpy.ndarray or torch.Tensor
timeperiod : int, default 30
Returns
-------
numpy.ndarray or torch.Tensor — same type as *close*.
"""
if _is_torch(close):
if not close.is_floating_point():
close = close.float()
return _ema_gpu(close, timeperiod)
from ferro_ta import EMA # noqa: PLC0415
return EMA(np.asarray(close, dtype=np.float64), timeperiod=timeperiod)
def rsi(close, timeperiod: int = 14):
"""Relative Strength Index — GPU-accelerated when *close* is a PyTorch Tensor.
Parameters
----------
close : numpy.ndarray or torch.Tensor
timeperiod : int, default 14
Returns
-------
numpy.ndarray or torch.Tensor — same type as *close*. Values in [0, 100].
"""
if _is_torch(close):
if not close.is_floating_point():
close = close.float()
return _rsi_gpu(close, timeperiod)
from ferro_ta import RSI # noqa: PLC0415
return RSI(np.asarray(close, dtype=np.float64), timeperiod=timeperiod)
__all__ = [
"sma",
"ema",
"rsi",
]
@@ -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",
]
@@ -0,0 +1,284 @@
"""
ferro_ta.tools — Stable Tool Wrappers for Agent / LLM Integration
=================================================================
Provides stable, well-documented functions that are easy to wrap as
LangChain/LlamaIndex/OpenAI Function tools or to call from automated agents.
All functions have clear signatures, descriptive docstrings, and return
JSON-serializable types so that agent frameworks can inspect and call them
without special handling.
See ``docs/agentic.md`` for the full agentic workflow guide, LangChain
integration examples, and scheduling instructions.
Quick start
-----------
>>> import numpy as np
>>> from ferro_ta.tools import compute_indicator, run_backtest, list_indicators
>>>
>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100
>>>
>>> # Compute a single indicator by name
>>> result = compute_indicator("SMA", close, timeperiod=14)
>>>
>>> # Run a backtest
>>> summary = run_backtest("rsi_30_70", close)
>>> print(summary["final_equity"])
API
---
compute_indicator(name, *args, **kwargs) → array or dict
Compute a built-in or registered indicator by name.
run_backtest(strategy, close, **kwargs) → dict
Run a backtest and return a summary dict.
list_indicators() → list[str]
list all registered indicator names.
describe_indicator(name) → str
Return the docstring of a registered indicator (or a summary).
"""
from __future__ import annotations
from typing import Any, Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
__all__ = [
"compute_indicator",
"run_backtest",
"list_indicators",
"describe_indicator",
]
def compute_indicator(
name: str,
*args: ArrayLike,
**kwargs: Any,
) -> Union[NDArray[np.float64], dict[str, NDArray[np.float64]]]:
"""Compute a named indicator and return the result.
Delegates to the ferro_ta registry so that both built-in and custom
indicators can be called by name.
Parameters
----------
name : str
Indicator name (e.g. ``"SMA"``, ``"RSI"``, ``"BBANDS"``).
Case-sensitive; use :func:`list_indicators` to see all names.
*args : array-like
Positional data arrays forwarded to the indicator (e.g. close, high).
**kwargs
Parameter keyword arguments forwarded to the indicator
(e.g. ``timeperiod=14``).
Returns
-------
ndarray or dict of str → ndarray
For single-output indicators, returns a 1-D ``numpy.ndarray``.
For multi-output indicators (e.g. BBANDS, MACD), returns a dict
mapping output names to arrays. The dict keys follow TA-Lib
conventions where known (``"upper"``/``"middle"``/``"lower"`` for
BBANDS; ``"macd"``/``"signal"``/``"hist"`` for MACD; etc.).
Raises
------
ferro_ta.registry.FerroTARegistryError
If *name* is not a known indicator.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools import compute_indicator
>>> close = np.linspace(100, 110, 20)
>>> result = compute_indicator("SMA", close, timeperiod=5)
>>> result.shape
(20,)
>>> bb = compute_indicator("BBANDS", close, timeperiod=5)
>>> sorted(bb.keys())
['lower', 'middle', 'upper']
"""
from ferro_ta.core.registry import run as _registry_run
raw = _registry_run(name, *args, **kwargs)
if isinstance(raw, tuple):
# Multi-output: try to map to named keys for well-known indicators
_multi_keys: dict[str, list[str]] = {
"BBANDS": ["upper", "middle", "lower"],
"MACD": ["macd", "signal", "hist"],
"MACDEXT": ["macd", "signal", "hist"],
"MACDFIX": ["macd", "signal", "hist"],
"STOCH": ["slowk", "slowd"],
"STOCHF": ["fastk", "fastd"],
"STOCHRSI": ["fastk", "fastd"],
"AROON": ["aroondown", "aroonup"],
"HT_PHASOR": ["inphase", "quadrature"],
"HT_SINE": ["sine", "leadsine"],
"MAMA": ["mama", "fama"],
}
keys = _multi_keys.get(name.upper())
if keys and len(keys) == len(raw):
return {k: np.asarray(v, dtype=np.float64) for k, v in zip(keys, raw)}
# Fallback: use integer keys
return {str(i): np.asarray(v, dtype=np.float64) for i, v in enumerate(raw)}
return np.asarray(raw, dtype=np.float64)
def run_backtest(
strategy: str,
close: ArrayLike,
commission_per_trade: float = 0.0,
slippage_bps: float = 0.0,
**strategy_kwargs: Any,
) -> dict[str, Any]:
"""Run a named backtest strategy and return a summary dictionary.
This is a convenience wrapper around :func:`ferro_ta.backtest.backtest`
that returns a JSON-serializable summary dict rather than a
``BacktestResult`` object, making it easy to use from agent tools.
Parameters
----------
strategy : str
Name of the built-in strategy: ``"rsi_30_70"``, ``"sma_crossover"``,
or ``"macd_crossover"``.
close : array-like
Close prices (1-D, at least 2 bars).
commission_per_trade : float
Fixed commission deducted from equity on each position change.
slippage_bps : float
Slippage in basis points applied on position-change bars.
**strategy_kwargs
Extra kwargs forwarded to the strategy function
(e.g. ``timeperiod=14``, ``oversold=25``).
Returns
-------
dict
Summary with the following keys:
* ``"strategy"`` — the strategy name used.
* ``"n_bars"`` — number of price bars.
* ``"n_trades"`` — number of position changes.
* ``"final_equity"`` — terminal equity value (start = 1.0).
* ``"max_drawdown"`` — maximum drawdown fraction (01, positive value
represents the magnitude of loss).
* ``"equity"`` — equity curve as a Python list of floats.
* ``"signals"`` — signal array as a Python list.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools import run_backtest
>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100
>>> summary = run_backtest("rsi_30_70", close)
>>> isinstance(summary["final_equity"], float)
True
"""
from ferro_ta.analysis.backtest import backtest as _backtest
result = _backtest(
close,
strategy=strategy,
commission_per_trade=commission_per_trade,
slippage_bps=slippage_bps,
**strategy_kwargs,
)
equity = np.asarray(result.equity, dtype=np.float64)
# Compute max drawdown
running_max = np.maximum.accumulate(equity)
drawdowns = (running_max - equity) / np.where(running_max > 0, running_max, 1.0)
max_dd = float(np.nanmax(drawdowns)) if len(drawdowns) > 0 else 0.0
return {
"strategy": strategy,
"n_bars": len(result.signals),
"n_trades": result.n_trades,
"final_equity": result.final_equity,
"max_drawdown": max_dd,
"equity": equity.tolist(),
"signals": np.asarray(result.signals, dtype=np.float64).tolist(),
}
def list_indicators() -> list[str]:
"""Return a sorted list of all registered indicator names.
Includes both built-in ferro_ta indicators and any custom indicators
registered via :func:`ferro_ta.registry.register`.
Returns
-------
list of str
Sorted list of indicator names (e.g. ``["AD", "ADOSC", "ADX", …]``).
Examples
--------
>>> from ferro_ta.tools import list_indicators
>>> names = list_indicators()
>>> "SMA" in names
True
>>> "RSI" in names
True
"""
from ferro_ta.core.registry import list_indicators as _list
return _list()
def describe_indicator(name: str) -> str:
"""Return a human-readable description of a registered indicator.
Looks up the indicator's docstring and returns the first paragraph (up to
the first blank line) so it can be used in agent prompts or tool
descriptions.
Parameters
----------
name : str
Indicator name (case-sensitive). Use :func:`list_indicators` to get
valid names.
Returns
-------
str
The first paragraph of the indicator's docstring, or a fallback
message if no docstring is available.
Raises
------
ferro_ta.registry.FerroTARegistryError
If *name* is not a known indicator.
Examples
--------
>>> from ferro_ta.tools import describe_indicator
>>> desc = describe_indicator("SMA")
>>> isinstance(desc, str) and len(desc) > 0
True
"""
from ferro_ta.core.registry import get as _get
func = _get(name)
doc = getattr(func, "__doc__", None) or ""
if not doc.strip():
return f"{name}: no description available."
# Return only the first paragraph (before the first blank line)
lines = doc.strip().splitlines()
para: list[str] = []
for line in lines:
stripped = line.strip()
if stripped == "" and para:
break
para.append(stripped)
return " ".join(para).strip() or f"{name}: no description available."
+351
View File
@@ -0,0 +1,351 @@
"""
ferro_ta.viz — Charting and visualisation API.
Generates charts (matplotlib and/or Plotly) with indicators overlaid on price.
API
---
plot(ohlcv, indicators=None, *, backend='matplotlib', title=None,
figsize=None, savefig=None, show=False)
Generate a chart from OHLCV data and optional indicator series.
Returns a figure object for further customisation.
Backends
--------
- ``'matplotlib'`` — requires ``matplotlib`` (recommended for static charts)
- ``'plotly'`` — requires ``plotly`` (recommended for interactive charts)
Install optional backends::
pip install ferro-ta[plot] # adds matplotlib + plotly
pip install matplotlib # matplotlib only
pip install plotly # plotly only
Examples
--------
>>> import numpy as np
>>> from ferro_ta import RSI, SMA
>>> from ferro_ta.tools.viz import plot
>>> rng = np.random.default_rng(0)
>>> n = 60
>>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100
>>> ohlcv = {"close": close, "open": close, "high": close * 1.01,
... "low": close * 0.99, "volume": np.ones(n) * 1000}
>>> fig = plot(ohlcv, indicators={"RSI(14)": RSI(close, timeperiod=14),
... "SMA(20)": SMA(close, timeperiod=20)},
... backend='matplotlib', show=False)
>>> fig is not None
True
"""
from __future__ import annotations
import warnings
from typing import Any, Optional
import numpy as np
from numpy.typing import ArrayLike, NDArray
__all__ = [
"plot",
]
# ---------------------------------------------------------------------------
# plot
# ---------------------------------------------------------------------------
def plot(
ohlcv: Any,
indicators: Optional[dict[str, ArrayLike]] = None,
*,
backend: str = "matplotlib",
title: Optional[str] = None,
figsize: Optional[tuple[float, float]] = None,
savefig: Optional[str] = None,
show: bool = True,
volume: bool = True,
close_col: str = "close",
volume_col: str = "volume",
) -> Any:
"""Generate a chart from OHLCV data and optional indicator series.
Parameters
----------
ohlcv : dict, pandas.DataFrame, or array-like
OHLCV data. At minimum a ``close`` key/column is required.
indicators : dict {label: array}, optional
Additional indicator series to plot below the price panel.
Each entry is plotted in its own subplot.
backend : str
``'matplotlib'`` (default) or ``'plotly'``.
title : str, optional
Chart title.
figsize : (width, height), optional
Figure size in inches (matplotlib) or pixels (plotly).
savefig : str, optional
Save figure to this file path (e.g. ``'chart.png'``, ``'chart.html'``).
show : bool
If ``True``, call ``plt.show()`` or ``fig.show()`` interactively.
volume : bool
If ``True`` and a volume series is present, add a volume subplot.
close_col, volume_col : str
Column names when *ohlcv* is a DataFrame.
Returns
-------
matplotlib.figure.Figure or plotly.graph_objects.Figure
Raises
------
ImportError
If the requested backend is not installed.
"""
close_arr, volume_arr = _extract_close_volume(ohlcv, close_col, volume_col)
if backend == "matplotlib":
return _plot_matplotlib(
close_arr,
volume_arr if volume else None,
indicators,
title=title,
figsize=figsize,
savefig=savefig,
show=show,
)
elif backend == "plotly":
return _plot_plotly(
close_arr,
volume_arr if volume else None,
indicators,
title=title,
figsize=figsize,
savefig=savefig,
show=show,
)
else:
raise ValueError(
f"Unknown backend {backend!r}. Supported: 'matplotlib', 'plotly'."
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _extract_close_volume(
ohlcv: Any,
close_col: str,
volume_col: str,
) -> tuple[NDArray[np.float64], Optional[NDArray[np.float64]]]:
"""Extract close and (optional) volume from various input formats."""
try:
import pandas as pd
if isinstance(ohlcv, pd.DataFrame):
close = ohlcv[close_col].values.astype(np.float64)
volume = (
ohlcv[volume_col].values.astype(np.float64)
if volume_col in ohlcv.columns
else None
)
return close, volume
except ImportError:
pass
if isinstance(ohlcv, dict):
close = np.asarray(
ohlcv.get(close_col, ohlcv.get("close", [])), dtype=np.float64
)
vol_key = volume_col if volume_col in ohlcv else "volume"
volume = (
np.asarray(ohlcv[vol_key], dtype=np.float64) if vol_key in ohlcv else None
)
return close, volume
# Plain array
return np.asarray(ohlcv, dtype=np.float64), None
def _n_subplots(indicators: Optional[dict], volume_arr: Optional[NDArray]) -> int:
n = 1 # price
if volume_arr is not None:
n += 1
if indicators:
n += len(indicators)
return n
# ---------------------------------------------------------------------------
# Matplotlib backend
# ---------------------------------------------------------------------------
def _plot_matplotlib(
close: NDArray,
volume: Optional[NDArray],
indicators: Optional[dict[str, ArrayLike]],
*,
title: Optional[str],
figsize: Optional[tuple],
savefig: Optional[str],
show: bool,
) -> Any:
try:
import matplotlib.gridspec as gridspec
import matplotlib.pyplot as plt
except ImportError as exc:
raise ImportError(
"matplotlib is required for the 'matplotlib' backend. "
"Install with: pip install matplotlib"
) from exc
n_subplots = _n_subplots(indicators, volume)
height_ratios = [3] + [1] * (n_subplots - 1)
fig_h = figsize[1] if figsize else 2.5 * n_subplots + 1
fig_w = figsize[0] if figsize else 12.0
fig = plt.figure(figsize=(fig_w, fig_h))
gs = gridspec.GridSpec(n_subplots, 1, height_ratios=height_ratios, hspace=0.35)
ax_price = fig.add_subplot(gs[0])
ax_price.plot(close, color="#1f77b4", linewidth=1.2, label="close")
ax_price.set_ylabel("Price")
ax_price.legend(loc="upper left", fontsize=8)
ax_price.grid(alpha=0.3)
if title:
ax_price.set_title(title)
row = 1
if volume is not None:
ax_vol = fig.add_subplot(gs[row], sharex=ax_price)
ax_vol.bar(range(len(volume)), volume, color="#aec7e8", alpha=0.7, width=0.8)
ax_vol.set_ylabel("Volume")
ax_vol.grid(alpha=0.3)
row += 1
if indicators:
colors = ["#d62728", "#2ca02c", "#9467bd", "#8c564b", "#e377c2", "#17becf"]
for idx, (label, arr) in enumerate(indicators.items()):
ax_ind = fig.add_subplot(gs[row], sharex=ax_price)
color = colors[idx % len(colors)]
arr_np = np.asarray(arr, dtype=np.float64)
ax_ind.plot(arr_np, color=color, linewidth=1.0, label=label)
ax_ind.set_ylabel(label, fontsize=8)
ax_ind.legend(loc="upper left", fontsize=8)
ax_ind.grid(alpha=0.3)
row += 1
# Use tight_layout when possible but suppress known benign UserWarning
# about incompatible Axes configurations.
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message="This figure includes Axes that are not compatible with tight_layout.*",
category=UserWarning,
)
plt.tight_layout()
if savefig:
fig.savefig(savefig, dpi=100, bbox_inches="tight")
if show:
plt.show()
return fig
# ---------------------------------------------------------------------------
# Plotly backend
# ---------------------------------------------------------------------------
def _plot_plotly(
close: NDArray,
volume: Optional[NDArray],
indicators: Optional[dict[str, ArrayLike]],
*,
title: Optional[str],
figsize: Optional[tuple],
savefig: Optional[str],
show: bool,
) -> Any:
try:
import plotly.graph_objects as go
from plotly.subplots import make_subplots
except ImportError as exc:
raise ImportError(
"plotly is required for the 'plotly' backend. "
"Install with: pip install plotly"
) from exc
n_subplots = _n_subplots(indicators, volume)
row_heights = [0.5] + [0.1] * (n_subplots - 1)
total = sum(row_heights)
row_heights = [r / total for r in row_heights]
shared_xaxes = True
subplot_titles = ["Price"]
if volume is not None:
subplot_titles.append("Volume")
if indicators:
subplot_titles.extend(list(indicators.keys()))
fig = make_subplots(
rows=n_subplots,
cols=1,
shared_xaxes=shared_xaxes,
row_heights=row_heights,
subplot_titles=subplot_titles,
vertical_spacing=0.05,
)
x = list(range(len(close)))
fig.add_trace(
go.Scatter(
x=x, y=close.tolist(), mode="lines", name="close", line={"color": "#1f77b4"}
),
row=1,
col=1,
)
row = 2
if volume is not None:
fig.add_trace(
go.Bar(x=x, y=volume.tolist(), name="volume", marker_color="#aec7e8"),
row=row,
col=1,
)
row += 1
if indicators:
colors = ["#d62728", "#2ca02c", "#9467bd", "#8c564b", "#e377c2", "#17becf"]
for idx, (label, arr) in enumerate(indicators.items()):
arr_np = np.asarray(arr, dtype=np.float64)
color = colors[idx % len(colors)]
fig.add_trace(
go.Scatter(
x=x,
y=arr_np.tolist(),
mode="lines",
name=label,
line={"color": color},
),
row=row,
col=1,
)
row += 1
fig_w = figsize[0] if figsize else 900
fig_h = figsize[1] if figsize else 500
fig.update_layout(
title=title or "ferro_ta Chart",
width=fig_w,
height=fig_h,
showlegend=True,
)
if savefig:
if savefig.endswith(".html"):
fig.write_html(savefig)
else:
fig.write_image(savefig)
if show:
fig.show()
return fig
@@ -0,0 +1,333 @@
"""
ferro_ta.workflow — End-to-End Workflow Orchestration
=====================================================
Provides a lightweight DAG/linear workflow that chains data acquisition,
resampling, indicator computation, strategy signal generation, and alerting
in a single call. All heavy computation is delegated to existing ferro_ta
modules; this module is **pure orchestration** with no new algorithmic logic.
See ``docs/agentic.md`` for a full end-to-end example including LangChain
integration and scheduling.
Quick start
-----------
>>> import numpy as np
>>> from ferro_ta.tools.workflow import Workflow
>>>
>>> # Build a workflow
>>> wf = (
... Workflow()
... .add_indicator("sma_20", "SMA", timeperiod=20)
... .add_indicator("rsi_14", "RSI", timeperiod=14)
... .add_strategy("rsi_30_70")
... )
>>>
>>> close = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 100)) * 100
>>> result = wf.run(close)
>>> print(result.keys())
API
---
Workflow
Fluent builder that chains: indicators → strategy → backtest → alerts.
run_pipeline(close, indicators, strategy, alert_level)
Functional interface: single call that returns all outputs.
"""
from __future__ import annotations
from typing import Any, Optional
import numpy as np
from numpy.typing import ArrayLike
__all__ = [
"Workflow",
"run_pipeline",
]
class Workflow:
"""Fluent builder for an end-to-end ferro_ta workflow.
A :class:`Workflow` chains these optional steps in order:
1. **Indicators** — compute one or more named indicators on close prices.
2. **Strategy** — optionally run a backtest strategy and capture the result.
3. **Alerts** — optionally define threshold or cross alerts on any indicator
output and collect firing bars.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.workflow import Workflow
>>> 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)
... .run(close)
... )
>>> "sma_20" in result
True
>>> "rsi_14" in result
True
"""
def __init__(self) -> None:
self._indicator_steps: list[tuple[str, str, dict[str, Any]]] = []
self._strategy: Optional[str] = None
self._strategy_kwargs: dict[str, Any] = {}
self._alert_steps: list[tuple[str, str, float, int]] = []
# ------------------------------------------------------------------
# Fluent builders
# ------------------------------------------------------------------
def add_indicator(
self,
output_key: str,
indicator_name: str,
**kwargs: Any,
) -> Workflow:
"""Add an indicator step.
Parameters
----------
output_key : str
Key under which the result will be stored in the output dict.
indicator_name : str
Name of the indicator (e.g. ``"SMA"``, ``"RSI"``).
**kwargs
Parameters forwarded to the indicator (e.g. ``timeperiod=14``).
Returns
-------
Workflow
Self, for chaining.
"""
self._indicator_steps.append((output_key, indicator_name, kwargs))
return self
def add_strategy(
self,
strategy: str,
**strategy_kwargs: Any,
) -> Workflow:
"""Set the backtest strategy to run.
Only one strategy can be active at a time; calling this method again
replaces the previous strategy.
Parameters
----------
strategy : str
Strategy name (``"rsi_30_70"``, ``"sma_crossover"``, or
``"macd_crossover"``).
**strategy_kwargs
Extra parameters forwarded to the strategy function.
Returns
-------
Workflow
Self, for chaining.
"""
self._strategy = strategy
self._strategy_kwargs = dict(strategy_kwargs)
return self
def add_alert(
self,
indicator_key: str,
level: float,
direction: int = 1,
) -> Workflow:
"""Add a threshold crossing alert on an indicator output.
The alert fires on bars where the specified indicator crosses *level*
in *direction*.
Parameters
----------
indicator_key : str
Key of an indicator already added via :meth:`add_indicator`.
level : float
Alert level (e.g. 30 for RSI oversold).
direction : int
``+1`` → alert when series crosses *above* level.
``-1`` → alert when series crosses *below* level.
Returns
-------
Workflow
Self, for chaining.
"""
alert_key = f"alert_{indicator_key}_{level:.4g}_{direction:+d}"
self._alert_steps.append((alert_key, indicator_key, level, direction))
return self
# ------------------------------------------------------------------
# Execution
# ------------------------------------------------------------------
def run(
self,
close: ArrayLike,
commission_per_trade: float = 0.0,
slippage_bps: float = 0.0,
) -> dict[str, Any]:
"""Execute the workflow and return all outputs.
Parameters
----------
close : array-like
Close price series (1-D).
commission_per_trade : float
Commission forwarded to backtest (if strategy is set).
slippage_bps : float
Slippage in bps forwarded to backtest (if strategy is set).
Returns
-------
dict
Dictionary containing:
* Each indicator key → ``numpy.ndarray`` result (or dict for
multi-output indicators such as BBANDS/MACD).
* ``"backtest"`` → summary dict (only if a strategy was added).
* Each alert key → list of bar indices where alert fired
(only if alerts were added).
"""
from ferro_ta.tools import compute_indicator, run_backtest
close_arr = np.asarray(close, dtype=np.float64)
output: dict[str, Any] = {}
# Step 1: compute indicators
for output_key, indicator_name, kwargs in self._indicator_steps:
output[output_key] = compute_indicator(indicator_name, close_arr, **kwargs)
# Step 2: run backtest strategy (if set)
if self._strategy is not None:
output["backtest"] = run_backtest(
self._strategy,
close_arr,
commission_per_trade=commission_per_trade,
slippage_bps=slippage_bps,
**self._strategy_kwargs,
)
# Step 3: compute alerts
if self._alert_steps:
from ferro_ta.tools.alerts import check_threshold, collect_alert_bars
for alert_key, ind_key, level, direction in self._alert_steps:
series = output.get(ind_key)
if series is None:
continue
# For multi-output indicators, skip alert silently
if isinstance(series, dict):
continue
arr = np.asarray(series, dtype=np.float64)
mask = check_threshold(arr, level=level, direction=direction)
output[alert_key] = collect_alert_bars(mask).tolist()
return output
# ---------------------------------------------------------------------------
# Functional interface
# ---------------------------------------------------------------------------
def run_pipeline(
close: ArrayLike,
indicators: Optional[dict[str, dict[str, Any]]] = None,
strategy: Optional[str] = None,
strategy_kwargs: Optional[dict[str, Any]] = None,
alert_level: Optional[float] = None,
alert_indicator: Optional[str] = None,
alert_direction: int = -1,
commission_per_trade: float = 0.0,
slippage_bps: float = 0.0,
) -> dict[str, Any]:
"""Run a full ferro_ta pipeline in one call.
Functional wrapper around :class:`Workflow` for scripting and agent use.
Parameters
----------
close : array-like
Close price series.
indicators : dict of {str: dict}, optional
Mapping of ``output_key → kwargs_dict`` for indicators to compute.
The indicator name must be embedded as ``"name"`` in the kwargs dict.
Example::
indicators = {
"sma_20": {"name": "SMA", "timeperiod": 20},
"rsi_14": {"name": "RSI", "timeperiod": 14},
}
strategy : str, optional
Built-in strategy name (``"rsi_30_70"`` etc.).
strategy_kwargs : dict, optional
Extra kwargs for the strategy.
alert_level : float, optional
If set, add a threshold alert on *alert_indicator* at this level.
alert_indicator : str, optional
Key of the indicator to alert on (must be in *indicators*).
alert_direction : int
Direction of the alert: ``+1`` cross-above, ``-1`` cross-below.
commission_per_trade : float
Backtest commission.
slippage_bps : float
Backtest slippage in bps.
Returns
-------
dict
Same structure as :meth:`Workflow.run`.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.tools.workflow import run_pipeline
>>> rng = np.random.default_rng(0)
>>> close = np.cumprod(1 + rng.normal(0, 0.01, 200)) * 100
>>> result = run_pipeline(
... close,
... indicators={
... "sma_20": {"name": "SMA", "timeperiod": 20},
... "rsi_14": {"name": "RSI", "timeperiod": 14},
... },
... strategy="rsi_30_70",
... )
>>> "sma_20" in result
True
>>> "backtest" in result
True
"""
wf = Workflow()
if indicators:
for key, params in indicators.items():
params = dict(params)
ind_name = params.pop("name")
wf.add_indicator(key, ind_name, **params)
if strategy:
wf.add_strategy(strategy, **(strategy_kwargs or {}))
if alert_level is not None and alert_indicator is not None:
wf.add_alert(alert_indicator, level=alert_level, direction=alert_direction)
return wf.run(
close,
commission_per_trade=commission_per_trade,
slippage_bps=slippage_bps,
)