flowbars 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowbars/__init__.py +163 -0
- flowbars/_version.py +8 -0
- flowbars/adapters/__init__.py +29 -0
- flowbars/adapters/polars.py +1115 -0
- flowbars/bars/__init__.py +3 -0
- flowbars/bars/accumulators.py +731 -0
- flowbars/bars/constructor.py +1254 -0
- flowbars/bars/information/__init__.py +41 -0
- flowbars/bars/information/imbalance_dollar_bars.py +200 -0
- flowbars/bars/information/imbalance_tick_bars.py +206 -0
- flowbars/bars/information/imbalance_volume_bars.py +200 -0
- flowbars/bars/information/run_dollar_bars.py +225 -0
- flowbars/bars/information/run_tick_bars.py +231 -0
- flowbars/bars/information/run_volume_bars.py +225 -0
- flowbars/bars/minute/__init__.py +99 -0
- flowbars/bars/minute/accumulators.py +303 -0
- flowbars/bars/minute/engine.py +244 -0
- flowbars/bars/minute/information.py +622 -0
- flowbars/bars/minute/numba.py +1043 -0
- flowbars/bars/minute/registry.py +22 -0
- flowbars/bars/minute/standard.py +261 -0
- flowbars/bars/minute/structural.py +474 -0
- flowbars/bars/numba_backend.py +1227 -0
- flowbars/bars/output.py +106 -0
- flowbars/bars/registry.py +47 -0
- flowbars/bars/spec.py +367 -0
- flowbars/bars/standard/__init__.py +17 -0
- flowbars/bars/standard/dollar_bars.py +168 -0
- flowbars/bars/standard/tick_bars.py +166 -0
- flowbars/bars/standard/time_bars.py +192 -0
- flowbars/bars/standard/volume_bars.py +168 -0
- flowbars/benchmarks.py +420 -0
- flowbars/calendars.py +368 -0
- flowbars/core.py +109 -0
- flowbars/py.typed +0 -0
- flowbars/quality.py +393 -0
- flowbars/sample_data/__init__.py +28 -0
- flowbars/sample_data/tick_data.csv +501 -0
- flowbars/schema.py +476 -0
- flowbars/thresholds.py +1167 -0
- flowbars/tick_rule.py +89 -0
- flowbars-0.1.0.dist-info/METADATA +650 -0
- flowbars-0.1.0.dist-info/RECORD +46 -0
- flowbars-0.1.0.dist-info/WHEEL +5 -0
- flowbars-0.1.0.dist-info/licenses/LICENSE +21 -0
- flowbars-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
"""Bar accumulators — track running statistics and decide when to close a bar.
|
|
2
|
+
|
|
3
|
+
Each accumulator type embodies one bar-sampling logic (tick count, volume,
|
|
4
|
+
dollar value, time boundary, signed imbalance, or run of same-sign ticks).
|
|
5
|
+
The base class handles shared OHLCV tracking, partial-bar access, and state
|
|
6
|
+
persistence.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from flowbars.core import Bar, MinuteInfo, TickInfo, validate_volume_semantics
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaseAccumulator(ABC):
|
|
21
|
+
"""Shared OHLCV tracking for all bar accumulators.
|
|
22
|
+
|
|
23
|
+
Subclasses define the closing criterion (via ``should_close``),
|
|
24
|
+
overflow handling (via ``close``), and any type-specific statistics
|
|
25
|
+
(via ``_on_tick``).
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
bar_type : str
|
|
30
|
+
The bar type label (e.g. ``"tick"``, ``"dollar"``, ``"imbalance_tick"``).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, bar_type: str) -> None:
|
|
34
|
+
self._bar_type = bar_type
|
|
35
|
+
self._bar_id = 0
|
|
36
|
+
self._has_tick = False
|
|
37
|
+
# Volume semantics — set by the constructor via ``configure_volume``.
|
|
38
|
+
self._multiplier = 1.0
|
|
39
|
+
self._volume_type = "base"
|
|
40
|
+
# OHLCV state
|
|
41
|
+
self._open = 0.0
|
|
42
|
+
self._high = 0.0
|
|
43
|
+
self._low = 0.0
|
|
44
|
+
self._close = 0.0
|
|
45
|
+
self._volume = 0.0
|
|
46
|
+
self._dollar_value = 0.0
|
|
47
|
+
self._num_ticks = 0
|
|
48
|
+
self._open_ts = 0
|
|
49
|
+
self._close_ts = 0
|
|
50
|
+
|
|
51
|
+
def configure_volume(self, multiplier: float, volume_type: str) -> None:
|
|
52
|
+
"""Set the contract multiplier and volume-unit label.
|
|
53
|
+
|
|
54
|
+
The multiplier scales notional (``dollar_value``, dollar-metric
|
|
55
|
+
imbalance/run thresholds) while ``vwap`` stays a price. Both values
|
|
56
|
+
are part of the accumulator's params identity and persisted state.
|
|
57
|
+
"""
|
|
58
|
+
self._multiplier, self._volume_type = validate_volume_semantics(multiplier, volume_type)
|
|
59
|
+
|
|
60
|
+
# ── tick ingestion ──────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def add_tick(self, tick: TickInfo) -> None:
|
|
63
|
+
"""Update running OHLCV statistics with a new tick.
|
|
64
|
+
|
|
65
|
+
Subclasses that override this must call ``super().add_tick(tick)``
|
|
66
|
+
or manually update the OHLCV fields.
|
|
67
|
+
"""
|
|
68
|
+
if not self._has_tick:
|
|
69
|
+
self._open = tick.price
|
|
70
|
+
self._high = tick.price
|
|
71
|
+
self._low = tick.price
|
|
72
|
+
self._open_ts = tick.timestamp
|
|
73
|
+
self._has_tick = True
|
|
74
|
+
else:
|
|
75
|
+
if tick.price > self._high:
|
|
76
|
+
self._high = tick.price
|
|
77
|
+
if tick.price < self._low:
|
|
78
|
+
self._low = tick.price
|
|
79
|
+
|
|
80
|
+
self._close = tick.price
|
|
81
|
+
self._volume += tick.volume
|
|
82
|
+
self._dollar_value += tick.price * tick.volume * self._multiplier
|
|
83
|
+
self._num_ticks += 1
|
|
84
|
+
self._close_ts = tick.timestamp
|
|
85
|
+
|
|
86
|
+
def add_minute(self, minute: MinuteInfo) -> None:
|
|
87
|
+
"""Update running OHLCV statistics with a whole minute candle.
|
|
88
|
+
|
|
89
|
+
Unlike :meth:`add_tick` (a single price, from which high/low are
|
|
90
|
+
derived), a minute already carries its own OHLC, so they are merged
|
|
91
|
+
directly. ``_num_ticks`` counts minutes here (the minute path's
|
|
92
|
+
documented ``num_ticks`` proxy). ``_dollar_value`` uses
|
|
93
|
+
``close × volume`` as the notional proxy.
|
|
94
|
+
"""
|
|
95
|
+
if not self._has_tick:
|
|
96
|
+
self._open = minute.open
|
|
97
|
+
self._high = minute.high
|
|
98
|
+
self._low = minute.low
|
|
99
|
+
self._open_ts = minute.timestamp
|
|
100
|
+
self._has_tick = True
|
|
101
|
+
else:
|
|
102
|
+
if minute.high > self._high:
|
|
103
|
+
self._high = minute.high
|
|
104
|
+
if minute.low < self._low:
|
|
105
|
+
self._low = minute.low
|
|
106
|
+
|
|
107
|
+
self._close = minute.close
|
|
108
|
+
self._volume += minute.volume
|
|
109
|
+
self._dollar_value += minute.close * minute.volume * self._multiplier
|
|
110
|
+
self._num_ticks += 1
|
|
111
|
+
self._close_ts = minute.timestamp
|
|
112
|
+
|
|
113
|
+
# ── partial bar access ──────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def current_bar(self) -> Bar | None:
|
|
117
|
+
"""The in-progress bar, or ``None`` if no ticks have been added."""
|
|
118
|
+
if not self._has_tick:
|
|
119
|
+
return None
|
|
120
|
+
traded_notional = self._volume * self._multiplier
|
|
121
|
+
vwap = self._dollar_value / traded_notional if traded_notional > 0.0 else 0.0
|
|
122
|
+
return Bar(
|
|
123
|
+
bar_id=self._bar_id,
|
|
124
|
+
open=self._open,
|
|
125
|
+
high=self._high,
|
|
126
|
+
low=self._low,
|
|
127
|
+
close=self._close,
|
|
128
|
+
volume=self._volume,
|
|
129
|
+
dollar_value=self._dollar_value,
|
|
130
|
+
vwap=vwap,
|
|
131
|
+
num_ticks=self._num_ticks,
|
|
132
|
+
open_ts=self._open_ts,
|
|
133
|
+
close_ts=self._close_ts,
|
|
134
|
+
bar_type=self._bar_type,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ── closing interface ───────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
def should_close(self, threshold: float) -> bool:
|
|
141
|
+
"""Return ``True`` if the running total meets or exceeds *threshold*."""
|
|
142
|
+
...
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
def close(self, threshold: float) -> Bar:
|
|
146
|
+
"""Emit the completed bar and reset state, with overflow rollover.
|
|
147
|
+
|
|
148
|
+
The caller must have verified that ``should_close(threshold)``
|
|
149
|
+
is ``True`` before calling this method.
|
|
150
|
+
"""
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
# ── internal helpers for subclasses ─────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def _emit_and_reset(self) -> Bar:
|
|
156
|
+
"""Emit the current bar, increment ``bar_id``, and reset OHLCV fields.
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
Bar
|
|
161
|
+
The completed bar (before overflow handling — subclasses must
|
|
162
|
+
apply their own overflow logic after this call).
|
|
163
|
+
"""
|
|
164
|
+
bar = self.current_bar
|
|
165
|
+
assert bar is not None, "Cannot emit bar without ticks"
|
|
166
|
+
self._bar_id += 1
|
|
167
|
+
self._reset_in_progress()
|
|
168
|
+
return bar
|
|
169
|
+
|
|
170
|
+
def _reset_in_progress(self) -> None:
|
|
171
|
+
"""Clear the in-progress OHLCV accumulator without emitting a bar."""
|
|
172
|
+
self._has_tick = False
|
|
173
|
+
self._open = 0.0
|
|
174
|
+
self._high = 0.0
|
|
175
|
+
self._low = 0.0
|
|
176
|
+
self._close = 0.0
|
|
177
|
+
self._volume = 0.0
|
|
178
|
+
self._dollar_value = 0.0
|
|
179
|
+
self._num_ticks = 0
|
|
180
|
+
self._open_ts = 0
|
|
181
|
+
self._close_ts = 0
|
|
182
|
+
|
|
183
|
+
def discard(self) -> None:
|
|
184
|
+
"""Drop the in-progress bar without emitting it.
|
|
185
|
+
|
|
186
|
+
Used by the ``"discard"`` session policy at a session boundary. The
|
|
187
|
+
base implementation clears the OHLCV state; subclasses zero their own
|
|
188
|
+
scalar accumulators so no partial metric or overflow crosses the
|
|
189
|
+
boundary. ``bar_id`` is not advanced — no bar was emitted.
|
|
190
|
+
"""
|
|
191
|
+
self._reset_in_progress()
|
|
192
|
+
|
|
193
|
+
# ── session boundaries ──────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
def on_session_boundary(self) -> None: # noqa: B027
|
|
196
|
+
"""Called after a session-boundary force-close.
|
|
197
|
+
|
|
198
|
+
Default no-op. Accumulators that carry a price across bars (the
|
|
199
|
+
volatility and hybrid minute accumulators) reset it here so a session
|
|
200
|
+
gap contributes no artificial return.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
# ── bar-close statistics ────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def get_close_stats(self) -> tuple[float, float]:
|
|
206
|
+
"""Return ``(t_stat, proportion_stat)`` for the bar about to be closed.
|
|
207
|
+
|
|
208
|
+
Called by the bar constructor **before** ``close()`` so the
|
|
209
|
+
pre-reset state is still available. The default returns zeros —
|
|
210
|
+
standard accumulators don't need meaningful close stats because
|
|
211
|
+
their threshold estimator's ``on_bar_close`` is a no-op.
|
|
212
|
+
|
|
213
|
+
Subclasses that are used with adaptive (EWMA) thresholds override
|
|
214
|
+
this to return real values.
|
|
215
|
+
"""
|
|
216
|
+
return (0.0, 0.0)
|
|
217
|
+
|
|
218
|
+
# ── state persistence ───────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def get_params(self) -> dict[str, Any]:
|
|
221
|
+
"""Return the configuration that defines this accumulator.
|
|
222
|
+
|
|
223
|
+
Params are the *identity* of a bar stream: they are hashed into the
|
|
224
|
+
constructor state so a resume with a mismatched configuration can be
|
|
225
|
+
rejected instead of silently changing bar semantics.
|
|
226
|
+
"""
|
|
227
|
+
return {
|
|
228
|
+
"bar_type": self._bar_type,
|
|
229
|
+
"multiplier": self._multiplier,
|
|
230
|
+
"volume_type": self._volume_type,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def get_state(self) -> dict[str, Any]:
|
|
234
|
+
"""Return a JSON-serializable dict of the accumulator's current state."""
|
|
235
|
+
return {
|
|
236
|
+
"bar_id": self._bar_id,
|
|
237
|
+
"has_tick": self._has_tick,
|
|
238
|
+
"multiplier": self._multiplier,
|
|
239
|
+
"volume_type": self._volume_type,
|
|
240
|
+
"open": self._open,
|
|
241
|
+
"high": self._high if self._has_tick else 0.0,
|
|
242
|
+
"low": self._low if self._has_tick else 0.0,
|
|
243
|
+
"close": self._close,
|
|
244
|
+
"volume": self._volume,
|
|
245
|
+
"dollar_value": self._dollar_value,
|
|
246
|
+
"num_ticks": self._num_ticks,
|
|
247
|
+
"open_ts": self._open_ts,
|
|
248
|
+
"close_ts": self._close_ts,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
def load_state(self, state: dict[str, Any]) -> None:
|
|
252
|
+
"""Restore accumulator state from a saved state dict."""
|
|
253
|
+
self.configure_volume(
|
|
254
|
+
state.get("multiplier", self._multiplier),
|
|
255
|
+
state.get("volume_type", self._volume_type),
|
|
256
|
+
)
|
|
257
|
+
self._bar_id = state["bar_id"]
|
|
258
|
+
self._has_tick = state["has_tick"]
|
|
259
|
+
self._open = state["open"]
|
|
260
|
+
self._high = state["high"]
|
|
261
|
+
self._low = state["low"]
|
|
262
|
+
self._close = state["close"]
|
|
263
|
+
self._volume = state["volume"]
|
|
264
|
+
self._dollar_value = state["dollar_value"]
|
|
265
|
+
self._num_ticks = state["num_ticks"]
|
|
266
|
+
self._open_ts = state["open_ts"]
|
|
267
|
+
self._close_ts = state["close_ts"]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
271
|
+
# Standard-bar accumulators
|
|
272
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class TickAccumulator(BaseAccumulator):
|
|
276
|
+
"""Closes a bar after a fixed number of ticks.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
bar_type : str
|
|
281
|
+
Bar type label (typically ``"tick"``).
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
def __init__(self, bar_type: str = "tick") -> None:
|
|
285
|
+
super().__init__(bar_type)
|
|
286
|
+
self._cum_ticks = 0.0
|
|
287
|
+
|
|
288
|
+
def add_tick(self, tick: TickInfo) -> None:
|
|
289
|
+
super().add_tick(tick)
|
|
290
|
+
self._cum_ticks += 1.0
|
|
291
|
+
|
|
292
|
+
def should_close(self, threshold: float) -> bool:
|
|
293
|
+
return self._cum_ticks >= threshold
|
|
294
|
+
|
|
295
|
+
def close(self, threshold: float) -> Bar:
|
|
296
|
+
bar = self._emit_and_reset()
|
|
297
|
+
excess = self._cum_ticks - threshold
|
|
298
|
+
self._cum_ticks = max(0.0, excess)
|
|
299
|
+
return bar
|
|
300
|
+
|
|
301
|
+
def discard(self) -> None:
|
|
302
|
+
super().discard()
|
|
303
|
+
self._cum_ticks = 0.0
|
|
304
|
+
|
|
305
|
+
def get_state(self) -> dict[str, Any]:
|
|
306
|
+
state = super().get_state()
|
|
307
|
+
state["cum_ticks"] = self._cum_ticks
|
|
308
|
+
return state
|
|
309
|
+
|
|
310
|
+
def load_state(self, state: dict[str, Any]) -> None:
|
|
311
|
+
super().load_state(state)
|
|
312
|
+
self._cum_ticks = state.get("cum_ticks", 0.0)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class VolumeAccumulator(BaseAccumulator):
|
|
316
|
+
"""Closes a bar when cumulative traded volume meets or exceeds a threshold.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
bar_type : str
|
|
321
|
+
Bar type label (typically ``"volume"``).
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
def __init__(self, bar_type: str = "volume") -> None:
|
|
325
|
+
super().__init__(bar_type)
|
|
326
|
+
self._cum_volume = 0.0
|
|
327
|
+
|
|
328
|
+
def add_tick(self, tick: TickInfo) -> None:
|
|
329
|
+
super().add_tick(tick)
|
|
330
|
+
self._cum_volume += tick.volume
|
|
331
|
+
|
|
332
|
+
def should_close(self, threshold: float) -> bool:
|
|
333
|
+
return self._cum_volume >= threshold
|
|
334
|
+
|
|
335
|
+
def close(self, threshold: float) -> Bar:
|
|
336
|
+
bar = self._emit_and_reset()
|
|
337
|
+
excess = self._cum_volume - threshold
|
|
338
|
+
self._cum_volume = excess
|
|
339
|
+
return bar
|
|
340
|
+
|
|
341
|
+
def discard(self) -> None:
|
|
342
|
+
super().discard()
|
|
343
|
+
self._cum_volume = 0.0
|
|
344
|
+
|
|
345
|
+
def get_state(self) -> dict[str, Any]:
|
|
346
|
+
state = super().get_state()
|
|
347
|
+
state["cum_volume"] = self._cum_volume
|
|
348
|
+
return state
|
|
349
|
+
|
|
350
|
+
def load_state(self, state: dict[str, Any]) -> None:
|
|
351
|
+
super().load_state(state)
|
|
352
|
+
self._cum_volume = state.get("cum_volume", 0.0)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class DollarAccumulator(BaseAccumulator):
|
|
356
|
+
"""Closes a bar when cumulative notional value meets or exceeds a threshold.
|
|
357
|
+
|
|
358
|
+
Parameters
|
|
359
|
+
----------
|
|
360
|
+
bar_type : str
|
|
361
|
+
Bar type label (typically ``"dollar"``).
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
def __init__(self, bar_type: str = "dollar") -> None:
|
|
365
|
+
super().__init__(bar_type)
|
|
366
|
+
self._cum_dollar = 0.0
|
|
367
|
+
|
|
368
|
+
def add_tick(self, tick: TickInfo) -> None:
|
|
369
|
+
super().add_tick(tick)
|
|
370
|
+
self._cum_dollar += tick.price * tick.volume * self._multiplier
|
|
371
|
+
|
|
372
|
+
def should_close(self, threshold: float) -> bool:
|
|
373
|
+
return self._cum_dollar >= threshold
|
|
374
|
+
|
|
375
|
+
def close(self, threshold: float) -> Bar:
|
|
376
|
+
bar = self._emit_and_reset()
|
|
377
|
+
excess = self._cum_dollar - threshold
|
|
378
|
+
self._cum_dollar = excess
|
|
379
|
+
return bar
|
|
380
|
+
|
|
381
|
+
def discard(self) -> None:
|
|
382
|
+
super().discard()
|
|
383
|
+
self._cum_dollar = 0.0
|
|
384
|
+
|
|
385
|
+
def get_state(self) -> dict[str, Any]:
|
|
386
|
+
state = super().get_state()
|
|
387
|
+
state["cum_dollar"] = self._cum_dollar
|
|
388
|
+
return state
|
|
389
|
+
|
|
390
|
+
def load_state(self, state: dict[str, Any]) -> None:
|
|
391
|
+
super().load_state(state)
|
|
392
|
+
self._cum_dollar = state.get("cum_dollar", 0.0)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
396
|
+
# Time accumulator
|
|
397
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class TimeAccumulator(BaseAccumulator):
|
|
401
|
+
"""Closes a bar when a tick's timestamp crosses a time boundary.
|
|
402
|
+
|
|
403
|
+
Parameters
|
|
404
|
+
----------
|
|
405
|
+
bar_type : str
|
|
406
|
+
Bar type label (typically ``"time"``).
|
|
407
|
+
interval_ms : int
|
|
408
|
+
Bar interval in milliseconds (e.g. 300000 for 5-minute bars).
|
|
409
|
+
anchor : str
|
|
410
|
+
``"clock"`` — bars aligned to round UTC boundaries (e.g. 14:00:00).
|
|
411
|
+
``"first_tick"`` — bars aligned relative to the first tick's timestamp.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
def __init__(
|
|
415
|
+
self,
|
|
416
|
+
bar_type: str = "time",
|
|
417
|
+
interval_ms: int = 60000,
|
|
418
|
+
anchor: str = "clock",
|
|
419
|
+
) -> None:
|
|
420
|
+
super().__init__(bar_type)
|
|
421
|
+
if interval_ms <= 0:
|
|
422
|
+
raise ValueError(f"interval_ms must be positive, got {interval_ms}")
|
|
423
|
+
if anchor not in ("clock", "first_tick"):
|
|
424
|
+
raise ValueError(f"anchor must be 'clock' or 'first_tick', got {anchor!r}")
|
|
425
|
+
self._interval_ms = interval_ms
|
|
426
|
+
self._anchor = anchor
|
|
427
|
+
# _next_boundary_ms: the timestamp (ms) at which the current bar closes
|
|
428
|
+
self._next_boundary_ms: int | None = None
|
|
429
|
+
|
|
430
|
+
def add_tick(self, tick: TickInfo) -> None:
|
|
431
|
+
super().add_tick(tick)
|
|
432
|
+
if self._next_boundary_ms is None:
|
|
433
|
+
self._next_boundary_ms = self._compute_first_boundary(tick.timestamp)
|
|
434
|
+
|
|
435
|
+
def should_close(self, threshold: float) -> bool:
|
|
436
|
+
# threshold unused — time bars close on boundary crossing, not on a
|
|
437
|
+
# numeric threshold (the "threshold" is the interval duration)
|
|
438
|
+
if self._next_boundary_ms is None:
|
|
439
|
+
return False
|
|
440
|
+
return self._close_ts >= self._next_boundary_ms
|
|
441
|
+
|
|
442
|
+
def close(self, threshold: float) -> Bar:
|
|
443
|
+
bar = self._emit_and_reset()
|
|
444
|
+
# Advance to the next boundary
|
|
445
|
+
assert self._next_boundary_ms is not None
|
|
446
|
+
self._next_boundary_ms += self._interval_ms
|
|
447
|
+
return bar
|
|
448
|
+
|
|
449
|
+
def discard(self) -> None:
|
|
450
|
+
super().discard()
|
|
451
|
+
# Re-anchor to the first tick after the boundary.
|
|
452
|
+
self._next_boundary_ms = None
|
|
453
|
+
|
|
454
|
+
def _compute_first_boundary(self, first_ts: int) -> int:
|
|
455
|
+
"""Compute the first closing boundary timestamp in milliseconds."""
|
|
456
|
+
if self._anchor == "first_tick":
|
|
457
|
+
return first_ts + self._interval_ms
|
|
458
|
+
# clock anchor: round up to the next interval boundary
|
|
459
|
+
# e.g. interval=300000 (5 min), ts=14:03:17 → next boundary = 14:05:00
|
|
460
|
+
remainder = first_ts % self._interval_ms
|
|
461
|
+
if remainder == 0:
|
|
462
|
+
return first_ts + self._interval_ms
|
|
463
|
+
return first_ts - remainder + self._interval_ms
|
|
464
|
+
|
|
465
|
+
def get_params(self) -> dict[str, Any]:
|
|
466
|
+
params = super().get_params()
|
|
467
|
+
params["interval_ms"] = self._interval_ms
|
|
468
|
+
params["anchor"] = self._anchor
|
|
469
|
+
return params
|
|
470
|
+
|
|
471
|
+
def get_state(self) -> dict[str, Any]:
|
|
472
|
+
state = super().get_state()
|
|
473
|
+
state["next_boundary_ms"] = self._next_boundary_ms
|
|
474
|
+
state["interval_ms"] = self._interval_ms
|
|
475
|
+
state["anchor"] = self._anchor
|
|
476
|
+
return state
|
|
477
|
+
|
|
478
|
+
def load_state(self, state: dict[str, Any]) -> None:
|
|
479
|
+
super().load_state(state)
|
|
480
|
+
self._next_boundary_ms = state.get("next_boundary_ms")
|
|
481
|
+
self._interval_ms = state["interval_ms"]
|
|
482
|
+
self._anchor = state["anchor"]
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
486
|
+
# Information-driven accumulators
|
|
487
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class ImbalanceAccumulator(BaseAccumulator):
|
|
491
|
+
"""Closes a bar when the absolute signed imbalance crosses a threshold.
|
|
492
|
+
|
|
493
|
+
The imbalance metric can be tick count, volume, or dollar value,
|
|
494
|
+
weighted by trade sign (tick rule or supplied side).
|
|
495
|
+
|
|
496
|
+
Parameters
|
|
497
|
+
----------
|
|
498
|
+
bar_type : str
|
|
499
|
+
Bar type label (e.g. ``"imbalance_tick"``).
|
|
500
|
+
metric : str
|
|
501
|
+
What to weight the sign by: ``"tick"``, ``"volume"``, or ``"dollar"``.
|
|
502
|
+
"""
|
|
503
|
+
|
|
504
|
+
# Map metric names to extractor functions on TickInfo
|
|
505
|
+
_METRIC_FNS: dict[str, Any] = {} # populated below
|
|
506
|
+
|
|
507
|
+
def __init__(self, bar_type: str = "imbalance_tick", metric: str = "tick") -> None:
|
|
508
|
+
super().__init__(bar_type)
|
|
509
|
+
if metric not in ("tick", "volume", "dollar"):
|
|
510
|
+
raise ValueError(f"metric must be 'tick', 'volume', or 'dollar', got {metric!r}")
|
|
511
|
+
self._metric = metric
|
|
512
|
+
self._signed_imbalance = 0.0
|
|
513
|
+
|
|
514
|
+
def add_tick(self, tick: TickInfo) -> None:
|
|
515
|
+
super().add_tick(tick)
|
|
516
|
+
# First-tick exclusion: if side is NaN, imbalance contribution is 0
|
|
517
|
+
if tick.side is not None and not math.isnan(tick.side):
|
|
518
|
+
value = _imbalance_metric(tick, self._metric, self._multiplier)
|
|
519
|
+
self._signed_imbalance += tick.side * value
|
|
520
|
+
|
|
521
|
+
def should_close(self, threshold: float) -> bool:
|
|
522
|
+
return abs(self._signed_imbalance) >= threshold
|
|
523
|
+
|
|
524
|
+
def get_close_stats(self) -> tuple[float, float]:
|
|
525
|
+
"""Return (t_stat, θ) where θ = signed_imbalance / t_stat."""
|
|
526
|
+
if self._metric == "tick":
|
|
527
|
+
t_stat = float(self._num_ticks)
|
|
528
|
+
elif self._metric == "volume":
|
|
529
|
+
t_stat = self._volume
|
|
530
|
+
else: # dollar
|
|
531
|
+
t_stat = self._dollar_value
|
|
532
|
+
proportion = self._signed_imbalance / t_stat if t_stat > 0.0 else 0.0
|
|
533
|
+
return (t_stat, proportion)
|
|
534
|
+
|
|
535
|
+
def close(self, threshold: float) -> Bar:
|
|
536
|
+
bar = self._emit_and_reset()
|
|
537
|
+
# Overflow: signed excess carries into next bar
|
|
538
|
+
sign = 1.0 if self._signed_imbalance >= 0.0 else -1.0
|
|
539
|
+
excess = abs(self._signed_imbalance) - threshold
|
|
540
|
+
self._signed_imbalance = sign * max(0.0, excess)
|
|
541
|
+
return bar
|
|
542
|
+
|
|
543
|
+
def discard(self) -> None:
|
|
544
|
+
super().discard()
|
|
545
|
+
self._signed_imbalance = 0.0
|
|
546
|
+
|
|
547
|
+
def get_params(self) -> dict[str, Any]:
|
|
548
|
+
params = super().get_params()
|
|
549
|
+
params["metric"] = self._metric
|
|
550
|
+
return params
|
|
551
|
+
|
|
552
|
+
def get_state(self) -> dict[str, Any]:
|
|
553
|
+
state = super().get_state()
|
|
554
|
+
state["signed_imbalance"] = self._signed_imbalance
|
|
555
|
+
state["metric"] = self._metric
|
|
556
|
+
return state
|
|
557
|
+
|
|
558
|
+
def load_state(self, state: dict[str, Any]) -> None:
|
|
559
|
+
super().load_state(state)
|
|
560
|
+
self._signed_imbalance = state.get("signed_imbalance", 0.0)
|
|
561
|
+
self._metric = state.get("metric", "tick")
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _imbalance_metric(tick: TickInfo, metric: str, multiplier: float = 1.0) -> float:
|
|
565
|
+
"""Extract the imbalance weight from a tick."""
|
|
566
|
+
if metric == "tick":
|
|
567
|
+
return 1.0
|
|
568
|
+
elif metric == "volume":
|
|
569
|
+
return tick.volume
|
|
570
|
+
else: # "dollar"
|
|
571
|
+
return tick.price * tick.volume * multiplier
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _minute_metric(minute: MinuteInfo, metric: str, multiplier: float = 1.0) -> float:
|
|
575
|
+
"""Extract the imbalance/run weight from a minute candle.
|
|
576
|
+
|
|
577
|
+
Mirrors :func:`_imbalance_metric` for the minute path. ``"tick"`` is not
|
|
578
|
+
used by the minute path (tick-count bars are not feasible from minutes),
|
|
579
|
+
but is included for symmetry.
|
|
580
|
+
"""
|
|
581
|
+
if metric == "tick":
|
|
582
|
+
return 1.0
|
|
583
|
+
elif metric == "volume":
|
|
584
|
+
return minute.volume
|
|
585
|
+
else: # "dollar"
|
|
586
|
+
return minute.close * minute.volume * multiplier
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
class RunAccumulator(BaseAccumulator):
|
|
590
|
+
"""Closes a bar when the cumulative same-sign run statistic crosses a threshold.
|
|
591
|
+
|
|
592
|
+
A *run* is a sequence of consecutive same-sign ticks. When the direction
|
|
593
|
+
changes, the current run is banked and a new one begins. The bar closes
|
|
594
|
+
when the total banked + current-run statistic reaches the threshold.
|
|
595
|
+
|
|
596
|
+
Parameters
|
|
597
|
+
----------
|
|
598
|
+
bar_type : str
|
|
599
|
+
Bar type label (e.g. ``"run_tick"``).
|
|
600
|
+
metric : str
|
|
601
|
+
What to accumulate per run: ``"tick"``, ``"volume"``, or ``"dollar"``.
|
|
602
|
+
"""
|
|
603
|
+
|
|
604
|
+
def __init__(self, bar_type: str = "run_tick", metric: str = "tick") -> None:
|
|
605
|
+
super().__init__(bar_type)
|
|
606
|
+
if metric not in ("tick", "volume", "dollar"):
|
|
607
|
+
raise ValueError(f"metric must be 'tick', 'volume', or 'dollar', got {metric!r}")
|
|
608
|
+
self._metric = metric
|
|
609
|
+
self._first_tick = True # True only before the very first tick (survives close())
|
|
610
|
+
self._banked = 0.0 # cumulative from completed runs in this bar
|
|
611
|
+
self._run_sign = np.nan # NaN = no run started yet
|
|
612
|
+
self._run_cum = 0.0 # cumulative within the current run
|
|
613
|
+
self._buy_cum = 0.0 # total buy-side run metric in this bar
|
|
614
|
+
self._sell_cum = 0.0 # total sell-side run metric in this bar
|
|
615
|
+
|
|
616
|
+
@staticmethod
|
|
617
|
+
def _same_direction(a: float, b: float) -> bool:
|
|
618
|
+
"""Two sides belong to the same run if either is NaN, or both are equal.
|
|
619
|
+
|
|
620
|
+
NaN acts as a wildcard — it matches any direction. This implements
|
|
621
|
+
the spec's rule that the first tick (NaN side) is retroactively
|
|
622
|
+
included in the first run whose direction is determined by tick 2.
|
|
623
|
+
"""
|
|
624
|
+
if np.isnan(a) or np.isnan(b):
|
|
625
|
+
return True
|
|
626
|
+
return a == b
|
|
627
|
+
|
|
628
|
+
def add_tick(self, tick: TickInfo) -> None:
|
|
629
|
+
super().add_tick(tick)
|
|
630
|
+
side = tick.side if tick.side is not None else np.nan
|
|
631
|
+
self._accumulate_run(side, _imbalance_metric(tick, self._metric, self._multiplier))
|
|
632
|
+
|
|
633
|
+
def _accumulate_run(self, side: float, metric_value: float) -> None:
|
|
634
|
+
"""Accumulate one same-sign run step (shared by tick and minute paths)."""
|
|
635
|
+
if self._first_tick:
|
|
636
|
+
# Very first tick of the stream: start the first run.
|
|
637
|
+
self._first_tick = False
|
|
638
|
+
self._run_sign = side # may be NaN
|
|
639
|
+
self._run_cum = metric_value
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
if self._same_direction(self._run_sign, side):
|
|
643
|
+
# Same direction — continue the run
|
|
644
|
+
self._run_cum += metric_value
|
|
645
|
+
if np.isnan(self._run_sign) and not np.isnan(side):
|
|
646
|
+
# Retroactively assign direction to a NaN-led run
|
|
647
|
+
self._run_sign = side
|
|
648
|
+
else:
|
|
649
|
+
# Direction change: bank current run, start a new one
|
|
650
|
+
self._banked += self._run_cum
|
|
651
|
+
# Track buy vs sell for P⁺ computation
|
|
652
|
+
if self._run_sign > 0.0:
|
|
653
|
+
self._buy_cum += self._run_cum
|
|
654
|
+
elif self._run_sign < 0.0:
|
|
655
|
+
self._sell_cum += self._run_cum
|
|
656
|
+
# else: _run_sign is NaN — run has no direction yet, don't count
|
|
657
|
+
self._run_sign = side
|
|
658
|
+
self._run_cum = metric_value
|
|
659
|
+
|
|
660
|
+
@property
|
|
661
|
+
def _total(self) -> float:
|
|
662
|
+
"""Total run statistic accumulated in this bar (banked + current)."""
|
|
663
|
+
return self._banked + self._run_cum
|
|
664
|
+
|
|
665
|
+
def should_close(self, threshold: float) -> bool:
|
|
666
|
+
return self._total >= threshold
|
|
667
|
+
|
|
668
|
+
def get_close_stats(self) -> tuple[float, float]:
|
|
669
|
+
"""Return (t_stat, P⁺) where P⁺ = buy_cum / (buy_cum + sell_cum).
|
|
670
|
+
|
|
671
|
+
The current (un-banked) run is included in the proportion computation
|
|
672
|
+
via the direction and cum tracked separately from buy_cum/sell_cum.
|
|
673
|
+
"""
|
|
674
|
+
# Include the current (un-banked) run in the buy/sell totals
|
|
675
|
+
cur_buy = self._buy_cum
|
|
676
|
+
cur_sell = self._sell_cum
|
|
677
|
+
if self._run_sign > 0.0:
|
|
678
|
+
cur_buy += self._run_cum
|
|
679
|
+
elif self._run_sign < 0.0:
|
|
680
|
+
cur_sell += self._run_cum
|
|
681
|
+
# else: run_sign is NaN — no direction, exclude from both
|
|
682
|
+
|
|
683
|
+
total = cur_buy + cur_sell
|
|
684
|
+
proportion = cur_buy / total if total > 0.0 else 0.5 # neutral prior
|
|
685
|
+
return (self._total, proportion)
|
|
686
|
+
|
|
687
|
+
def close(self, threshold: float) -> Bar:
|
|
688
|
+
bar = self._emit_and_reset()
|
|
689
|
+
# Overflow: the excess from total carries into the next bar.
|
|
690
|
+
# The excess belongs to the current run (the one that crossed).
|
|
691
|
+
excess = self._total - threshold
|
|
692
|
+
self._banked = 0.0
|
|
693
|
+
self._run_cum = max(0.0, excess)
|
|
694
|
+
self._buy_cum = 0.0
|
|
695
|
+
self._sell_cum = 0.0
|
|
696
|
+
# Keep _run_sign — the overflow run continues into the next bar
|
|
697
|
+
return bar
|
|
698
|
+
|
|
699
|
+
def discard(self) -> None:
|
|
700
|
+
super().discard()
|
|
701
|
+
self._banked = 0.0
|
|
702
|
+
self._run_sign = np.nan
|
|
703
|
+
self._run_cum = 0.0
|
|
704
|
+
self._buy_cum = 0.0
|
|
705
|
+
self._sell_cum = 0.0
|
|
706
|
+
|
|
707
|
+
def get_params(self) -> dict[str, Any]:
|
|
708
|
+
params = super().get_params()
|
|
709
|
+
params["metric"] = self._metric
|
|
710
|
+
return params
|
|
711
|
+
|
|
712
|
+
def get_state(self) -> dict[str, Any]:
|
|
713
|
+
state = super().get_state()
|
|
714
|
+
state["metric"] = self._metric
|
|
715
|
+
state["first_tick"] = self._first_tick
|
|
716
|
+
state["banked"] = self._banked
|
|
717
|
+
state["run_sign"] = self._run_sign
|
|
718
|
+
state["run_cum"] = self._run_cum
|
|
719
|
+
state["buy_cum"] = self._buy_cum
|
|
720
|
+
state["sell_cum"] = self._sell_cum
|
|
721
|
+
return state
|
|
722
|
+
|
|
723
|
+
def load_state(self, state: dict[str, Any]) -> None:
|
|
724
|
+
super().load_state(state)
|
|
725
|
+
self._metric = state.get("metric", "tick")
|
|
726
|
+
self._first_tick = state.get("first_tick", True)
|
|
727
|
+
self._banked = state.get("banked", 0.0)
|
|
728
|
+
self._run_sign = state.get("run_sign", np.nan)
|
|
729
|
+
self._run_cum = state.get("run_cum", 0.0)
|
|
730
|
+
self._buy_cum = state.get("buy_cum", 0.0)
|
|
731
|
+
self._sell_cum = state.get("sell_cum", 0.0)
|