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,1254 @@
1
+ """Base bar constructor — shared infrastructure for all bar types.
2
+
3
+ The ``BaseBarConstructor`` orchestrates an accumulator, threshold estimator,
4
+ and trading calendar to produce bars from a streaming tick feed. It is
5
+ abstracted over the specific bar type — subclasses in ``standard/`` and
6
+ ``information/`` provide the concrete accumulator + estimator wiring.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import collections
12
+ import copy
13
+ import hashlib
14
+ import json
15
+ import math
16
+ import warnings
17
+ from typing import Any, Callable, ClassVar, Literal
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from flowbars._version import __version__ as _version_string
23
+ from flowbars.bars.accumulators import BaseAccumulator
24
+ from flowbars.calendars import ContinuousCalendar, TradingCalendar
25
+ from flowbars.core import (
26
+ Bar,
27
+ MinuteInfo,
28
+ SchemaError,
29
+ StateValidationError,
30
+ TickDataError,
31
+ TickInfo,
32
+ )
33
+ from flowbars.schema import MinuteSchemaMapping, SchemaMapping
34
+ from flowbars.thresholds import EWMAThresholdEstimator, ThresholdEstimator
35
+ from flowbars.tick_rule import resolve_tick_signs
36
+
37
+ _OUTPUT_COLUMNS = [
38
+ "bar_id",
39
+ "open",
40
+ "high",
41
+ "low",
42
+ "close",
43
+ "volume",
44
+ "dollar_value",
45
+ "vwap",
46
+ "num_ticks",
47
+ "open_ts",
48
+ "close_ts",
49
+ "bar_type",
50
+ "partial",
51
+ ]
52
+
53
+ # Current state schema. v2 adds params/params_hash/engine_version plus the
54
+ # persisted warmup/ordering settings; v1 states remain loadable.
55
+ _STATE_SCHEMA_VERSION = 2
56
+ _ENGINE_VERSION = _version_string
57
+ # Version of the public output contract (columns + frame attrs). Consumers
58
+ # can pin ``(bar_type, spec_version, params_hash)``.
59
+ _OUTPUT_CONTRACT_VERSION = 1
60
+
61
+
62
+ def params_hash(params: dict[str, Any]) -> str:
63
+ """Stable SHA-256 hex digest of a params dict.
64
+
65
+ Canonical JSON rules: keys sorted, separators ``(",", ":")`` (no
66
+ whitespace), NaN/Infinity rejected. Two equal parameter sets therefore
67
+ always hash identically regardless of insertion order.
68
+ """
69
+ blob = json.dumps(params, sort_keys=True, separators=(",", ":"), allow_nan=False)
70
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
71
+
72
+
73
+ # Backwards-compatible internal alias (imported by existing tests).
74
+ _params_hash = params_hash
75
+
76
+
77
+ def _bars_to_frame(bars: list[Bar]) -> pd.DataFrame:
78
+ """Build the public output frame from completed bars."""
79
+ if not bars:
80
+ return pd.DataFrame(columns=_OUTPUT_COLUMNS)
81
+ return pd.DataFrame([vars(bar) for bar in bars], columns=_OUTPUT_COLUMNS)
82
+
83
+
84
+ def _input_watermark(item: TickInfo | MinuteInfo) -> int:
85
+ """Return the dedup watermark for *item*.
86
+
87
+ Uses ``item.watermark`` when set (a custom monotonic key such as a
88
+ sequence number), otherwise falls back to ``timestamp``.
89
+ """
90
+ return item.watermark if item.watermark is not None else item.timestamp
91
+
92
+
93
+ def _states_equal(a: Any, b: Any) -> bool:
94
+ """Compare two state structures, treating NaN floats as equal."""
95
+ if isinstance(a, dict) and isinstance(b, dict):
96
+ return a.keys() == b.keys() and all(_states_equal(a[k], b[k]) for k in a)
97
+ if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)):
98
+ return len(a) == len(b) and all(_states_equal(x, y) for x, y in zip(a, b))
99
+ if isinstance(a, float) and isinstance(b, float) and math.isnan(a) and math.isnan(b):
100
+ return True
101
+ return bool(a == b)
102
+
103
+
104
+ def _row_to_bar(row: np.ndarray, bar_type: str) -> Bar:
105
+ """Build a :class:`Bar` from one row of a numba output array."""
106
+ return Bar(
107
+ bar_id=int(row[0]),
108
+ open=float(row[1]),
109
+ high=float(row[2]),
110
+ low=float(row[3]),
111
+ close=float(row[4]),
112
+ volume=float(row[5]),
113
+ dollar_value=float(row[6]),
114
+ vwap=float(row[7]),
115
+ num_ticks=int(row[8]),
116
+ open_ts=int(row[9]),
117
+ close_ts=int(row[10]),
118
+ bar_type=bar_type,
119
+ )
120
+
121
+
122
+ class BaseBarConstructor:
123
+ """Shared bar-construction engine for all bar types.
124
+
125
+ Wires together an accumulator (Phase 2), a threshold estimator (Phase 3),
126
+ and a trading calendar (Phase 4). Each bar type is a subclass that
127
+ picks the right components.
128
+
129
+ Parameters
130
+ ----------
131
+ accumulator : BaseAccumulator
132
+ The bar-type-specific accumulator (tick, volume, imbalance, …).
133
+ threshold_estimator : ThresholdEstimator
134
+ Fixed (:class:`StaticThresholdEstimator`) for standard bars,
135
+ adaptive (:class:`EWMAThresholdEstimator`) for information-driven bars.
136
+ calendar : TradingCalendar, optional
137
+ Defaults to :class:`ContinuousCalendar` (always-open).
138
+ schema : SchemaMapping, optional
139
+ Required for :meth:`batch`; not used by :meth:`update`.
140
+ watermark : str or None, default ``"timestamp"``
141
+ Dedup key for idempotent resume. ``"timestamp"`` (default) dedups
142
+ on the tick timestamp; any other string is a column name (batch) or
143
+ ``TickInfo.watermark`` field (streaming) to dedup on. ``None``
144
+ disables dedup.
145
+ stream_id : str, default ``"default"``
146
+ Opaque identifier validated on state resume.
147
+ strict_ordering : bool, default False
148
+ When True, raise :class:`TickDataError` if a tick arrives with a
149
+ timestamp earlier than the previous tick's (out-of-order input).
150
+ warmup_bars : int, default 0
151
+ Number of initial bars to discard (not returned from
152
+ :meth:`update` / :meth:`batch`). Bars during warmup still
153
+ trigger callbacks and update the estimator. Mutually exclusive
154
+ with *warmup_minutes*.
155
+ warmup_minutes : int or None, default None
156
+ Duration-based alternative to *warmup_bars*: bars that close
157
+ before ``warmup_minutes`` minutes have elapsed since the first
158
+ accepted input are not returned (callbacks still fire and the
159
+ estimator still learns). Bars keep their original ``bar_id``, so
160
+ the first returned bar is not renumbered. ``None`` disables it;
161
+ mutually exclusive with *warmup_bars*.
162
+ backend : str, default ``"python"``
163
+ ``"python"`` (default, always available) or ``"numba"`` (optional,
164
+ requires ``pip install flowbars[numba]``). The numba backend
165
+ accelerates :meth:`batch` via a JIT-compiled inner loop.
166
+ :meth:`update` (streaming, single-tick) always uses the Python
167
+ backend regardless of this setting.
168
+ on_bar : callable or None
169
+ Called as ``on_bar(bar)`` after every bar closes (including
170
+ warmup bars and session-boundary force-closes).
171
+ on_threshold_update : callable or None
172
+ Called as ``on_threshold_update(new_value)`` when the threshold
173
+ estimator produces a new value after a bar close.
174
+ multiplier : float, default 1.0
175
+ Contract multiplier: converts traded units to notional
176
+ (``dollar_value = Σ price × volume × multiplier``). ``vwap`` stays
177
+ a price. Must be positive.
178
+ volume_type : str, default ``"base"``
179
+ What the ``volume`` column means: ``"base"``, ``"quote"``,
180
+ ``"shares"``, ``"contracts"``, or ``"tick"``. Metadata only.
181
+
182
+ Thread safety
183
+ -------------
184
+ Not thread-safe. Designed for single-threaded streaming or batch use.
185
+ """
186
+
187
+ input_kind: ClassVar[str] = "tick"
188
+ # True when the bar's notional/volume mapping is a proxy (e.g. minute
189
+ # bars use close × volume instead of exact trade notional).
190
+ approximate: ClassVar[bool] = False
191
+ # Recommended warm-up duration advertised in the registry spec (metadata
192
+ # only; the runtime warmup is the ``warmup_bars``/``warmup_minutes`` param).
193
+ recommended_warmup_minutes: ClassVar[int] = 0
194
+ # Canonical registry key (e.g. ``"minute_volume"``), set at registration.
195
+ # The emitted ``bar_type`` label can be shorter (e.g. ``"volume"``); this
196
+ # attribute is the unambiguous identity.
197
+ registry_name: ClassVar[str | None] = None
198
+
199
+ def __init__(
200
+ self,
201
+ accumulator: BaseAccumulator,
202
+ threshold_estimator: ThresholdEstimator,
203
+ calendar: TradingCalendar | None = None,
204
+ schema: SchemaMapping | MinuteSchemaMapping | None = None,
205
+ watermark: str | None = "timestamp",
206
+ stream_id: str = "default",
207
+ strict_ordering: bool = False,
208
+ warmup_bars: int = 0,
209
+ warmup_minutes: int | None = None,
210
+ backend: Literal["python", "numba"] = "python",
211
+ on_bar: Callable[[Bar], None] | None = None,
212
+ on_threshold_update: Callable[[float], None] | None = None,
213
+ multiplier: float = 1.0,
214
+ volume_type: str = "base",
215
+ session_policy: Literal["close", "carry", "discard"] = "close",
216
+ session_estimator: Literal["persist", "reset"] = "persist",
217
+ ) -> None:
218
+ if warmup_bars < 0:
219
+ raise ValueError(f"warmup_bars must be non-negative, got {warmup_bars}")
220
+ if warmup_minutes is not None and warmup_minutes < 0:
221
+ raise ValueError(f"warmup_minutes must be non-negative, got {warmup_minutes}")
222
+ if warmup_bars > 0 and warmup_minutes:
223
+ raise ValueError("warmup_bars and warmup_minutes are mutually exclusive; set only one.")
224
+ if backend not in ("python", "numba"):
225
+ raise ValueError(f"backend must be 'python' or 'numba', got {backend!r}")
226
+ if session_policy not in ("close", "carry", "discard"):
227
+ raise ValueError(
228
+ f"session_policy must be 'close', 'carry', or 'discard', got {session_policy!r}"
229
+ )
230
+ if session_estimator not in ("persist", "reset"):
231
+ raise ValueError(
232
+ f"session_estimator must be 'persist' or 'reset', got {session_estimator!r}"
233
+ )
234
+
235
+ self._accumulator = accumulator
236
+ self._accumulator.configure_volume(multiplier, volume_type)
237
+ self._threshold_estimator = threshold_estimator
238
+ self._calendar = calendar if calendar is not None else ContinuousCalendar()
239
+ self._schema = schema
240
+ self._watermark_key = watermark
241
+ self._stream_id = stream_id
242
+ self._strict_ordering = strict_ordering
243
+ self._warmup_bars = warmup_bars
244
+ self._warmup_minutes = warmup_minutes
245
+ self._warmup_start_ts: int | None = None
246
+ self._backend = backend
247
+ self._on_bar = on_bar
248
+ self._on_threshold_update = on_threshold_update
249
+ self._session_policy = session_policy
250
+ self._session_estimator = session_estimator
251
+
252
+ # Internal state
253
+ self._bars_emitted: int = 0
254
+ self._in_session: bool = True # True until first boundary tick lands
255
+ self._pending_bars: collections.deque[Bar] = collections.deque()
256
+ self._last_watermark: int | None = None # dedup watermark of last accepted tick
257
+ self._last_timestamp: int | None = None # timestamp of last accepted tick (ordering)
258
+ self._session_id: str | None = None # calendar session of the last in-session input
259
+ self._last_session_end: int | None = None # its close instant (Unix ms)
260
+
261
+ # Snapshots used to detect a resumed/partially-consumed constructor, in
262
+ # which case the numba batch kernels (which assume a zero start) fall
263
+ # back to the Python path.
264
+ self._initial_acc_state = copy.deepcopy(self._accumulator.get_state())
265
+ self._initial_est_state = copy.deepcopy(self._threshold_estimator.get_state())
266
+
267
+ # ── core API ────────────────────────────────────────────────────────
268
+
269
+ def update(self, item: TickInfo | MinuteInfo) -> Bar | None:
270
+ """Feed one tick or minute. Return a completed bar, or ``None``.
271
+
272
+ Session-boundary force-closes and threshold-driven closes are both
273
+ handled. During warmup bars are queued for the estimator but not
274
+ returned.
275
+ """
276
+ # 0. Watermark dedup — drop input already processed (idempotent resume)
277
+ wm = _input_watermark(item)
278
+ if (
279
+ self._watermark_key is not None
280
+ and self._last_watermark is not None
281
+ and wm <= self._last_watermark
282
+ ):
283
+ return None
284
+
285
+ # 0b. Ordering check — reject non-monotonic timestamps when enabled
286
+ if (
287
+ self._strict_ordering
288
+ and self._last_timestamp is not None
289
+ and item.timestamp < self._last_timestamp
290
+ ):
291
+ raise TickDataError(
292
+ f"Out-of-order tick: timestamp {item.timestamp} < previous {self._last_timestamp}"
293
+ )
294
+
295
+ # 1. Session boundary detection. This runs before input preparation
296
+ # because the minute path derives its tick-rule sign there, and a
297
+ # session gap must clear the carried price state first.
298
+ is_boundary = self._calendar.is_session_boundary(item.timestamp)
299
+ session_id = None if is_boundary else self._calendar.session_id(item.timestamp)
300
+ skipped_gap = self._detect_skipped_gap(item, is_boundary, session_id)
301
+ # A transition is a boundary->session or session->boundary flip, or a
302
+ # direct jump from one session into a later one (a feed that never
303
+ # prints during the gap — see _detect_skipped_gap).
304
+ if skipped_gap or is_boundary == self._in_session:
305
+ if self._accumulator._has_tick and self._session_policy != "carry":
306
+ if self._session_policy == "close":
307
+ # Force-close the in-progress bar as a partial bar. Its
308
+ # close_ts is the session boundary, not this input's ts.
309
+ if skipped_gap:
310
+ self._emit_bar(close_ts=self._skipped_gap_close_ts())
311
+ else:
312
+ self._emit_bar(close_ts=self._session_transition_ts(item, is_boundary))
313
+ else: # "discard"
314
+ self._discard_bar()
315
+ # A session gap never contributes a price return: reset carried
316
+ # price state under every policy (a "carry" bar stays open but
317
+ # skips the gap return).
318
+ self._accumulator.on_session_boundary()
319
+ self._on_session_boundary()
320
+ if self._session_estimator == "reset":
321
+ self._threshold_estimator.reset()
322
+ if not skipped_gap:
323
+ self._in_session = not is_boundary
324
+
325
+ # 2. Per-input preparation (minute path derives missing sides here, so
326
+ # a deduplicated input never corrupts the sign state)
327
+ item = self._prepare_input(item)
328
+ if self._warmup_minutes and self._warmup_start_ts is None:
329
+ self._warmup_start_ts = item.timestamp
330
+
331
+ # 3. Add input to accumulator
332
+ self._ingest_input(item)
333
+ self._last_watermark = wm
334
+ self._last_timestamp = item.timestamp
335
+ if session_id is not None:
336
+ self._session_id = session_id
337
+ if not is_boundary:
338
+ session_end = self._calendar.session_end(item.timestamp)
339
+ if session_end is not None:
340
+ self._last_session_end = session_end
341
+
342
+ # 4. Update threshold estimator (no-op for static, also no-op for EWMA)
343
+ self._threshold_estimator.update(item)
344
+
345
+ # 5. Threshold-based closure
346
+ threshold = self._threshold_estimator.current_threshold
347
+ if self._accumulator.should_close(threshold):
348
+ t_stat, proportion_stat = self._accumulator.get_close_stats()
349
+ bar = self._accumulator.close(threshold)
350
+
351
+ old_threshold = threshold
352
+ self._threshold_estimator.on_bar_close(t_stat, proportion_stat)
353
+ new_threshold = self._threshold_estimator.current_threshold
354
+
355
+ self._bars_emitted += 1
356
+
357
+ if self._on_bar is not None:
358
+ self._on_bar(bar)
359
+
360
+ if self._on_threshold_update is not None and old_threshold != new_threshold:
361
+ self._on_threshold_update(new_threshold)
362
+
363
+ if self._past_warmup(item.timestamp) and self._should_return(bar):
364
+ self._pending_bars.append(bar)
365
+
366
+ # 6. Return one bar from queue, or None
367
+ if self._pending_bars:
368
+ return self._pending_bars.popleft()
369
+ return None
370
+
371
+ def flush(self, timestamp: int | None = None) -> Bar | None:
372
+ """Force-close the in-progress bar and return it, or ``None``.
373
+
374
+ Use at end of stream, on a feed's session-close event, or before
375
+ checkpointing so an open bar is not left hanging across a gap. The
376
+ bar is marked ``partial``. When *timestamp* is omitted the close
377
+ instant is the calendar's session end for the last input, falling back
378
+ to the accumulator's own close timestamp.
379
+
380
+ Price-derived state is reset as at any session boundary, so a later
381
+ resumption never treats the flush gap as a price move. Warmup and
382
+ subclass filters (e.g. ``min_run_length``) apply as in :meth:`update`.
383
+ """
384
+ if not self._accumulator._has_tick:
385
+ return None
386
+ close_ts = timestamp
387
+ if close_ts is None and self._last_timestamp is not None:
388
+ close_ts = self._calendar.session_end(self._last_timestamp)
389
+ self._emit_bar(close_ts=close_ts)
390
+ self._accumulator.on_session_boundary()
391
+ self._on_session_boundary()
392
+ if self._pending_bars:
393
+ return self._pending_bars.popleft()
394
+ return None
395
+
396
+ def update_many(self, ticks_df: pd.DataFrame, *, validate: bool = False) -> pd.DataFrame:
397
+ """Stream every row of *ticks_df* through :meth:`update`.
398
+
399
+ Equivalent to calling :meth:`update` once per row: the same schema
400
+ mapping, watermark dedup, strict-ordering checks, callbacks, warmup,
401
+ and session handling apply. Returns the bars closed during the call
402
+ (queued pending bars from a previous call are included first).
403
+
404
+ ``backend`` is ignored here — streaming always uses the Python path.
405
+
406
+ Parameters
407
+ ----------
408
+ ticks_df : pd.DataFrame
409
+ Ticks (or minute candles) with user-mapped column names.
410
+ validate : bool, default False
411
+ Run :func:`~flowbars.bars.output.validate_bar_frame` on the
412
+ returned frame (raises :class:`BarContractError` on violation).
413
+
414
+ Returns
415
+ -------
416
+ pd.DataFrame
417
+ Bars with the standard output schema (empty frame if none closed).
418
+ """
419
+ if self._schema is None:
420
+ self._schema = self._default_schema()
421
+
422
+ arrays = self._extract_batch_arrays(ticks_df)
423
+ timestamps = arrays[0]
424
+ wm_arr = self._resolve_watermark_array(ticks_df, timestamps)
425
+
426
+ bars: list[Bar] = []
427
+ while self._pending_bars:
428
+ bars.append(self._pending_bars.popleft())
429
+
430
+ for i in range(len(timestamps)):
431
+ bar = self.update(self._make_input(arrays, i, wm_arr))
432
+ if bar is not None:
433
+ bars.append(bar)
434
+
435
+ while self._pending_bars:
436
+ bars.append(self._pending_bars.popleft())
437
+
438
+ return self._maybe_validate(self._frame(bars), validate)
439
+
440
+ def batch(self, ticks_df: pd.DataFrame, *, validate: bool = False) -> pd.DataFrame:
441
+ """Feed all input rows from a DataFrame, return completed bars.
442
+
443
+ Parameters
444
+ ----------
445
+ ticks_df : pd.DataFrame
446
+ Ticks (or minute candles) with user-mapped column names (see
447
+ :class:`SchemaMapping` / :class:`MinuteSchemaMapping`).
448
+ validate : bool, default False
449
+ Run :func:`~flowbars.bars.output.validate_bar_frame` on the
450
+ returned frame (raises :class:`BarContractError` on violation).
451
+
452
+ Returns
453
+ -------
454
+ pd.DataFrame
455
+ Bars as a DataFrame with columns: ``bar_id``, ``open``, ``high``,
456
+ ``low``, ``close``, ``volume``, ``dollar_value``, ``vwap``,
457
+ ``num_ticks``, ``open_ts``, ``close_ts``, ``bar_type``.
458
+
459
+ Raises
460
+ ------
461
+ ValueError
462
+ If no schema was provided and the input columns are not standard.
463
+ """
464
+ if self._schema is None:
465
+ # Auto-create a default schema so convenience batch functions
466
+ # work without an explicit schema when columns are standard.
467
+ self._schema = self._default_schema()
468
+
469
+ arrays = self._extract_batch_arrays(ticks_df)
470
+ timestamps = arrays[0]
471
+
472
+ # ── watermark pre-filter (idempotent resume, backend-agnostic) ──
473
+ wm_arr = self._resolve_watermark_array(ticks_df, timestamps)
474
+ if wm_arr is not None and self._last_watermark is not None:
475
+ keep = wm_arr > self._last_watermark
476
+ arrays = self._slice_batch_arrays(arrays, keep)
477
+ timestamps = arrays[0]
478
+ wm_arr = wm_arr[keep]
479
+
480
+ # ── duration warmup anchor (numba bypasses update()) ────────────
481
+ if self._warmup_minutes and self._warmup_start_ts is None and len(timestamps) > 0:
482
+ self._warmup_start_ts = int(timestamps[0])
483
+
484
+ # ── strict ordering check (fail fast on the whole batch) ────────
485
+ self._check_batch_ordering(timestamps)
486
+
487
+ # ── numba path ──────────────────────────────────────────────────
488
+ if self._backend == "numba":
489
+ result = self._batch_numba(arrays, wm_arr)
490
+ # numba bypasses update(); advance the watermark explicitly
491
+ if wm_arr is not None and len(wm_arr) > 0:
492
+ self._last_watermark = int(wm_arr[-1])
493
+ if len(timestamps) > 0:
494
+ self._last_timestamp = int(timestamps[-1])
495
+ return self._maybe_validate(result, validate)
496
+
497
+ # ── Python path ─────────────────────────────────────────────────
498
+ return self._maybe_validate(self._batch_python(arrays, wm_arr), validate)
499
+
500
+ def _maybe_validate(self, frame: pd.DataFrame, validate: bool) -> pd.DataFrame:
501
+ """Validate *frame* against the output contract when requested."""
502
+ if validate:
503
+ from flowbars.bars.output import validate_bar_frame
504
+
505
+ validate_bar_frame(frame)
506
+ return frame
507
+
508
+ # ── numba batch path ──────────────────────────────────────────────────
509
+
510
+ def _batch_numba(
511
+ self,
512
+ arrays: tuple[np.ndarray, ...],
513
+ watermark: np.ndarray | None = None,
514
+ ) -> pd.DataFrame:
515
+ """Run the batch bar-construction loop via numba (or fall back to Python).
516
+
517
+ Handles static and EWMA thresholds, time-bar special casing, and
518
+ warmup-bar slicing. Falls back to the Python path when:
519
+
520
+ * numba is not installed
521
+ * a session-producing calendar is in use (``has_sessions=True`` —
522
+ boundaries are infrequent callbacks, not worth duplicating in numba)
523
+ * the constructor is not pristine (resumed or partially consumed
524
+ state — the kernels assume a zero start)
525
+ * compilation/execution fails
526
+ """
527
+ from flowbars.bars.numba_backend import (
528
+ _NUMBA_AVAILABLE,
529
+ _get_compilation_warning,
530
+ numba_batch_ewma,
531
+ numba_batch_static,
532
+ )
533
+
534
+ if not _NUMBA_AVAILABLE:
535
+ _get_compilation_warning()
536
+ return self._batch_python(arrays, watermark)
537
+
538
+ if self._calendar.has_sessions:
539
+ warnings.warn(
540
+ "This calendar produces session boundaries, which the numba "
541
+ "backend does not support. Falling back to the Python path.",
542
+ stacklevel=2,
543
+ )
544
+ return self._batch_python(arrays, watermark)
545
+
546
+ if not self._is_pristine():
547
+ warnings.warn(
548
+ "The numba backend requires a pristine constructor (no prior "
549
+ "update()/batch() calls and no restored state). Falling back "
550
+ "to the Python path.",
551
+ stacklevel=2,
552
+ )
553
+ return self._batch_python(arrays, watermark)
554
+
555
+ timestamps, prices, volumes, sides = arrays
556
+ bar_type = self._accumulator._bar_type
557
+
558
+ # ── determine threshold config ─────────────────────────────────
559
+ estimator = self._threshold_estimator
560
+
561
+ if bar_type == "time":
562
+ # Time bars: extract interval_ms and anchor from the accumulator.
563
+ # TimeAccumulator stores these — drop into the instance to read them.
564
+ acc = self._accumulator
565
+ interval_ms: int = getattr(acc, "_interval_ms", 60000)
566
+ anchor: str = getattr(acc, "_anchor", "clock")
567
+ try:
568
+ bar_data, bt = numba_batch_static(
569
+ bar_type="time",
570
+ timestamps=timestamps,
571
+ prices=prices,
572
+ volumes=volumes,
573
+ sides=None,
574
+ threshold=0.0, # unused by time bars
575
+ interval_ms=interval_ms,
576
+ anchor=anchor,
577
+ multiplier=self.multiplier,
578
+ )
579
+ except Exception:
580
+ warnings.warn(
581
+ "numba compilation/execution failed for time bars. "
582
+ "Falling back to the Python path.",
583
+ stacklevel=2,
584
+ )
585
+ return self._batch_python(arrays, watermark)
586
+ elif isinstance(estimator, EWMAThresholdEstimator):
587
+ # Adaptive threshold — use the EWMA-aware numba path
588
+ try:
589
+ bar_data, bt = numba_batch_ewma(
590
+ bar_type=bar_type,
591
+ timestamps=timestamps,
592
+ prices=prices,
593
+ volumes=volumes,
594
+ sides=sides,
595
+ alpha=estimator.alpha,
596
+ initial_ewa_t=estimator.ewa_t,
597
+ initial_ewa_proportion=estimator.ewa_proportion,
598
+ multiplier=self.multiplier,
599
+ )
600
+ except Exception:
601
+ warnings.warn(
602
+ f"numba compilation/execution failed for {bar_type}. "
603
+ "Falling back to the Python path.",
604
+ stacklevel=2,
605
+ )
606
+ return self._batch_python(arrays, watermark)
607
+ else:
608
+ # Static threshold (standard bars or info-driven bars with fixed threshold)
609
+ threshold = estimator.current_threshold
610
+ try:
611
+ bar_data, bt = numba_batch_static(
612
+ bar_type=bar_type,
613
+ timestamps=timestamps,
614
+ prices=prices,
615
+ volumes=volumes,
616
+ sides=sides if bar_type.startswith(("imbalance_", "run_")) else None,
617
+ threshold=threshold,
618
+ multiplier=self.multiplier,
619
+ )
620
+ except Exception:
621
+ warnings.warn(
622
+ f"numba compilation/execution failed for {bar_type}. "
623
+ "Falling back to the Python path.",
624
+ stacklevel=2,
625
+ )
626
+ return self._batch_python(arrays, watermark)
627
+
628
+ return self._finalize_numba_result(bar_data, bt)
629
+
630
+ def _finalize_numba_result(self, bar_data: np.ndarray, bar_type: str) -> pd.DataFrame:
631
+ """Turn a numba kernel's raw output into the public bar DataFrame.
632
+
633
+ Matches the Python path: every closed bar (including warmup bars)
634
+ fires ``on_bar`` and counts toward ``bars_emitted``; warmup bars are
635
+ then removed without renumbering (their ``bar_id`` values already
636
+ start at ``warmup_bars``); finally the subclass ``_should_return``
637
+ filter is applied.
638
+ """
639
+ from flowbars.bars.numba_backend import _COL_CLOSE_TS, _bar_data_to_columns
640
+
641
+ self._bars_emitted += len(bar_data)
642
+
643
+ if self._on_bar is not None:
644
+ for row in bar_data:
645
+ self._on_bar(_row_to_bar(row, bar_type))
646
+
647
+ if self._warmup_bars > 0 and len(bar_data) > 0:
648
+ bar_data = bar_data[self._warmup_bars :]
649
+ elif self._warmup_minutes and self._warmup_start_ts is not None and len(bar_data) > 0:
650
+ # A bar closes on an input at `close_ts`, so the streaming
651
+ # `_past_warmup(item.timestamp)` rule maps onto this mask.
652
+ bar_data = bar_data[bar_data[:, _COL_CLOSE_TS] >= self._warmup_end_ts()]
653
+
654
+ if len(bar_data) > 0:
655
+ keep = np.array([self._should_return(_row_to_bar(row, bar_type)) for row in bar_data])
656
+ bar_data = bar_data[keep]
657
+
658
+ if len(bar_data) == 0:
659
+ return self._frame([])
660
+
661
+ cols = _bar_data_to_columns(bar_data)
662
+ cols["bar_type"] = bar_type # type: ignore[assignment]
663
+ cols["partial"] = False # type: ignore[assignment]
664
+ frame = pd.DataFrame(cols)
665
+ frame.attrs.update(self._frame_metadata())
666
+ return frame
667
+
668
+ def _frame_metadata(self) -> dict[str, Any]:
669
+ """Frame-level metadata identifying the series that produced it."""
670
+ return {
671
+ "bar_type": self._accumulator._bar_type,
672
+ "registry_name": self.registry_name,
673
+ "params_hash": _params_hash(self.get_params()),
674
+ "input_kind": self.input_kind,
675
+ "engine_version": _ENGINE_VERSION,
676
+ "state_schema_version": _STATE_SCHEMA_VERSION,
677
+ "spec_version": _OUTPUT_CONTRACT_VERSION,
678
+ "multiplier": self.multiplier,
679
+ "volume_type": self.volume_type,
680
+ "approximate": self.approximate,
681
+ }
682
+
683
+ def _frame(self, bars: list[Bar]) -> pd.DataFrame:
684
+ """Build the output frame with series metadata attached."""
685
+ frame = _bars_to_frame(bars)
686
+ frame.attrs.update(self._frame_metadata())
687
+ return frame
688
+
689
+ def _batch_python(
690
+ self,
691
+ arrays: tuple[np.ndarray, ...],
692
+ watermark: np.ndarray | None = None,
693
+ ) -> pd.DataFrame:
694
+ """Pure-Python batch loop — used as the fallback from :meth:`_batch_numba`."""
695
+ timestamps = arrays[0]
696
+ bars: list[Bar] = []
697
+ for i in range(len(timestamps)):
698
+ bar_result = self.update(self._make_input(arrays, i, watermark))
699
+ if bar_result is not None:
700
+ bars.append(bar_result)
701
+
702
+ # Drain remaining pending bars
703
+ while self._pending_bars:
704
+ bars.append(self._pending_bars.popleft())
705
+
706
+ return self._frame(bars)
707
+
708
+ # ── input abstraction hooks (tick defaults) ───────────────────────────
709
+
710
+ def _default_schema(self) -> SchemaMapping | MinuteSchemaMapping:
711
+ """Schema used by :meth:`batch` when none was supplied."""
712
+ return SchemaMapping({"timestamp": "timestamp", "price": "price", "volume": "volume"})
713
+
714
+ def _extract_batch_arrays(self, df: pd.DataFrame) -> tuple[np.ndarray, ...]:
715
+ """Extract and validate the per-column arrays from a batch DataFrame."""
716
+ assert isinstance(self._schema, SchemaMapping)
717
+ timestamps, prices, volumes, sides = self._schema.extract_arrays(df)
718
+ sides = resolve_tick_signs(prices, sides)
719
+ return (timestamps, prices, volumes, sides)
720
+
721
+ def _slice_batch_arrays(
722
+ self, arrays: tuple[np.ndarray, ...], keep: np.ndarray
723
+ ) -> tuple[np.ndarray, ...]:
724
+ """Apply a boolean mask to every extracted array (``None`` passes through)."""
725
+ return tuple(a[keep] if a is not None else None for a in arrays) # type: ignore[misc]
726
+
727
+ def _make_input(
728
+ self, arrays: tuple[np.ndarray, ...], i: int, watermark: np.ndarray | None
729
+ ) -> TickInfo | MinuteInfo:
730
+ """Build one ``TickInfo``/``MinuteInfo`` from the batch arrays at row *i*."""
731
+ timestamps, prices, volumes, sides = arrays
732
+ raw_side = sides[i]
733
+ return TickInfo(
734
+ timestamp=int(timestamps[i]),
735
+ price=float(prices[i]),
736
+ volume=float(volumes[i]),
737
+ side=float(raw_side) if not np.isnan(raw_side) else None,
738
+ watermark=int(watermark[i]) if watermark is not None else None,
739
+ )
740
+
741
+ def _prepare_input(self, item: TickInfo | MinuteInfo) -> TickInfo | MinuteInfo:
742
+ """Hook for per-input normalization before ingestion (tick: no-op)."""
743
+ return item
744
+
745
+ def _ingest_input(self, item: TickInfo | MinuteInfo) -> None:
746
+ """Add one prepared input to the accumulator."""
747
+ self._accumulator.add_tick(item) # type: ignore[arg-type]
748
+
749
+ def _check_batch_ordering(self, timestamps: np.ndarray) -> None:
750
+ """Fail fast if the whole batch is out of order (when enabled)."""
751
+ if not self._strict_ordering or len(timestamps) == 0:
752
+ return
753
+ if self._last_timestamp is not None and int(timestamps[0]) < self._last_timestamp:
754
+ raise TickDataError(
755
+ f"Out-of-order tick: timestamp {int(timestamps[0])} < "
756
+ f"previous {self._last_timestamp}"
757
+ )
758
+ diffs = np.diff(timestamps)
759
+ if diffs.size and np.any(diffs < 0):
760
+ idx = int(np.argmax(diffs < 0)) + 1
761
+ raise TickDataError(
762
+ f"Out-of-order tick at batch index {idx}: "
763
+ f"timestamp {int(timestamps[idx])} < "
764
+ f"previous {int(timestamps[idx - 1])}"
765
+ )
766
+
767
+ def _is_pristine(self) -> bool:
768
+ """True if no input has been consumed and state is untouched."""
769
+ return (
770
+ self._bars_emitted == 0
771
+ and not self._pending_bars
772
+ and self._last_watermark is None
773
+ and _states_equal(self._accumulator.get_state(), self._initial_acc_state)
774
+ and _states_equal(self._threshold_estimator.get_state(), self._initial_est_state)
775
+ )
776
+
777
+ def _extra_state(self) -> dict[str, Any]: # noqa: B027
778
+ """Subclass-specific state merged into :meth:`get_state`."""
779
+ return {}
780
+
781
+ def _load_extra_state(self, state: dict[str, Any]) -> None: # noqa: B027
782
+ """Restore subclass-specific state from :meth:`load_state`."""
783
+
784
+ def _extra_params(self) -> dict[str, Any]: # noqa: B027
785
+ """Subclass-specific configuration merged into :meth:`get_params`."""
786
+ return {}
787
+
788
+ def _resolve_watermark_array(
789
+ self, ticks_df: pd.DataFrame, timestamps: np.ndarray
790
+ ) -> np.ndarray | None:
791
+ """Resolve the watermark values for a batch, or ``None`` if disabled.
792
+
793
+ ``"timestamp"`` reuses the already-extracted timestamp array; any
794
+ other key is looked up as a literal column name in ``ticks_df``.
795
+ """
796
+ if self._watermark_key is None:
797
+ return None
798
+ if self._watermark_key == "timestamp":
799
+ return timestamps
800
+ col = self._watermark_key
801
+ if col not in ticks_df.columns:
802
+ raise SchemaError(
803
+ f"Watermark column {col!r} not found in input. "
804
+ f"Available columns: {sorted(ticks_df.columns)}"
805
+ )
806
+ return np.asarray(ticks_df[col].to_numpy())
807
+
808
+ # ── read-only accessors ─────────────────────────────────────────────
809
+
810
+ @property
811
+ def current_bar(self) -> Bar | None:
812
+ """The in-progress bar, or ``None`` if no ticks have been added."""
813
+ return self._accumulator.current_bar
814
+
815
+ @property
816
+ def bars_emitted(self) -> int:
817
+ """Total bars emitted so far (including warmup bars)."""
818
+ return self._bars_emitted
819
+
820
+ @property
821
+ def warmup_done(self) -> bool:
822
+ """``True`` once no warmup is configured or the warmup window passed.
823
+
824
+ Bar-count warmup is done after ``warmup_bars`` + 1 bars have closed
825
+ (the extra bar is the first returned one). Duration warmup is done
826
+ once the last accepted input reaches ``warmup_minutes`` minutes past
827
+ the first accepted input.
828
+ """
829
+ if self._warmup_minutes:
830
+ if self._warmup_start_ts is None or self._last_timestamp is None:
831
+ return False
832
+ return self._last_timestamp >= self._warmup_end_ts()
833
+ return self._warmup_bars == 0 or self._bars_emitted > self._warmup_bars
834
+
835
+ @property
836
+ def multiplier(self) -> float:
837
+ """Contract multiplier scaling notional (``dollar_value``)."""
838
+ return self._accumulator._multiplier
839
+
840
+ @property
841
+ def volume_type(self) -> str:
842
+ """What the ``volume`` column means (e.g. ``"contracts"``)."""
843
+ return self._accumulator._volume_type
844
+
845
+ # ── state persistence ───────────────────────────────────────────────
846
+
847
+ def get_params(self) -> dict[str, Any]:
848
+ """Configuration that defines this bar stream.
849
+
850
+ Params are hashed into the serialized state so a resume against a
851
+ constructor with a different configuration fails loudly instead of
852
+ silently changing bar semantics. Runtime values (partial-bar OHLCV,
853
+ EWMA terms) live in the state, not here.
854
+ """
855
+ return {
856
+ "input_kind": self.input_kind,
857
+ "bar_type": self._accumulator._bar_type,
858
+ "session_policy": self._session_policy,
859
+ "session_estimator": self._session_estimator,
860
+ "accumulator": self._accumulator.get_params(),
861
+ "threshold_estimator": self._threshold_estimator.get_params(),
862
+ **self._extra_params(),
863
+ }
864
+
865
+ def get_state(self) -> dict[str, Any]:
866
+ """Return a JSON-serializable dict of the constructor's current state.
867
+
868
+ Includes the bar-defining params and their hash, the engine version,
869
+ accumulator/estimator state, and constructor-level bookkeeping.
870
+ """
871
+ params = self.get_params()
872
+ state = {
873
+ "schema_version": _STATE_SCHEMA_VERSION,
874
+ "engine_version": _ENGINE_VERSION,
875
+ "input_kind": self.input_kind,
876
+ "stream_id": self._stream_id,
877
+ "bar_type": self._accumulator._bar_type,
878
+ "registry_name": self.registry_name,
879
+ "backend": self._backend,
880
+ "watermark_key": self._watermark_key,
881
+ "warmup_bars": self._warmup_bars,
882
+ "warmup_minutes": self._warmup_minutes,
883
+ "warmup_start_ts": self._warmup_start_ts,
884
+ "strict_ordering": self._strict_ordering,
885
+ "session_policy": self._session_policy,
886
+ "session_estimator": self._session_estimator,
887
+ "session_id": self._session_id,
888
+ "last_session_end": self._last_session_end,
889
+ "calendar": self._calendar.to_dict(),
890
+ "calendar_fingerprint": self._calendar.fingerprint(),
891
+ "schema_mapping": None if self._schema is None else self._schema.to_dict(),
892
+ "schema_fingerprint": None if self._schema is None else self._schema.fingerprint(),
893
+ "params": params,
894
+ "params_hash": _params_hash(params),
895
+ "last_watermark": self._last_watermark,
896
+ "last_timestamp": self._last_timestamp,
897
+ "accumulator": self._accumulator.get_state(),
898
+ "threshold_estimator": self._threshold_estimator.get_state(),
899
+ "bars_emitted": self._bars_emitted,
900
+ "in_session": self._in_session,
901
+ }
902
+ state.update(self._extra_state())
903
+ return state
904
+
905
+ def _verify_saved_identity(self, state: dict[str, Any]) -> None:
906
+ """Reject state written against a different registry key, calendar, or schema."""
907
+ saved_registry = state.get("registry_name")
908
+ if (
909
+ saved_registry is not None
910
+ and self.registry_name is not None
911
+ and saved_registry != self.registry_name
912
+ ):
913
+ raise StateValidationError(
914
+ f"registry_name mismatch: state has {saved_registry!r}, "
915
+ f"constructor has {self.registry_name!r}"
916
+ )
917
+
918
+ saved_calendar_fp = state.get("calendar_fingerprint")
919
+ if saved_calendar_fp is not None:
920
+ calendar_fp = self._calendar.fingerprint()
921
+ if saved_calendar_fp != calendar_fp:
922
+ raise StateValidationError(
923
+ f"calendar mismatch: state was built with {state.get('calendar')!r} "
924
+ f"(fingerprint {saved_calendar_fp}), constructor has "
925
+ f"{self._calendar.to_dict()!r} (fingerprint {calendar_fp}). "
926
+ "Pass the same calendar or use verify_identity=False."
927
+ )
928
+
929
+ saved_schema_fp = state.get("schema_fingerprint")
930
+ if saved_schema_fp is not None and self._schema is not None:
931
+ schema_fp = self._schema.fingerprint()
932
+ if saved_schema_fp != schema_fp:
933
+ raise StateValidationError(
934
+ f"schema mapping mismatch: state was built with "
935
+ f"{state.get('schema_mapping')!r}, constructor has "
936
+ f"{self._schema.to_dict()!r}. Pass the same schema or use "
937
+ "verify_identity=False."
938
+ )
939
+
940
+ def load_state(self, state: dict[str, Any], *, verify_identity: bool = True) -> None:
941
+ """Restore constructor state from a saved state dict.
942
+
943
+ Validates stream identity and schema version before applying state.
944
+
945
+ Parameters
946
+ ----------
947
+ state : dict
948
+ State previously returned by :meth:`get_state`.
949
+ verify_identity : bool, default True
950
+ Also require the calendar and schema column mapping to match the
951
+ ones recorded in the state. Pass ``False`` to opt out (e.g. when
952
+ intentionally replaying state under a different input mapping).
953
+
954
+ Raises
955
+ ------
956
+ StateValidationError
957
+ If *stream_id*, *bar_type*, *registry_name*, *schema_version*,
958
+ calendar, or schema mapping is incompatible.
959
+ """
960
+ # Version check
961
+ version = state.get("schema_version", 0)
962
+ if version < 1:
963
+ raise StateValidationError(
964
+ f"State schema version {version} is not supported. "
965
+ "Rebuild the state from the source input. Minimum required version is 1."
966
+ )
967
+ if version > _STATE_SCHEMA_VERSION:
968
+ raise StateValidationError(
969
+ f"State schema version {version} was written by a newer version "
970
+ f"of flowbars (this build supports up to {_STATE_SCHEMA_VERSION}). "
971
+ "Upgrade the library or rebuild the state from the source input."
972
+ )
973
+
974
+ # Identity validation
975
+ saved_kind = state.get("input_kind", "tick")
976
+ if saved_kind != self.input_kind:
977
+ raise StateValidationError(
978
+ f"input_kind mismatch: state has {saved_kind!r}, "
979
+ f"constructor has {self.input_kind!r}"
980
+ )
981
+ if state["stream_id"] != self._stream_id:
982
+ raise StateValidationError(
983
+ f"stream_id mismatch: state has {state['stream_id']!r}, "
984
+ f"constructor has {self._stream_id!r}"
985
+ )
986
+ if state["bar_type"] != self._accumulator._bar_type:
987
+ raise StateValidationError(
988
+ f"bar_type mismatch: state has {state['bar_type']!r}, "
989
+ f"accumulator has {self._accumulator._bar_type!r}"
990
+ )
991
+ if state["watermark_key"] != self._watermark_key:
992
+ raise StateValidationError(
993
+ f"watermark_key mismatch: state has {state['watermark_key']!r}, "
994
+ f"constructor has {self._watermark_key!r}"
995
+ )
996
+
997
+ if verify_identity:
998
+ self._verify_saved_identity(state)
999
+
1000
+ saved_engine = state.get("engine_version")
1001
+ if (
1002
+ saved_engine is not None
1003
+ and str(saved_engine).split(".")[0] != _ENGINE_VERSION.split(".")[0]
1004
+ ):
1005
+ raise StateValidationError(
1006
+ f"State was written by engine version {saved_engine}, which is "
1007
+ f"incompatible with engine version {_ENGINE_VERSION}. "
1008
+ "Rebuild the state from the source input."
1009
+ )
1010
+
1011
+ if version >= 2:
1012
+ saved_params = state.get("params")
1013
+ saved_hash = state.get("params_hash")
1014
+ if saved_params is None or saved_hash is None:
1015
+ raise StateValidationError(
1016
+ "State schema v2 is missing 'params'/'params_hash'. "
1017
+ "Rebuild the state from the source input."
1018
+ )
1019
+ if _params_hash(saved_params) != saved_hash:
1020
+ raise StateValidationError(
1021
+ "State params_hash does not match its params — the state is "
1022
+ "corrupt. Rebuild it from the source input."
1023
+ )
1024
+ current_params = self.get_params()
1025
+ if _params_hash(current_params) != saved_hash:
1026
+ raise StateValidationError(
1027
+ f"params mismatch: state was built with {saved_params!r}, "
1028
+ f"constructor has {current_params!r}. Rebuild the state from "
1029
+ "the source input or resume with a matching constructor."
1030
+ )
1031
+
1032
+ # Apply state
1033
+ self._accumulator.load_state(state["accumulator"])
1034
+ self._threshold_estimator.load_state(state["threshold_estimator"])
1035
+ self._bars_emitted = state.get("bars_emitted", 0)
1036
+ self._in_session = state.get("in_session", True)
1037
+ self._session_id = state.get("session_id", self._session_id)
1038
+ self._last_session_end = state.get("last_session_end", self._last_session_end)
1039
+ self._last_watermark = state["last_watermark"]
1040
+ self._last_timestamp = state.get("last_timestamp")
1041
+ if version >= 2:
1042
+ self._warmup_bars = int(state.get("warmup_bars", self._warmup_bars))
1043
+ self._warmup_minutes = state.get("warmup_minutes", self._warmup_minutes)
1044
+ self._warmup_start_ts = state.get("warmup_start_ts", self._warmup_start_ts)
1045
+ self._strict_ordering = bool(state.get("strict_ordering", self._strict_ordering))
1046
+ self._session_policy = state.get("session_policy", self._session_policy)
1047
+ self._session_estimator = state.get("session_estimator", self._session_estimator)
1048
+ self._load_extra_state(state)
1049
+ # Restore backend if present (new in state schema v1, added in Phase 9)
1050
+ saved_backend = state.get("backend", "python")
1051
+ if saved_backend in ("python", "numba"):
1052
+ self._backend = saved_backend
1053
+
1054
+ # Clear any pending bars (they should have been drained before saving)
1055
+ self._pending_bars.clear()
1056
+
1057
+ @classmethod
1058
+ def from_state(
1059
+ cls,
1060
+ state: dict[str, Any],
1061
+ accumulator: BaseAccumulator,
1062
+ threshold_estimator: ThresholdEstimator,
1063
+ calendar: TradingCalendar | None = None,
1064
+ schema: SchemaMapping | MinuteSchemaMapping | None = None,
1065
+ *,
1066
+ verify_identity: bool = True,
1067
+ ) -> BaseBarConstructor:
1068
+ """Create a new constructor instance from a saved state dict.
1069
+
1070
+ The caller is responsible for providing the correct accumulator
1071
+ and threshold-estimator types (or re-creating them from the
1072
+ bar-type information). Subclasses can wrap this to auto-create
1073
+ the right components.
1074
+
1075
+ The instance is built through an attribute-initialization path rather
1076
+ than ``__init__`` so that subclass behavior (e.g. ``min_run_length``
1077
+ filtering) is preserved; subclass-specific fields are restored by
1078
+ :meth:`_load_extra_state`.
1079
+
1080
+ Parameters
1081
+ ----------
1082
+ state : dict
1083
+ State previously returned by :meth:`get_state`.
1084
+ accumulator : BaseAccumulator
1085
+ Fresh instance of the correct accumulator type.
1086
+ threshold_estimator : ThresholdEstimator
1087
+ Fresh instance of the correct estimator type.
1088
+ calendar : TradingCalendar, optional
1089
+ Must match the calendar recorded in the state (the default
1090
+ :class:`ContinuousCalendar` is used when omitted).
1091
+ schema : SchemaMapping or MinuteSchemaMapping, optional
1092
+ Must match the schema recorded in the state when provided.
1093
+ verify_identity : bool, default True
1094
+ Require the saved registry key, calendar, and schema mapping to
1095
+ match. Pass ``False`` to resume under a different configuration.
1096
+
1097
+ Returns
1098
+ -------
1099
+ BaseBarConstructor
1100
+ A new constructor with its state restored from *state*.
1101
+ """
1102
+ backend = state.get("backend", "python")
1103
+ if backend not in ("python", "numba"):
1104
+ backend = "python"
1105
+
1106
+ inst = cls.__new__(cls)
1107
+ inst._accumulator = accumulator
1108
+ inst._threshold_estimator = threshold_estimator
1109
+ inst._calendar = calendar if calendar is not None else ContinuousCalendar()
1110
+ inst._schema = schema
1111
+ inst._watermark_key = state["watermark_key"]
1112
+ inst._stream_id = state["stream_id"]
1113
+ inst._strict_ordering = False # restored by load_state (v2+)
1114
+ inst._warmup_bars = 0 # restored by load_state (v2+)
1115
+ inst._warmup_minutes = state.get("warmup_minutes") # restored by load_state (v2+)
1116
+ inst._warmup_start_ts = state.get("warmup_start_ts")
1117
+ # Session settings are part of the params identity, so seed them from
1118
+ # the state before load_state validates the params hash.
1119
+ inst._session_policy = state.get("session_policy", "close")
1120
+ inst._session_estimator = state.get("session_estimator", "persist")
1121
+ inst._backend = backend
1122
+ inst._on_bar = None
1123
+ inst._on_threshold_update = None
1124
+ inst._bars_emitted = 0
1125
+ inst._in_session = True
1126
+ inst._pending_bars = collections.deque()
1127
+ inst._last_watermark = None
1128
+ inst._last_timestamp = None
1129
+ inst._session_id = state.get("session_id")
1130
+ inst._last_session_end = state.get("last_session_end")
1131
+ # Volume semantics live in the accumulator state; restore them before
1132
+ # params validation so the identity check sees the right multiplier.
1133
+ acc_state = state.get("accumulator") or {}
1134
+ inst._accumulator.configure_volume(
1135
+ acc_state.get("multiplier", 1.0),
1136
+ acc_state.get("volume_type", "base"),
1137
+ )
1138
+ inst._initial_acc_state = copy.deepcopy(accumulator.get_state())
1139
+ inst._initial_est_state = copy.deepcopy(threshold_estimator.get_state())
1140
+ # Subclass params (e.g. min_run_length) are not passed through this
1141
+ # path — seed them from the state before validating params identity.
1142
+ inst._load_extra_state(state)
1143
+ inst.load_state(state, verify_identity=verify_identity)
1144
+ return inst
1145
+
1146
+ # ── internal helpers ────────────────────────────────────────────────
1147
+
1148
+ def _warmup_end_ts(self) -> int:
1149
+ """Timestamp at which duration warmup ends (requires a start ts)."""
1150
+ assert self._warmup_minutes is not None and self._warmup_start_ts is not None
1151
+ return self._warmup_start_ts + self._warmup_minutes * 60_000
1152
+
1153
+ def _past_warmup(self, close_ts: int) -> bool:
1154
+ """Return ``True`` if a bar closing at *close_ts* is past warmup."""
1155
+ if self._warmup_minutes:
1156
+ if self._warmup_start_ts is None:
1157
+ return True
1158
+ return close_ts >= self._warmup_end_ts()
1159
+ return self._bars_emitted > self._warmup_bars
1160
+
1161
+ def _should_return(self, bar: Bar) -> bool: # noqa: B027
1162
+ """Return ``True`` if *bar* should be returned to the caller.
1163
+
1164
+ Override in subclasses to filter bars (e.g. ``min_run_length`` for
1165
+ run bars). The default always returns ``True``.
1166
+ """
1167
+ return True
1168
+
1169
+ def _detect_skipped_gap(
1170
+ self, item: TickInfo | MinuteInfo, is_boundary: bool, session_id: str | None
1171
+ ) -> bool:
1172
+ """True when this in-session input belongs to a session after the last one.
1173
+
1174
+ A feed that only prints during trading hours never shows the engine an
1175
+ out-of-session tick, so the boundary flip in :meth:`update` never fires
1176
+ and one bar silently spans the weekend or a holiday. Compare against
1177
+ the calendar's next session open (or session identity when the calendar
1178
+ can name sessions) instead.
1179
+ """
1180
+ if is_boundary or not self._in_session or self._last_timestamp is None:
1181
+ return False
1182
+ if not self._calendar.has_sessions:
1183
+ return False
1184
+ if session_id is not None and self._session_id is not None:
1185
+ return session_id != self._session_id
1186
+ next_open = self._calendar.next_session_open(self._last_timestamp)
1187
+ # Guard against calendars whose "next open" is the input itself (no
1188
+ # real sessions): only a strictly later opening indicates a gap.
1189
+ return next_open > self._last_timestamp and item.timestamp >= next_open
1190
+
1191
+ def _skipped_gap_close_ts(self) -> int:
1192
+ """Close instant for a bar whose session ended between two inputs."""
1193
+ assert self._last_timestamp is not None
1194
+ session_end = self._calendar.session_end(self._last_timestamp)
1195
+ if session_end is not None:
1196
+ return session_end
1197
+ return self._calendar.next_session_open(self._last_timestamp)
1198
+
1199
+ def _on_session_boundary(self) -> None: # noqa: B027
1200
+ """Reset engine-level carried price state at a session boundary.
1201
+
1202
+ Accumulators reset their own metric state via
1203
+ :meth:`BaseAccumulator.on_session_boundary`; this hook covers state
1204
+ held by the constructor (the minute path's tick-rule derivation).
1205
+ """
1206
+
1207
+ def _session_transition_ts(self, item: TickInfo | MinuteInfo, is_boundary: bool) -> int:
1208
+ """The instant a session-boundary force-close logically happens.
1209
+
1210
+ Leaving a session: that session's close. Entering a session: the
1211
+ open. Falls back to the calendar's ``next_session_open`` when the
1212
+ calendar cannot describe the boundary.
1213
+ """
1214
+ ref = self._last_timestamp if self._last_timestamp is not None else item.timestamp
1215
+ if is_boundary:
1216
+ session_end = self._calendar.session_end(ref)
1217
+ if session_end is not None:
1218
+ return session_end
1219
+ return self._calendar.next_session_open(ref)
1220
+
1221
+ def _emit_bar(self, close_ts: int | None = None) -> None:
1222
+ """Force-close the current bar and queue it for return.
1223
+
1224
+ Does NOT call ``on_bar_close`` on the estimator — session-boundary
1225
+ closures are artificial and should not influence the adaptive
1226
+ threshold. Price-state resets are applied by the caller after this
1227
+ returns (see :meth:`update`).
1228
+
1229
+ ``close_ts`` overrides the accumulator's close timestamp; session
1230
+ force-closes pass the session boundary so a partial bar's
1231
+ ``close_ts`` is the instant its information became available.
1232
+ """
1233
+ threshold = self._threshold_estimator.current_threshold
1234
+ bar = self._accumulator.close(threshold)
1235
+ bar.partial = True
1236
+ if close_ts is not None:
1237
+ bar.close_ts = max(close_ts, bar.open_ts)
1238
+ self._bars_emitted += 1
1239
+
1240
+ if self._on_bar is not None:
1241
+ self._on_bar(bar)
1242
+
1243
+ # Warmup check + subclass filter (e.g. min_run_length)
1244
+ if self._past_warmup(bar.close_ts) and self._should_return(bar):
1245
+ self._pending_bars.append(bar)
1246
+
1247
+ def _discard_bar(self) -> None:
1248
+ """Drop the in-progress bar at a session boundary (``"discard"`` policy).
1249
+
1250
+ No bar is emitted, no callbacks fire, and ``bars_emitted`` does not
1251
+ advance; scalar accumulators are zeroed so no partial metric or
1252
+ overflow crosses the boundary.
1253
+ """
1254
+ self._accumulator.discard()