From 520ff6bfa607d8b06d539e28d3043708e6ceefc3 Mon Sep 17 00:00:00 2001 From: Mikus Date: Sun, 25 Oct 2026 18:28:31 +0100 Subject: [PATCH] Keep is_buyer_maker aligned with its trade when TradesData sorts and de-duplicates --- finmlkit/bar/data_model.py | 9 ++++++ tests/bars/test_trades_data_side_alignment.py | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 tests/bars/test_trades_data_side_alignment.py diff --git a/finmlkit/bar/data_model.py b/finmlkit/bar/data_model.py index e91437d..6da7570 100644 --- a/finmlkit/bar/data_model.py +++ b/finmlkit/bar/data_model.py @@ -162,6 +162,11 @@ def __init__(self, self._data = pd.DataFrame({'timestamp': ts, 'price': px, 'amount': qty, 'id': id}) self.is_buyer_maker = is_buyer_maker + if is_buyer_maker is not None and preprocess: + if len(is_buyer_maker) != len(self._data): + raise ValueError("is_buyer_maker must have the same length as the trades") + # Carried as a column so that sorting and de-duplicating re-order it together with its trade + self._data['is_buyer_maker'] = is_buyer_maker if side is not None: self._data['side'] = side self._orig_timestamp_unit = timestamp_unit if timestamp_unit else self._infer_timestamp_unit() @@ -329,6 +334,10 @@ def _merge_trades(self): """ logger.info('Merging split trades (same timestamps) on same price level...') + if 'is_buyer_maker' in self._data.columns: + # re-ordered together with the other columns by _sort_trades + self.is_buyer_maker = self._data['is_buyer_maker'].to_numpy(dtype=np.bool_) + ts, px, am, side = merge_split_trades( self.data['timestamp'].values.astype(np.int64), self.data['price'].values.astype(np.float64), diff --git a/tests/bars/test_trades_data_side_alignment.py b/tests/bars/test_trades_data_side_alignment.py new file mode 100644 index 0000000..cd415fa --- /dev/null +++ b/tests/bars/test_trades_data_side_alignment.py @@ -0,0 +1,29 @@ +import numpy as np + +from finmlkit.bar.data_model import TradesData + + +def test_side_stays_with_its_trade_when_input_is_not_in_id_order(): + """Binance dumps are not always in id order; sorting must carry is_buyer_maker along with price/time/amount.""" + n = 1000 + ids = np.arange(1, n + 1, dtype=np.int64) + ts = (1_700_000_000_000 + ids * 5).astype(np.int64) # ms, one trade per timestamp: nothing merges + px = 100.0 + ids * 0.01 + qty = np.ones(n) + buyer_maker = (ids % 3 == 0) # a side that is a function of the trade + order = np.random.default_rng(0).permutation(n) # shuffled, as in an out-of-order dump + td = TradesData(ts[order], px[order], qty[order], id=ids[order], is_buyer_maker=buyer_maker[order], + timestamp_unit="ms", preprocess=True) + out = td.data + trade_id = np.rint((out["price"].to_numpy() - 100.0) / 0.01).astype(np.int64) + expected = np.where(trade_id % 3 == 0, -1, 1) # buyer maker => seller aggressor => -1 + assert (out["side"].to_numpy() == expected).all() + + +def test_sorted_input_is_unchanged(): + n = 50 + ids = np.arange(1, n + 1, dtype=np.int64) + ts = (1_700_000_000_000 + ids * 5).astype(np.int64) + td = TradesData(ts, 100.0 + ids, np.ones(n), id=ids, is_buyer_maker=(ids % 2 == 0), + timestamp_unit="ms", preprocess=True) + assert (td.data["side"].to_numpy() == np.where(ids % 2 == 0, -1, 1)).all()