Files
Pratik Bhadane 436954138f chore: prepare v1.1.0 release
Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
2026-03-30 12:45:52 +05:30

292 lines
9.7 KiB
Python

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