""" Shared utility helpers for ferro_ta Python wrappers. """ from __future__ import annotations import functools from typing import Any, Optional import numpy as np from numpy.typing import ArrayLike # Default OHLCV column names for DataFrame contract DEFAULT_OHLCV_COLUMNS = { "open": "open", "high": "high", "low": "low", "close": "close", "volume": "volume", } @functools.lru_cache(maxsize=1) def _optional_pandas_module(): """Import pandas lazily once and cache absence for low-overhead hot paths.""" try: import pandas as pd except ImportError: return None return pd @functools.lru_cache(maxsize=1) def _optional_polars_module(): """Import polars lazily once and cache absence for low-overhead hot paths.""" try: import polars as pl except ImportError: return None return pl def _to_f64(data: ArrayLike) -> np.ndarray: """Convert any array-like to a contiguous 1-D float64 NumPy array. Transparently accepts ``pandas.Series`` and ``polars.Series`` — the values are extracted and the index/metadata is discarded (use :func:`pandas_wrap` or :func:`polars_wrap` to preserve it). Fast path: if *data* is already a 1-D C-contiguous ``float64`` NumPy array it is returned as-is without any copy or allocation. """ # Fast path: already a 1-D contiguous float64 numpy array — no copy needed. if ( isinstance(data, np.ndarray) and data.dtype == np.float64 and data.ndim == 1 and data.flags["C_CONTIGUOUS"] ): return data # Accept pandas Series/DataFrame without requiring pandas at import time if hasattr(data, "to_numpy"): try: data = data.to_numpy(dtype=np.float64) # type: ignore[union-attr] except TypeError: # Some libraries (e.g. polars) have to_numpy() but don't accept dtype data = np.asarray(data.to_numpy(), dtype=np.float64) # type: ignore[union-attr] # Accept polars Series via to_numpy() (available since polars 0.13) elif hasattr(data, "to_list") and type(data).__name__ == "Series": # polars Series doesn't have to_numpy with dtype kwarg; use cast+to_numpy try: data = data.cast(float).to_numpy() # type: ignore[union-attr] except Exception: data = np.array(data.to_list(), dtype=np.float64) # type: ignore[union-attr] arr = np.ascontiguousarray(data, dtype=np.float64) if arr.ndim != 1: from ferro_ta.core.exceptions import FerroTAInputError raise FerroTAInputError( f"Input must be a 1-D array or list of prices, got {arr.ndim}-D array.", suggestion="Flatten your array with .ravel() or pass a 1-D Series/list.", ) return arr def get_ohlcv( df: Any, open_col: str = "open", high_col: str = "high", low_col: str = "low", close_col: str = "close", volume_col: Optional[str] = "volume", ) -> tuple[Any, Any, Any, Any, Any]: """Extract OHLCV arrays or Series from a DataFrame with configurable column names. Use this when you have a single DataFrame with OHLCV columns (possibly with different names) and want to call indicators that expect separate arrays. Index is preserved when the input is a pandas DataFrame. Parameters ---------- df : pandas.DataFrame DataFrame with at least columns for open, high, low, close (and optionally volume). open_col, high_col, low_col, close_col, volume_col : str Column names to use. Defaults are ``'open'``, ``'high'``, ``'low'``, ``'close'``, ``'volume'``. Returns ------- tuple of (open, high, low, close, volume) Each element is a 1-D array or pandas Series (same type as DataFrame columns) with the same index as ``df``. Missing columns raise KeyError. Examples -------- >>> import pandas as pd >>> from ferro_ta import ATR, RSI >>> from ferro_ta._utils import get_ohlcv >>> df = pd.DataFrame({ ... 'Open': [1, 2, 3], 'High': [1.1, 2.1, 3.1], ... 'Low': [0.9, 1.9, 2.9], 'Close': [1.05, 2.05, 3.05] ... }) >>> o, h, l, c, v = get_ohlcv(df, open_col='Open', high_col='High', ... low_col='Low', close_col='Close', volume_col=None) >>> atr = ATR(h, l, c, timeperiod=2) # index preserved if pandas """ try: import pandas as pd except ImportError: raise ImportError("get_ohlcv requires pandas. Install with: pip install pandas") if not isinstance(df, pd.DataFrame): raise TypeError("get_ohlcv expects a pandas.DataFrame") def _get(name: Optional[str]) -> Any: if name is None: return np.full(len(df), np.nan) if name not in df.columns: raise KeyError( f"Column '{name}' not found in DataFrame. Columns: {list(df.columns)}" ) return df[name] vol_col = volume_col if (volume_col and volume_col in df.columns) else None return ( _get(open_col), _get(high_col), _get(low_col), _get(close_col), _get(vol_col) if vol_col else np.full(len(df), np.nan), ) def pandas_wrap(func): """Decorator — transparent ``pandas.Series`` / ``DataFrame`` support. When at least one positional argument is a ``pandas.Series`` or ``pandas.DataFrame`` column, the wrapper: 1. Extracts the NumPy arrays from all pandas inputs. 2. Captures the index from the *first* pandas input. 3. Calls the original function with plain NumPy arrays. 4. Wraps every ``numpy.ndarray`` in the result back into a ``pandas.Series`` (or tuple of Series) with the captured index. If ``pandas`` is not installed the decorator is a no-op pass-through so the NumPy API is unaffected. Parameters that are already NumPy arrays (or lists) are passed through unchanged. Scalar keyword arguments (e.g. ``timeperiod``) are always passed through unchanged. Examples -------- >>> import pandas as pd, numpy as np >>> from ferro_ta import SMA >>> s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0]) >>> result = SMA(s, timeperiod=3) >>> isinstance(result, pd.Series) True >>> list(result.index) == list(s.index) True """ @functools.wraps(func) def wrapper(*args, **kwargs): pd = _optional_pandas_module() if pd is None: return func(*args, **kwargs) pd_index = None new_args: list[Any] = [] for arg in args: if isinstance(arg, pd.Series): if pd_index is None: pd_index = arg.index new_args.append(arg.to_numpy(dtype=np.float64)) elif isinstance(arg, pd.DataFrame): if pd_index is None: pd_index = arg.index # Pass each column as a 1-D array (single-column DataFrames) if arg.shape[1] == 1: new_args.append(arg.iloc[:, 0].to_numpy(dtype=np.float64)) else: new_args.append(arg) else: new_args.append(arg) result = func(*new_args, **kwargs) if pd_index is not None: if isinstance(result, tuple): return tuple( pd.Series(r, index=pd_index) if isinstance(r, np.ndarray) else r for r in result ) elif isinstance(result, np.ndarray): return pd.Series(result, index=pd_index) return result # Mark so callers can detect wrapped functions wrapper._pandas_wrapped = True # type: ignore[attr-defined] return wrapper def polars_wrap(func): """Decorator — transparent ``polars.Series`` support. When at least one positional argument is a ``polars.Series``, the wrapper: 1. Converts all polars Series inputs to NumPy arrays. 2. Captures the name from the *first* polars input (used as the result series name). 3. Calls the original function with plain NumPy arrays. 4. Wraps every ``numpy.ndarray`` in the result back into a ``polars.Series`` with the same name. If ``polars`` is not installed the decorator is a no-op pass-through so the NumPy API is unaffected. Parameters that are already NumPy arrays (or lists) are passed through unchanged. Scalar keyword arguments (e.g. ``timeperiod``) are always passed through unchanged. Examples -------- >>> import polars as pl >>> from ferro_ta import SMA >>> s = pl.Series("close", [1.0, 2.0, 3.0, 4.0, 5.0]) >>> result = SMA(s, timeperiod=3) >>> isinstance(result, pl.Series) True """ @functools.wraps(func) def wrapper(*args, **kwargs): pl = _optional_polars_module() if pl is None: return func(*args, **kwargs) pl_name: Optional[str] = None new_args: list[Any] = [] for arg in args: if isinstance(arg, pl.Series): if pl_name is None: pl_name = arg.name try: new_args.append(arg.cast(pl.Float64).to_numpy()) except Exception: new_args.append(np.array(arg.to_list(), dtype=np.float64)) else: new_args.append(arg) result = func(*new_args, **kwargs) if pl_name is not None: if isinstance(result, tuple): return tuple( pl.Series(pl_name, r) if isinstance(r, np.ndarray) else r for r in result ) elif isinstance(result, np.ndarray): return pl.Series(pl_name, result) return result wrapper._polars_wrapped = True # type: ignore[attr-defined] return wrapper