""" ferro_ta.features — Feature matrix and ML readiness. Exports a feature matrix (indicators as columns, bars as rows) suitable for sklearn or other ML pipelines. Functions --------- feature_matrix(ohlcv, indicators, *, nan_policy='keep', close_col='close', ...) Compute all requested indicators on the OHLCV data and return a single DataFrame with bars as rows and indicator names as columns. Rust backend ------------ Individual indicator calls delegate to existing Rust-backed ferro_ta functions via the registry. """ from __future__ import annotations from typing import Any, Optional, Union import numpy as np from numpy.typing import NDArray from ferro_ta._utils import _to_f64 from ferro_ta.data.batch import compute_many __all__ = [ "feature_matrix", ] def _forward_fill_nan(arr: NDArray[np.float64]) -> NDArray[np.float64]: mask = np.isnan(arr) if not mask.any(): return arr last_valid = np.where(~mask, np.arange(len(arr)), 0) np.maximum.accumulate(last_valid, out=last_valid) return arr[last_valid] # --------------------------------------------------------------------------- # feature_matrix # --------------------------------------------------------------------------- def feature_matrix( ohlcv: Any, indicators: list[Union[str, tuple[str, dict[str, Any]]]], *, nan_policy: str = "keep", close_col: str = "close", high_col: str = "high", low_col: str = "low", open_col: str = "open", volume_col: str = "volume", ) -> Any: """Compute multiple indicators on OHLCV data and return a feature matrix. Parameters ---------- ohlcv : pandas.DataFrame or dict of arrays OHLCV data. Must contain at least a ``close`` column/key. indicators : list of (str | tuple) Each element is either: - A string indicator name (e.g. ``'RSI'``), using default params. - A ``(name, kwargs)`` tuple, e.g. ``('RSI', {'timeperiod': 14})``. - A ``(name, kwargs, output_key)`` 3-tuple to name a specific output of a multi-output indicator (0-indexed int or output key). The column name in the output matrix is ```` for single-output indicators or ``_`` for multi-output ones. nan_policy : str How to handle NaN values (warmup rows): - ``'keep'`` (default) — keep NaN rows as-is. - ``'drop'`` — drop any row that contains at least one NaN. - ``'fill'`` — forward-fill NaN values. close_col, high_col, low_col, open_col, volume_col : str Column names when *ohlcv* is a DataFrame. Returns ------- pandas.DataFrame or dict of numpy arrays If pandas is available, returns a DataFrame with one column per indicator. Otherwise returns a dict {name: array}. Examples -------- >>> import numpy as np >>> from ferro_ta.analysis.features import feature_matrix >>> rng = np.random.default_rng(0) >>> n = 50 >>> close = np.cumprod(1 + rng.normal(0, 0.01, n)) * 100 >>> ohlcv = {"close": close, "high": close * 1.01, "low": close * 0.99, ... "open": close, "volume": np.ones(n) * 1000} >>> fm = feature_matrix(ohlcv, [("SMA", {"timeperiod": 10}), ... ("RSI", {"timeperiod": 14})]) >>> list(fm.keys()) ['SMA', 'RSI'] """ # --- Extract arrays --- def _get(col: str) -> Optional[NDArray[np.float64]]: try: import pandas as pd if isinstance(ohlcv, pd.DataFrame): return _to_f64(ohlcv[col].to_numpy()) if col in ohlcv.columns else None except ImportError: pass if isinstance(ohlcv, dict): return _to_f64(ohlcv[col]) if col in ohlcv else None return None close = _get(close_col) high = _get(high_col) low = _get(low_col) _open = _get(open_col) # noqa: F841 - reserved for future OHLCV indicators volume = _get(volume_col) if close is None: raise ValueError(f"close column '{close_col}' not found in ohlcv") n = len(close) columns: dict[str, NDArray[np.float64]] = {} results = compute_many( indicators, close=close, high=high if high is not None else None, low=low if low is not None else None, volume=volume if volume is not None else None, ) for spec, result in zip(indicators, results): if isinstance(spec, str): name = spec out_key: Optional[Any] = None elif len(spec) == 2: name, _ = spec # type: ignore[misc] out_key = None else: name, _, out_key = spec # type: ignore[misc] if isinstance(result, tuple): if out_key is not None: if isinstance(out_key, int): col_name = f"{name}_{out_key}" columns[col_name] = np.asarray(result[out_key], dtype=np.float64) else: col_name = f"{name}_{out_key}" columns[col_name] = np.asarray( result[int(out_key)], dtype=np.float64 ) else: for ki, arr in enumerate(result): columns[f"{name}_{ki}"] = np.asarray(arr, dtype=np.float64) else: columns[name] = np.asarray(result, dtype=np.float64) # --- NaN policy --- try: import pandas as pd index = None if isinstance(ohlcv, pd.DataFrame): index = ohlcv.index df = pd.DataFrame(columns, index=index) if nan_policy == "drop": df = df.dropna() elif nan_policy == "fill": df = df.ffill() return df except ImportError: if nan_policy == "drop": mask = np.ones(n, dtype=bool) for arr in columns.values(): mask &= ~np.isnan(arr) return {k: v[mask] for k, v in columns.items()} elif nan_policy == "fill": for key, arr in columns.items(): columns[key] = _forward_fill_nan(arr) return columns