Files
ferro-ta/python/ferro_ta/data/aggregation.py
T
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

239 lines
8.0 KiB
Python

"""
ferro_ta.aggregation — Tick and trade aggregation pipeline.
Aggregates raw tick or trade data (streams of (timestamp, price, size)) into
OHLCV bars using three bar types:
- **time bars** — fixed time intervals (e.g. every 1 minute)
- **volume bars** — fixed volume threshold per bar
- **tick bars** — fixed number of ticks per bar
The compute-intensive bar accumulation is implemented in Rust; this module
provides the Python-facing API with DataFrame support.
Functions
---------
aggregate_ticks(ticks, rule)
Aggregate a tick stream to OHLCV bars. The *rule* string specifies the
bar type and parameter:
- ``'time:<seconds>'`` — e.g. ``'time:60'`` for 1-minute bars
- ``'volume:<threshold>'`` — e.g. ``'volume:1000'`` for 1000-unit volume bars
- ``'tick:<n>'`` — e.g. ``'tick:100'`` for 100-tick bars
Rust backend
------------
All accumulation logic delegates to::
ferro_ta._ferro_ta.aggregate_tick_bars
ferro_ta._ferro_ta.aggregate_volume_bars_ticks
ferro_ta._ferro_ta.aggregate_time_bars
"""
from __future__ import annotations
from typing import Any, Optional
import numpy as np
from numpy.typing import NDArray
from ferro_ta._ferro_ta import aggregate_tick_bars as _rust_tick_bars
from ferro_ta._ferro_ta import aggregate_time_bars as _rust_time_bars
from ferro_ta._ferro_ta import aggregate_volume_bars_ticks as _rust_volume_bars_ticks
from ferro_ta._utils import _to_f64
from ferro_ta.core.exceptions import FerroTAValueError
__all__ = [
"aggregate_ticks",
"TickAggregator",
]
# ---------------------------------------------------------------------------
# _parse_rule
# ---------------------------------------------------------------------------
def _parse_rule(rule: str) -> tuple[str, float]:
"""Parse a rule string into (bar_type, parameter).
Supported formats::
'time:60' → ('time', 60.0)
'volume:1000' → ('volume', 1000.0)
'tick:100' → ('tick', 100.0)
"""
parts = rule.split(":", 1)
if len(parts) != 2:
raise FerroTAValueError(
f"Invalid rule format: {rule!r}. "
"Expected 'time:<seconds>', 'volume:<threshold>', or 'tick:<n>'."
)
bar_type = parts[0].lower().strip()
if bar_type not in ("time", "volume", "tick"):
raise FerroTAValueError(
f"Unknown bar type {bar_type!r}. Supported types: 'time', 'volume', 'tick'."
)
try:
param = float(parts[1].strip())
except ValueError as exc:
raise FerroTAValueError(
f"Cannot parse parameter {parts[1]!r} as a number in rule {rule!r}."
) from exc
if param <= 0:
raise FerroTAValueError(
f"Rule parameter must be > 0, got {param} in rule {rule!r}."
)
return bar_type, param
# ---------------------------------------------------------------------------
# aggregate_ticks
# ---------------------------------------------------------------------------
def aggregate_ticks(
ticks: Any,
rule: str = "tick:100",
*,
timestamp_col: str = "timestamp",
price_col: str = "price",
size_col: str = "size",
) -> Any:
"""Aggregate tick/trade data into OHLCV bars.
Parameters
----------
ticks : pandas.DataFrame, list of (timestamp, price, size), or dict of arrays
Tick data. Accepted formats:
1. **pandas DataFrame** with columns ``timestamp``, ``price``, ``size``
(column names configurable via *_col* parameters). The timestamp
column must contain numeric Unix timestamps (seconds) for time bars.
2. **list of tuples** ``[(ts, price, size), …]``.
3. **dict** ``{'timestamp': array, 'price': array, 'size': array}``.
rule : str
Bar specification:
- ``'tick:<n>'`` — every N ticks become one bar (default ``'tick:100'``)
- ``'volume:<threshold>'`` — every N units of volume become one bar
- ``'time:<seconds>'`` — every N seconds become one bar
timestamp_col, price_col, size_col : str
Column names when *ticks* is a DataFrame.
Returns
-------
pandas.DataFrame or tuple of numpy arrays
If a DataFrame was passed in (or pandas is available), returns a
DataFrame with columns ``open``, ``high``, ``low``, ``close``,
``volume``, and (for time bars) ``timestamp``.
Otherwise returns a tuple ``(open, high, low, close, volume)``.
Examples
--------
>>> import numpy as np
>>> from ferro_ta.data.aggregation import aggregate_ticks
>>> rng = np.random.default_rng(42)
>>> n = 500
>>> price = 100 + np.cumsum(rng.normal(0, 0.1, n))
>>> size = rng.uniform(10, 100, n)
>>> bars = aggregate_ticks({"price": price, "size": size}, rule="tick:50")
>>> len(bars["open"]) == n // 50 + (1 if n % 50 != 0 else 0)
True
"""
bar_type, param = _parse_rule(rule)
# --- Normalise input ---
ts_arr: Optional[NDArray[np.float64]] = None
if isinstance(ticks, list):
# list of (ts, price, size) tuples
arr = np.ascontiguousarray(ticks, dtype=np.float64)
ts_arr = np.ascontiguousarray(arr[:, 0])
price_arr = np.ascontiguousarray(arr[:, 1])
size_arr = np.ascontiguousarray(arr[:, 2])
elif isinstance(ticks, dict):
price_arr = _to_f64(ticks[price_col])
size_arr = _to_f64(ticks[size_col])
if timestamp_col in ticks:
ts_arr = _to_f64(ticks[timestamp_col])
else:
# pandas DataFrame
try:
import pandas as pd
except ImportError as exc:
raise ImportError("pandas is required for DataFrame input") from exc
price_arr = _to_f64(ticks[price_col].values)
size_arr = _to_f64(ticks[size_col].values)
if timestamp_col in ticks.columns:
ts_arr = _to_f64(ticks[timestamp_col].values)
# --- Aggregate ---
if bar_type == "tick":
ro, rh, rl, rc, rv = _rust_tick_bars(price_arr, size_arr, int(param))
extra: Optional[NDArray] = None
elif bar_type == "volume":
ro, rh, rl, rc, rv = _rust_volume_bars_ticks(price_arr, size_arr, param)
extra = None
else: # time
if ts_arr is None:
raise FerroTAValueError(
"Time bars require a timestamp column in the tick data."
)
period_secs = int(param)
labels = (ts_arr // period_secs).astype(np.int64)
ro, rh, rl, rc, rv, lbl = _rust_time_bars(price_arr, size_arr, labels)
extra = lbl
# --- Return ---
try:
import pandas as pd
df: dict[str, Any] = {
"open": ro,
"high": rh,
"low": rl,
"close": rc,
"volume": rv,
}
if extra is not None:
df["timestamp"] = (extra * int(param)).astype(np.int64)
return pd.DataFrame(df)
except ImportError:
return {"open": ro, "high": rh, "low": rl, "close": rc, "volume": rv}
# ---------------------------------------------------------------------------
# TickAggregator — class-based API
# ---------------------------------------------------------------------------
class TickAggregator:
"""Class-based API for tick aggregation.
Parameters
----------
rule : str
Bar specification (see :func:`aggregate_ticks`).
Examples
--------
>>> from ferro_ta.data.aggregation import TickAggregator
>>> agg = TickAggregator(rule="tick:50")
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> ticks = {"price": rng.uniform(99, 101, 200), "size": rng.uniform(1, 10, 200)}
>>> bars = agg.aggregate(ticks)
>>> len(bars["open"]) >= 4
True
"""
def __init__(self, rule: str = "tick:100") -> None:
self.rule = rule
# Validate rule at construction time
_parse_rule(rule)
def aggregate(self, ticks: Any, **kwargs: Any) -> Any:
"""Aggregate *ticks* into bars. See :func:`aggregate_ticks`."""
return aggregate_ticks(ticks, rule=self.rule, **kwargs)
def __repr__(self) -> str:
return f"TickAggregator(rule={self.rule!r})"