436954138f
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.
239 lines
8.0 KiB
Python
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})"
|