Files
2026-03-23 23:34:28 +05:30

279 lines
9.0 KiB
Python

"""
ferro_ta.resampling — OHLCV resampling and multi-timeframe API.
Provides functions to resample OHLCV data into coarser time bars or volume
bars, and a multi-timeframe helper that runs an indicator on two or more
resampled timeframes in one call.
The heavy OHLCV aggregation logic lives in the Rust backend
(``_ferro_ta.volume_bars`` and ``_ferro_ta.ohlcv_agg``); this module provides
the Python-facing API with:
- Time-based resampling via pandas (requires ``pandas``).
- Volume-bar resampling via Rust (no extra dependencies).
- Multi-timeframe helper that returns a dict of DataFrames.
Functions
---------
resample(ohlcv, rule, *, label='right', closed='right')
Resample a pandas OHLCV DataFrame by a time rule (e.g. ``'5min'``,
``'1h'``). Requires pandas.
volume_bars(ohlcv, volume_threshold)
Aggregate OHLCV data into volume bars using the Rust backend.
Accepts a pandas DataFrame or separate numpy arrays.
multi_timeframe(ohlcv, rules, *, indicator=None, indicator_kwargs=None)
Resample OHLCV to multiple timeframes and optionally run an indicator
on each. Returns a dict mapping each rule to a DataFrame (or to an
indicator result when *indicator* is given).
Rust backend
------------
All bar-accumulation logic delegates to::
ferro_ta._ferro_ta.volume_bars
ferro_ta._ferro_ta.ohlcv_agg
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, Optional
from ferro_ta._ferro_ta import volume_bars as _rust_volume_bars
from ferro_ta._utils import _to_f64
__all__ = [
"resample",
"volume_bars",
"multi_timeframe",
]
# ---------------------------------------------------------------------------
# resample — time-based resampling (pandas required)
# ---------------------------------------------------------------------------
def resample(
ohlcv: Any,
rule: str,
*,
label: str = "right",
closed: str = "right",
) -> Any:
"""Resample an OHLCV DataFrame to a coarser time rule.
Uses ``pandas.DataFrame.resample`` under the hood; the index must be a
``DatetimeIndex`` (timezone-aware or naive).
Parameters
----------
ohlcv : pandas.DataFrame
Must have columns ``open``, ``high``, ``low``, ``close``, ``volume``
(case-sensitive; use the column-name helpers in :mod:`ferro_ta._utils`
if your column names differ). Index must be a ``DatetimeIndex``.
rule : str
Pandas offset alias (e.g. ``'5min'``, ``'1h'``, ``'1D'``).
label : str
Which bin edge to label the bucket with (``'left'`` or ``'right'``).
Default ``'right'``.
closed : str
Which side of the interval is closed (``'left'`` or ``'right'``).
Default ``'right'``.
Returns
-------
pandas.DataFrame
Resampled OHLCV DataFrame with the same column names.
Raises
------
ImportError
If pandas is not installed.
ValueError
If required columns are missing or the index is not a DatetimeIndex.
Examples
--------
>>> import pandas as pd, numpy as np
>>> from ferro_ta.data.resampling import resample
>>> idx = pd.date_range("2024-01-01", periods=60, freq="1min")
>>> df = pd.DataFrame({
... "open": np.random.rand(60) + 100,
... "high": np.random.rand(60) + 101,
... "low": np.random.rand(60) + 99,
... "close": np.random.rand(60) + 100,
... "volume": np.random.randint(100, 1000, 60).astype(float),
... }, index=idx)
>>> df5 = resample(df, "5min")
>>> df5.shape[0]
12
"""
try:
import pandas as pd
except ImportError as exc:
raise ImportError(
"pandas is required for time-based resampling. "
"Install it with: pip install pandas"
) from exc
required = {"open", "high", "low", "close", "volume"}
missing = required - set(ohlcv.columns)
if missing:
raise ValueError(f"OHLCV DataFrame missing columns: {missing}")
if not isinstance(ohlcv.index, pd.DatetimeIndex):
raise ValueError(
"ohlcv.index must be a pandas DatetimeIndex for time-based resampling."
)
agg = {
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}
return ohlcv.resample(rule, label=label, closed=closed).agg(agg).dropna(how="all")
# ---------------------------------------------------------------------------
# volume_bars — volume-based resampling (Rust backend)
# ---------------------------------------------------------------------------
def volume_bars(
ohlcv: Any,
volume_threshold: float,
*,
open_col: str = "open",
high_col: str = "high",
low_col: str = "low",
close_col: str = "close",
volume_col: str = "volume",
) -> Any:
"""Aggregate OHLCV data into volume bars using the Rust backend.
Each output bar accumulates input bars until ``volume_threshold`` units of
volume have been consumed.
Parameters
----------
ohlcv : pandas.DataFrame or tuple of arrays
Either a pandas DataFrame with OHLCV columns, or a tuple
``(open, high, low, close, volume)`` of array-like objects.
volume_threshold : float
Target volume per output bar (must be > 0).
open_col, high_col, low_col, close_col, volume_col : str
Column names when ``ohlcv`` is a DataFrame.
Returns
-------
pandas.DataFrame or tuple of numpy arrays
If a DataFrame was passed in, returns a DataFrame with the same column
names. Otherwise returns a tuple
``(open, high, low, close, volume)`` of numpy arrays.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.data.resampling import volume_bars
>>> n = 100
>>> o = np.random.rand(n) + 100
>>> h = o + np.random.rand(n)
>>> l = o - np.random.rand(n)
>>> c = np.random.rand(n) + 100
>>> v = np.random.randint(50, 150, n).astype(float)
>>> bars = volume_bars((o, h, l, c, v), volume_threshold=500)
>>> len(bars[0]) > 0
True
"""
if isinstance(ohlcv, tuple):
o, h, low, c, v = (_to_f64(x) for x in ohlcv)
return _rust_volume_bars(o, h, low, c, v, float(volume_threshold))
# pandas DataFrame path
try:
import pandas as pd
except ImportError as exc:
raise ImportError("pandas is required when passing a DataFrame") from exc
o = _to_f64(ohlcv[open_col].values)
h = _to_f64(ohlcv[high_col].values)
low = _to_f64(ohlcv[low_col].values)
c = _to_f64(ohlcv[close_col].values)
v = _to_f64(ohlcv[volume_col].values)
ro, rh, rl, rc, rv = _rust_volume_bars(o, h, low, c, v, float(volume_threshold))
return pd.DataFrame(
{
open_col: ro,
high_col: rh,
low_col: rl,
close_col: rc,
volume_col: rv,
}
)
# ---------------------------------------------------------------------------
# multi_timeframe — run indicator on multiple resampled timeframes
# ---------------------------------------------------------------------------
def multi_timeframe(
ohlcv: Any,
rules: list[str],
*,
indicator: Optional[Callable[..., Any]] = None,
indicator_kwargs: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Resample OHLCV to multiple timeframes and optionally run an indicator.
Parameters
----------
ohlcv : pandas.DataFrame
OHLCV data with a ``DatetimeIndex``.
rules : list of str
Pandas offset aliases, e.g. ``['5min', '1h']``.
indicator : callable, optional
A function ``indicator(close, **kwargs) -> array`` (or multi-output).
When provided it is called on the resampled ``close`` column for each
rule, and the result is stored in the returned dict instead of the
full DataFrame.
indicator_kwargs : dict, optional
Keyword arguments forwarded to *indicator*.
Returns
-------
dict
Mapping from each rule string to:
- a resampled pandas DataFrame when *indicator* is ``None``, or
- the indicator output (numpy array or tuple) when *indicator* is given.
Examples
--------
>>> import pandas as pd, numpy as np
>>> from ferro_ta import RSI
>>> from ferro_ta.data.resampling import multi_timeframe
>>> idx = pd.date_range("2024-01-01", periods=200, freq="1min")
>>> close = np.cumprod(1 + np.random.randn(200) * 0.001) * 100
>>> df = pd.DataFrame({
... "open": close, "high": close * 1.001, "low": close * 0.999,
... "close": close, "volume": np.ones(200) * 1000,
... }, index=idx)
>>> result = multi_timeframe(df, ["5min", "15min"], indicator=RSI,
... indicator_kwargs={"timeperiod": 14})
>>> sorted(result.keys())
['15min', '5min']
"""
kw = indicator_kwargs or {}
out: dict[str, Any] = {}
for rule in rules:
df_r = resample(ohlcv, rule)
if indicator is not None:
out[rule] = indicator(_to_f64(df_r["close"].values), **kw)
else:
out[rule] = df_r
return out