Files
2026-03-24 02:02:10 +05:30

223 lines
7.0 KiB
Python

"""
ferro_ta.signals — Signal composition and screening.
Provides helpers to combine multiple indicator outputs into a composite score
and to screen/rank symbols by that score.
Functions
---------
compose(signals, weights=None, method='weighted')
Combine a DataFrame (or 2-D array) of signals into one composite score
per bar. Methods: ``'weighted'`` (weighted sum), ``'rank'`` (rank-based),
``'mean'`` (equal-weight mean).
screen(scores, top_n=None, bottom_n=None, above=None, below=None)
Filter/rank a dict or Series of per-symbol scores.
rank_signals(x)
Compute the fractional rank of each element in *x* (wrapper around Rust).
Rust backend
------------
ferro_ta._ferro_ta.compose_weighted
ferro_ta._ferro_ta.rank_series
ferro_ta._ferro_ta.top_n_indices
ferro_ta._ferro_ta.bottom_n_indices
"""
from __future__ import annotations
from typing import Any, Optional, Union
import numpy as np
from numpy.typing import ArrayLike, NDArray
from ferro_ta._ferro_ta import bottom_n_indices as _rust_bottom_n
from ferro_ta._ferro_ta import compose_rank as _rust_compose_rank
from ferro_ta._ferro_ta import compose_weighted as _rust_compose_weighted
from ferro_ta._ferro_ta import rank_series as _rust_rank_series
from ferro_ta._ferro_ta import top_n_indices as _rust_top_n
from ferro_ta._utils import _to_f64
__all__ = [
"compose",
"screen",
"rank_signals",
]
# ---------------------------------------------------------------------------
# rank_signals
# ---------------------------------------------------------------------------
def rank_signals(x: ArrayLike) -> NDArray[np.float64]:
"""Compute the fractional rank of each element (1-based, ascending).
Ties receive the average of their rank positions.
Parameters
----------
x : array-like — 1-D
Returns
-------
numpy.ndarray of ranks in [1, n]
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.signals import rank_signals
>>> rank_signals(np.array([3.0, 1.0, 2.0]))
array([3., 1., 2.])
"""
return _rust_rank_series(_to_f64(x))
# ---------------------------------------------------------------------------
# compose
# ---------------------------------------------------------------------------
def compose(
signals: Any,
weights: Optional[ArrayLike] = None,
method: str = "weighted",
) -> NDArray[np.float64]:
"""Combine multiple signal columns into one composite score per bar.
Parameters
----------
signals : pandas.DataFrame or 2-D array-like, shape (n_bars, n_signals)
Each column is one indicator/signal.
weights : array-like of length n_signals, optional
Weights for each signal column. Required for ``method='weighted'``.
If ``None`` and method is ``'weighted'``, equal weights are used.
method : str
Composition method:
- ``'weighted'`` (default) — weighted sum (Rust fast path)
- ``'mean'`` — equal-weight mean (equivalent to weighted with 1/n)
- ``'rank'`` — sum of per-signal ranks (rank-based scoring)
Returns
-------
numpy.ndarray of length n_bars
Examples
--------
>>> import numpy as np
>>> from ferro_ta.analysis.signals import compose
>>> rng = np.random.default_rng(0)
>>> sigs = rng.standard_normal((50, 3))
>>> score = compose(sigs, weights=[0.5, 0.3, 0.2])
>>> score.shape
(50,)
"""
try:
import pandas as pd
if isinstance(signals, pd.DataFrame):
arr = signals.values.astype(np.float64, copy=False)
else:
arr = np.asarray(signals, dtype=np.float64)
except ImportError:
arr = np.asarray(signals, dtype=np.float64)
if arr.ndim == 1:
arr = arr.reshape(-1, 1)
n_bars, n_sigs = arr.shape
arr = np.ascontiguousarray(arr)
if method == "mean":
w = np.full(n_sigs, 1.0 / n_sigs)
return _rust_compose_weighted(arr, w)
elif method == "rank":
return _rust_compose_rank(arr)
else:
# weighted (default)
if weights is None:
w = np.full(n_sigs, 1.0 / n_sigs)
else:
w = np.ascontiguousarray(np.asarray(weights, dtype=np.float64))
return _rust_compose_weighted(arr, w)
# ---------------------------------------------------------------------------
# screen
# ---------------------------------------------------------------------------
def screen(
scores: Union[dict[str, float], Any],
top_n: Optional[int] = None,
bottom_n: Optional[int] = None,
above: Optional[float] = None,
below: Optional[float] = None,
) -> Any:
"""Filter and rank symbols by composite score.
Parameters
----------
scores : dict {symbol: score} or pandas.Series or array-like
Per-symbol scores.
top_n : int, optional
Return the top-N symbols by score.
bottom_n : int, optional
Return the bottom-N symbols by score.
above : float, optional
Return all symbols with score > *above*.
below : float, optional
Return all symbols with score < *below*.
Returns
-------
dict {symbol: score} sorted by score (descending for top_n, ascending for
bottom_n), or a pandas.DataFrame if pandas is available and input is a
Series/DataFrame.
Examples
--------
>>> from ferro_ta.analysis.signals import screen
>>> scores = {"AAPL": 0.8, "GOOG": 0.5, "MSFT": 0.9, "AMZN": 0.3}
>>> result = screen(scores, top_n=2)
>>> list(result.keys())
['MSFT', 'AAPL']
"""
# Normalise to dict
try:
import pandas as pd
if isinstance(scores, pd.Series):
symbols = scores.index.tolist() # type: ignore[union-attr]
values = scores.values.astype(np.float64) # type: ignore[union-attr]
elif isinstance(scores, dict):
symbols = list(scores.keys())
values = np.array(list(scores.values()), dtype=np.float64)
else:
symbols = list(range(len(scores)))
values = np.array(list(scores), dtype=np.float64)
except ImportError:
if isinstance(scores, dict):
symbols = list(scores.keys())
values = np.array(list(scores.values()), dtype=np.float64)
else:
symbols = list(range(len(scores)))
values = np.array(list(scores), dtype=np.float64)
if top_n is not None:
idxs = _rust_top_n(values, int(top_n))
# Sort by score descending
idxs = sorted(idxs, key=lambda i: -values[i])
return {symbols[i]: float(values[i]) for i in idxs}
if bottom_n is not None:
idxs = _rust_bottom_n(values, int(bottom_n))
idxs = sorted(idxs, key=lambda i: values[i])
return {symbols[i]: float(values[i]) for i in idxs}
if above is not None:
return {s: float(v) for s, v in zip(symbols, values) if v > above}
if below is not None:
return {s: float(v) for s, v in zip(symbols, values) if v < below}
# Default: return all sorted descending
order = sorted(range(len(values)), key=lambda i: -values[i])
return {symbols[i]: float(values[i]) for i in order}