Files
ferro-ta/python/ferro_ta/tools/alerts.py
T
2026-03-23 23:34:28 +05:30

433 lines
14 KiB
Python

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