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.
Files changed (46) hide show
  1. flowbars/__init__.py +163 -0
  2. flowbars/_version.py +8 -0
  3. flowbars/adapters/__init__.py +29 -0
  4. flowbars/adapters/polars.py +1115 -0
  5. flowbars/bars/__init__.py +3 -0
  6. flowbars/bars/accumulators.py +731 -0
  7. flowbars/bars/constructor.py +1254 -0
  8. flowbars/bars/information/__init__.py +41 -0
  9. flowbars/bars/information/imbalance_dollar_bars.py +200 -0
  10. flowbars/bars/information/imbalance_tick_bars.py +206 -0
  11. flowbars/bars/information/imbalance_volume_bars.py +200 -0
  12. flowbars/bars/information/run_dollar_bars.py +225 -0
  13. flowbars/bars/information/run_tick_bars.py +231 -0
  14. flowbars/bars/information/run_volume_bars.py +225 -0
  15. flowbars/bars/minute/__init__.py +99 -0
  16. flowbars/bars/minute/accumulators.py +303 -0
  17. flowbars/bars/minute/engine.py +244 -0
  18. flowbars/bars/minute/information.py +622 -0
  19. flowbars/bars/minute/numba.py +1043 -0
  20. flowbars/bars/minute/registry.py +22 -0
  21. flowbars/bars/minute/standard.py +261 -0
  22. flowbars/bars/minute/structural.py +474 -0
  23. flowbars/bars/numba_backend.py +1227 -0
  24. flowbars/bars/output.py +106 -0
  25. flowbars/bars/registry.py +47 -0
  26. flowbars/bars/spec.py +367 -0
  27. flowbars/bars/standard/__init__.py +17 -0
  28. flowbars/bars/standard/dollar_bars.py +168 -0
  29. flowbars/bars/standard/tick_bars.py +166 -0
  30. flowbars/bars/standard/time_bars.py +192 -0
  31. flowbars/bars/standard/volume_bars.py +168 -0
  32. flowbars/benchmarks.py +420 -0
  33. flowbars/calendars.py +368 -0
  34. flowbars/core.py +109 -0
  35. flowbars/py.typed +0 -0
  36. flowbars/quality.py +393 -0
  37. flowbars/sample_data/__init__.py +28 -0
  38. flowbars/sample_data/tick_data.csv +501 -0
  39. flowbars/schema.py +476 -0
  40. flowbars/thresholds.py +1167 -0
  41. flowbars/tick_rule.py +89 -0
  42. flowbars-0.1.0.dist-info/METADATA +650 -0
  43. flowbars-0.1.0.dist-info/RECORD +46 -0
  44. flowbars-0.1.0.dist-info/WHEEL +5 -0
  45. flowbars-0.1.0.dist-info/licenses/LICENSE +21 -0
  46. 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)