""" 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:'`` — e.g. ``'time:60'`` for 1-minute bars - ``'volume:'`` — e.g. ``'volume:1000'`` for 1000-unit volume bars - ``'tick:'`` — 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:', 'volume:', or 'tick:'." ) 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:'`` — every N ticks become one bar (default ``'tick:100'``) - ``'volume:'`` — every N units of volume become one bar - ``'time:'`` — 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})"