Files
2026-03-23 23:34:28 +05:30

334 lines
10 KiB
Python

"""
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,
)