2026-03-23 23:34:28 +05:30
|
|
|
"""
|
|
|
|
|
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",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 00:55:56 +05:30
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-03-23 23:34:28 +05:30
|
|
|
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:
|
|
|
|
|
raise ValueError("Input must be a 1-D array or list of prices.")
|
|
|
|
|
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):
|
2026-03-24 00:55:56 +05:30
|
|
|
pd = _optional_pandas_module()
|
|
|
|
|
if pd is None:
|
2026-03-23 23:34:28 +05:30
|
|
|
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):
|
2026-03-24 00:55:56 +05:30
|
|
|
pl = _optional_polars_module()
|
|
|
|
|
if pl is None:
|
2026-03-23 23:34:28 +05:30
|
|
|
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
|