扩展指标
This commit is contained in:
@@ -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
|
||||
@@ -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})"
|
||||
@@ -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 (0–1, 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."
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user