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,99 @@
1
+ """Minute-OHLCV → bars path.
2
+
3
+ Builds non-time bars from minute OHLCV candles instead of raw ticks. Same
4
+ output bar schema and the same orchestration engine as the tick path (batch,
5
+ streaming ``update()``, state save/resume, callbacks, warmup, watermark dedup,
6
+ strict ordering, calendars), plus a numba batch backend.
7
+
8
+ Design decisions are specified in the repo's ``SPEC.md`` under
9
+ "Minute-OHLCV → bars path — design". Key points:
10
+
11
+ * **Direct OHLCV accumulation** (not pseudo-tick resampling).
12
+ * **Whole-minute attribution** — the crossing minute is consumed whole; only
13
+ the scalar metric excess rolls into the next bar (renko has no overflow).
14
+ * **Minute tick rule** for signs — ``sign(close_i − close_{i−1})``, first
15
+ minute ``NaN`` (reused from :func:`flowbars.tick_rule.resolve_tick_signs`).
16
+ * ``num_ticks`` counts **minutes**; ``dollar_value``/``vwap`` use
17
+ ``close × volume`` as the notional proxy.
18
+
19
+ Bar types: volume, dollar, imbalance/run (volume, dollar), plus the
20
+ minute-only volatility, relative-range, renko-displacement, and hybrid types.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from flowbars.bars.minute.accumulators import (
26
+ MinuteDollarAccumulator,
27
+ MinuteHybridAccumulator,
28
+ MinuteImbalanceAccumulator,
29
+ MinuteRangeAccumulator,
30
+ MinuteRenkoAccumulator,
31
+ MinuteRunAccumulator,
32
+ MinuteVolatilityAccumulator,
33
+ MinuteVolumeAccumulator,
34
+ )
35
+ from flowbars.bars.minute.engine import BaseMinuteBarConstructor
36
+
37
+ # Registration side-effects: importing these modules registers every type.
38
+ from flowbars.bars.minute.information import (
39
+ MinuteImbalanceDollarBarConstructor,
40
+ MinuteImbalanceVolumeBarConstructor,
41
+ MinuteRunDollarBarConstructor,
42
+ MinuteRunVolumeBarConstructor,
43
+ compute_imbalance_dollar_bars_from_minutes,
44
+ compute_imbalance_volume_bars_from_minutes,
45
+ compute_run_dollar_bars_from_minutes,
46
+ compute_run_volume_bars_from_minutes,
47
+ )
48
+ from flowbars.bars.minute.registry import MinuteBarRegistry
49
+ from flowbars.bars.minute.standard import (
50
+ MinuteDollarBarConstructor,
51
+ MinuteVolumeBarConstructor,
52
+ compute_dollar_bars_from_minutes,
53
+ compute_volume_bars_from_minutes,
54
+ )
55
+ from flowbars.bars.minute.structural import (
56
+ MinuteHybridBarConstructor,
57
+ MinuteRangeBarConstructor,
58
+ MinuteRenkoBarConstructor,
59
+ MinuteVolatilityBarConstructor,
60
+ compute_hybrid_bars_from_minutes,
61
+ compute_range_bars_from_minutes,
62
+ compute_renko_bars_from_minutes,
63
+ compute_volatility_bars_from_minutes,
64
+ )
65
+ from flowbars.thresholds import MinuteCalibrationHelper
66
+
67
+ __all__ = [
68
+ "BaseMinuteBarConstructor",
69
+ "MinuteBarRegistry",
70
+ "MinuteCalibrationHelper",
71
+ "MinuteDollarAccumulator",
72
+ "MinuteDollarBarConstructor",
73
+ "MinuteHybridAccumulator",
74
+ "MinuteHybridBarConstructor",
75
+ "MinuteImbalanceAccumulator",
76
+ "MinuteImbalanceDollarBarConstructor",
77
+ "MinuteImbalanceVolumeBarConstructor",
78
+ "MinuteRangeAccumulator",
79
+ "MinuteRangeBarConstructor",
80
+ "MinuteRenkoAccumulator",
81
+ "MinuteRenkoBarConstructor",
82
+ "MinuteRunAccumulator",
83
+ "MinuteRunDollarBarConstructor",
84
+ "MinuteRunVolumeBarConstructor",
85
+ "MinuteVolatilityAccumulator",
86
+ "MinuteVolatilityBarConstructor",
87
+ "MinuteVolumeAccumulator",
88
+ "MinuteVolumeBarConstructor",
89
+ "compute_dollar_bars_from_minutes",
90
+ "compute_hybrid_bars_from_minutes",
91
+ "compute_imbalance_dollar_bars_from_minutes",
92
+ "compute_imbalance_volume_bars_from_minutes",
93
+ "compute_range_bars_from_minutes",
94
+ "compute_renko_bars_from_minutes",
95
+ "compute_run_dollar_bars_from_minutes",
96
+ "compute_run_volume_bars_from_minutes",
97
+ "compute_volatility_bars_from_minutes",
98
+ "compute_volume_bars_from_minutes",
99
+ ]
@@ -0,0 +1,303 @@
1
+ """Accumulators for the minute-OHLCV → bars path.
2
+
3
+ The first four mirror the tick accumulators but ingest whole-minute candles.
4
+ The last four implement the minute-only structural bar types (volatility,
5
+ relative range, renko displacement, hybrid). All state values are
6
+ JSON-serializable; prices carried across bars use ``None`` (never NaN).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+ from flowbars.bars.accumulators import (
17
+ BaseAccumulator,
18
+ DollarAccumulator,
19
+ ImbalanceAccumulator,
20
+ RunAccumulator,
21
+ VolumeAccumulator,
22
+ _minute_metric,
23
+ )
24
+ from flowbars.core import Bar, MinuteInfo
25
+
26
+
27
+ class MinuteVolumeAccumulator(VolumeAccumulator):
28
+ """Volume accumulator for minute input (metric = minute volume)."""
29
+
30
+ def add_minute(self, minute: MinuteInfo) -> None:
31
+ super().add_minute(minute)
32
+ self._cum_volume += minute.volume
33
+
34
+
35
+ class MinuteDollarAccumulator(DollarAccumulator):
36
+ """Dollar accumulator for minute input (metric = close × volume)."""
37
+
38
+ def add_minute(self, minute: MinuteInfo) -> None:
39
+ super().add_minute(minute)
40
+ self._cum_dollar += minute.close * minute.volume * self._multiplier
41
+
42
+
43
+ class MinuteImbalanceAccumulator(ImbalanceAccumulator):
44
+ """Signed-imbalance accumulator for minute input (metric = volume or dollar)."""
45
+
46
+ def add_minute(self, minute: MinuteInfo) -> None:
47
+ super().add_minute(minute)
48
+ if minute.side is not None and not math.isnan(minute.side):
49
+ self._signed_imbalance += minute.side * _minute_metric(
50
+ minute, self._metric, self._multiplier
51
+ )
52
+
53
+
54
+ class MinuteRunAccumulator(RunAccumulator):
55
+ """Same-sign run accumulator for minute input (metric = volume or dollar)."""
56
+
57
+ def add_minute(self, minute: MinuteInfo) -> None:
58
+ super().add_minute(minute)
59
+ side = minute.side if minute.side is not None else np.nan
60
+ self._accumulate_run(side, _minute_metric(minute, self._metric, self._multiplier))
61
+
62
+
63
+ # ═══════════════════════════════════════════════════════════════════════════════
64
+ # Structural (minute-only) accumulators
65
+ # ═══════════════════════════════════════════════════════════════════════════════
66
+
67
+
68
+ class MinuteVolatilityAccumulator(BaseAccumulator):
69
+ """Closes when cumulative absolute close-to-close returns cross a threshold.
70
+
71
+ Per-minute metric: ``|close − prev_close| / prev_close`` (0 for the first
72
+ minute of the stream or when ``prev_close == 0``). ``prev_close`` carries
73
+ across bars; a session boundary resets it so an overnight gap contributes
74
+ nothing. Scalar overflow rolls into the next bar.
75
+ """
76
+
77
+ def __init__(self, bar_type: str = "volatility") -> None:
78
+ super().__init__(bar_type)
79
+ self._cum_metric = 0.0
80
+ self._prev_close: float | None = None
81
+
82
+ def add_minute(self, minute: MinuteInfo) -> None:
83
+ super().add_minute(minute)
84
+ if self._prev_close is None or self._prev_close == 0.0:
85
+ metric = 0.0
86
+ else:
87
+ metric = abs(minute.close - self._prev_close) / self._prev_close
88
+ self._cum_metric += metric
89
+ self._prev_close = minute.close
90
+
91
+ def should_close(self, threshold: float) -> bool:
92
+ return self._cum_metric >= threshold
93
+
94
+ def close(self, threshold: float) -> Bar:
95
+ bar = self._emit_and_reset()
96
+ self._cum_metric = max(0.0, self._cum_metric - threshold)
97
+ return bar
98
+
99
+ def discard(self) -> None:
100
+ super().discard()
101
+ self._cum_metric = 0.0
102
+
103
+ def get_close_stats(self) -> tuple[float, float]:
104
+ return (self._cum_metric, 0.0)
105
+
106
+ def on_session_boundary(self) -> None:
107
+ self._prev_close = None
108
+
109
+ def get_state(self) -> dict[str, Any]:
110
+ state = super().get_state()
111
+ state["cum_metric"] = self._cum_metric
112
+ state["prev_close"] = self._prev_close
113
+ return state
114
+
115
+ def load_state(self, state: dict[str, Any]) -> None:
116
+ super().load_state(state)
117
+ self._cum_metric = state.get("cum_metric", 0.0)
118
+ self._prev_close = state.get("prev_close")
119
+
120
+
121
+ class MinuteRangeAccumulator(BaseAccumulator):
122
+ """Closes when cumulative relative range crosses a threshold.
123
+
124
+ Per-minute metric: ``(high − low) / close`` (0 when ``close == 0``).
125
+ Scalar overflow rolls into the next bar.
126
+ """
127
+
128
+ def __init__(self, bar_type: str = "range") -> None:
129
+ super().__init__(bar_type)
130
+ self._cum_metric = 0.0
131
+
132
+ def add_minute(self, minute: MinuteInfo) -> None:
133
+ super().add_minute(minute)
134
+ if minute.close == 0.0:
135
+ metric = 0.0
136
+ else:
137
+ metric = (minute.high - minute.low) / minute.close
138
+ self._cum_metric += metric
139
+
140
+ def should_close(self, threshold: float) -> bool:
141
+ return self._cum_metric >= threshold
142
+
143
+ def close(self, threshold: float) -> Bar:
144
+ bar = self._emit_and_reset()
145
+ self._cum_metric = max(0.0, self._cum_metric - threshold)
146
+ return bar
147
+
148
+ def discard(self) -> None:
149
+ super().discard()
150
+ self._cum_metric = 0.0
151
+
152
+ def get_close_stats(self) -> tuple[float, float]:
153
+ return (self._cum_metric, 0.0)
154
+
155
+ def get_state(self) -> dict[str, Any]:
156
+ state = super().get_state()
157
+ state["cum_metric"] = self._cum_metric
158
+ return state
159
+
160
+ def load_state(self, state: dict[str, Any]) -> None:
161
+ super().load_state(state)
162
+ self._cum_metric = state.get("cum_metric", 0.0)
163
+
164
+
165
+ class MinuteRenkoAccumulator(BaseAccumulator):
166
+ """Closes when price displacement from the bar's anchor crosses a threshold.
167
+
168
+ The anchor is the **open of the bar's first minute**, and that first
169
+ minute is evaluated against it (so a one-minute renko bar is possible).
170
+ Per-minute displacement: ``|close − anchor| / anchor`` (0 when the anchor
171
+ is non-positive). Displacement is not additive, so there is **no
172
+ overflow** — the anchor resets on close.
173
+ """
174
+
175
+ def __init__(self, bar_type: str = "renko") -> None:
176
+ super().__init__(bar_type)
177
+ self._anchor: float | None = None
178
+ self._displacement = 0.0
179
+
180
+ def add_minute(self, minute: MinuteInfo) -> None:
181
+ super().add_minute(minute)
182
+ if self._anchor is None:
183
+ self._anchor = minute.open
184
+ if self._anchor <= 0.0:
185
+ self._displacement = 0.0
186
+ else:
187
+ self._displacement = abs(minute.close - self._anchor) / self._anchor
188
+
189
+ def should_close(self, threshold: float) -> bool:
190
+ return self._displacement >= threshold
191
+
192
+ def close(self, threshold: float) -> Bar:
193
+ bar = self._emit_and_reset()
194
+ self._anchor = None
195
+ self._displacement = 0.0
196
+ return bar
197
+
198
+ def discard(self) -> None:
199
+ super().discard()
200
+ self._anchor = None
201
+ self._displacement = 0.0
202
+
203
+ def get_close_stats(self) -> tuple[float, float]:
204
+ return (self._displacement, 0.0)
205
+
206
+ def get_state(self) -> dict[str, Any]:
207
+ state = super().get_state()
208
+ state["anchor"] = self._anchor
209
+ state["displacement"] = self._displacement
210
+ return state
211
+
212
+ def load_state(self, state: dict[str, Any]) -> None:
213
+ super().load_state(state)
214
+ self._anchor = state.get("anchor")
215
+ self._displacement = state.get("displacement", 0.0)
216
+
217
+
218
+ class MinuteHybridAccumulator(BaseAccumulator):
219
+ """Closes when **both** cumulative notional and cumulative volatility targets are met.
220
+
221
+ Two running metrics: ``Σ close × volume`` and the volatility metric
222
+ ``Σ |close − prev_close| / prev_close`` (0 for the first minute or when
223
+ ``prev_close == 0``). Both thresholds live on the accumulator. At close,
224
+ both scalar excesses roll into the next bar (the scalar-metric rule
225
+ applied twice). A session boundary resets ``prev_close``.
226
+ """
227
+
228
+ def __init__(
229
+ self,
230
+ bar_type: str = "hybrid",
231
+ dollar_threshold: float = 0.0,
232
+ volatility_threshold: float = 0.0,
233
+ ) -> None:
234
+ super().__init__(bar_type)
235
+ if dollar_threshold < 0:
236
+ raise ValueError(f"dollar_threshold must be non-negative, got {dollar_threshold}")
237
+ if volatility_threshold < 0:
238
+ raise ValueError(
239
+ f"volatility_threshold must be non-negative, got {volatility_threshold}"
240
+ )
241
+ self._dollar_threshold = dollar_threshold
242
+ self._volatility_threshold = volatility_threshold
243
+ self._cum_dollar = 0.0
244
+ self._cum_vol = 0.0
245
+ self._prev_close: float | None = None
246
+
247
+ def add_minute(self, minute: MinuteInfo) -> None:
248
+ super().add_minute(minute)
249
+ self._cum_dollar += minute.close * minute.volume * self._multiplier
250
+ if self._prev_close is None or self._prev_close == 0.0:
251
+ metric = 0.0
252
+ else:
253
+ metric = abs(minute.close - self._prev_close) / self._prev_close
254
+ self._cum_vol += metric
255
+ self._prev_close = minute.close
256
+
257
+ def should_close(self, threshold: float) -> bool:
258
+ # ``threshold`` (the engine's static threshold) is the dollar target
259
+ # for API symmetry; both targets are read from the accumulator.
260
+ return (
261
+ self._cum_dollar >= self._dollar_threshold
262
+ and self._cum_vol >= self._volatility_threshold
263
+ )
264
+
265
+ def close(self, threshold: float) -> Bar:
266
+ bar = self._emit_and_reset()
267
+ self._cum_dollar = max(0.0, self._cum_dollar - self._dollar_threshold)
268
+ self._cum_vol = max(0.0, self._cum_vol - self._volatility_threshold)
269
+ return bar
270
+
271
+ def discard(self) -> None:
272
+ super().discard()
273
+ self._cum_dollar = 0.0
274
+ self._cum_vol = 0.0
275
+
276
+ def get_close_stats(self) -> tuple[float, float]:
277
+ return (self._cum_dollar, 0.0)
278
+
279
+ def on_session_boundary(self) -> None:
280
+ self._prev_close = None
281
+
282
+ def get_params(self) -> dict[str, Any]:
283
+ params = super().get_params()
284
+ params["dollar_threshold"] = self._dollar_threshold
285
+ params["volatility_threshold"] = self._volatility_threshold
286
+ return params
287
+
288
+ def get_state(self) -> dict[str, Any]:
289
+ state = super().get_state()
290
+ state["cum_dollar"] = self._cum_dollar
291
+ state["cum_vol"] = self._cum_vol
292
+ state["prev_close"] = self._prev_close
293
+ state["dollar_threshold"] = self._dollar_threshold
294
+ state["volatility_threshold"] = self._volatility_threshold
295
+ return state
296
+
297
+ def load_state(self, state: dict[str, Any]) -> None:
298
+ super().load_state(state)
299
+ self._cum_dollar = state.get("cum_dollar", 0.0)
300
+ self._cum_vol = state.get("cum_vol", 0.0)
301
+ self._prev_close = state.get("prev_close")
302
+ self._dollar_threshold = state.get("dollar_threshold", 0.0)
303
+ self._volatility_threshold = state.get("volatility_threshold", 0.0)
@@ -0,0 +1,244 @@
1
+ """Shared engine for the minute-OHLCV → bars path.
2
+
3
+ The minute path reuses :class:`~flowbars.bars.constructor.BaseBarConstructor`
4
+ for all orchestration (streaming ``update``, batch, warmup, watermark dedup,
5
+ strict ordering, callbacks, session calendars, and state save/resume). This
6
+ module supplies the minute-specific input hooks:
7
+
8
+ * :meth:`BaseMinuteBarConstructor._extract_batch_arrays` — pulls
9
+ ``(timestamps, opens, highs, lows, closes, volumes, signs)`` from a
10
+ :class:`~flowbars.schema.MinuteSchemaMapping` DataFrame and derives minute
11
+ signs with the tick rule applied to closes.
12
+ * :meth:`BaseMinuteBarConstructor._prepare_input` — derives the minute tick
13
+ rule for streaming inputs whose ``side`` is ``None``.
14
+ * :meth:`BaseMinuteBarConstructor._ingest_input` — calls
15
+ ``accumulator.add_minute``.
16
+
17
+ Design decisions are specified in the repo's ``SPEC.md`` under
18
+ "Minute-OHLCV → bars path — design". Key points: direct OHLCV accumulation,
19
+ whole-minute attribution (only the scalar metric excess rolls), the minute
20
+ tick rule ``sign(close_i − close_{i−1})`` with first-minute ``NaN``,
21
+ ``num_ticks`` counts **minutes**, and ``dollar_value``/``vwap`` use
22
+ ``close × volume`` as the notional proxy.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import math
28
+ from dataclasses import replace
29
+ from typing import Any, ClassVar, Literal
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+
34
+ from flowbars.bars.constructor import BaseBarConstructor
35
+ from flowbars.core import MinuteInfo, TickInfo
36
+ from flowbars.schema import MinuteSchemaMapping
37
+ from flowbars.thresholds import EWMAThresholdEstimator, ThresholdEstimator
38
+ from flowbars.tick_rule import resolve_tick_signs
39
+
40
+ _DEFAULT_MINUTE_SCHEMA = MinuteSchemaMapping(
41
+ {
42
+ "timestamp": "timestamp",
43
+ "open": "open",
44
+ "high": "high",
45
+ "low": "low",
46
+ "close": "close",
47
+ "volume": "volume",
48
+ }
49
+ )
50
+
51
+
52
+ class BaseMinuteBarConstructor(BaseBarConstructor):
53
+ """Shared engine for bar types built from minute OHLCV candles."""
54
+
55
+ input_kind: ClassVar[str] = "minute"
56
+
57
+ # Session-end behavior for the in-progress bar. Minute bars opt in to
58
+ # the full policy set; tick bars support force-close only.
59
+ session_policies: ClassVar[tuple[str, ...]] = ("close", "discard", "carry")
60
+
61
+ def __init__(
62
+ self,
63
+ accumulator: Any,
64
+ threshold_estimator: ThresholdEstimator,
65
+ calendar: Any = None,
66
+ schema: MinuteSchemaMapping | None = None,
67
+ watermark: str | None = "timestamp",
68
+ stream_id: str = "default",
69
+ strict_ordering: bool = False,
70
+ warmup_bars: int = 0,
71
+ warmup_minutes: int | None = None,
72
+ backend: Any = "python",
73
+ on_bar: Any = None,
74
+ on_threshold_update: Any = None,
75
+ multiplier: float = 1.0,
76
+ volume_type: str = "base",
77
+ session_policy: Literal["close", "carry", "discard"] = "close",
78
+ session_estimator: Literal["persist", "reset"] = "persist",
79
+ ) -> None:
80
+ super().__init__(
81
+ accumulator=accumulator,
82
+ threshold_estimator=threshold_estimator,
83
+ calendar=calendar,
84
+ schema=schema,
85
+ watermark=watermark,
86
+ stream_id=stream_id,
87
+ strict_ordering=strict_ordering,
88
+ warmup_bars=warmup_bars,
89
+ warmup_minutes=warmup_minutes,
90
+ backend=backend,
91
+ on_bar=on_bar,
92
+ on_threshold_update=on_threshold_update,
93
+ multiplier=multiplier,
94
+ volume_type=volume_type,
95
+ session_policy=session_policy,
96
+ session_estimator=session_estimator,
97
+ )
98
+ # Previous minute close/derived sign for the streaming tick rule.
99
+ self._prev_close: float | None = None
100
+ self._prev_sign: float = np.nan
101
+
102
+ # ── input hooks ─────────────────────────────────────────────────────
103
+
104
+ def _default_schema(self) -> MinuteSchemaMapping:
105
+ return _DEFAULT_MINUTE_SCHEMA
106
+
107
+ def _extract_batch_arrays(self, df: pd.DataFrame) -> tuple[np.ndarray, ...]:
108
+ assert isinstance(self._schema, MinuteSchemaMapping)
109
+ timestamps, opens, highs, lows, closes, volumes, sides = self._schema.extract_arrays(df)
110
+ signs = resolve_tick_signs(closes, sides)
111
+ return (timestamps, opens, highs, lows, closes, volumes, signs)
112
+
113
+ def _make_input(
114
+ self, arrays: tuple[np.ndarray, ...], i: int, watermark: np.ndarray | None
115
+ ) -> MinuteInfo:
116
+ timestamps, opens, highs, lows, closes, volumes, signs = arrays
117
+ return MinuteInfo(
118
+ timestamp=int(timestamps[i]),
119
+ open=float(opens[i]),
120
+ high=float(highs[i]),
121
+ low=float(lows[i]),
122
+ close=float(closes[i]),
123
+ volume=float(volumes[i]),
124
+ side=float(signs[i]),
125
+ watermark=int(watermark[i]) if watermark is not None else None,
126
+ )
127
+
128
+ def _prepare_input(self, item: TickInfo | MinuteInfo) -> TickInfo | MinuteInfo:
129
+ """Derive the minute tick rule when no side was supplied."""
130
+ if not isinstance(item, MinuteInfo):
131
+ raise TypeError(f"Minute constructors require MinuteInfo, got {type(item).__name__}")
132
+
133
+ close = item.close
134
+ if item.side is None:
135
+ if self._prev_close is None:
136
+ sign = np.nan
137
+ else:
138
+ diff = close - self._prev_close
139
+ if diff > 0.0:
140
+ sign = 1.0
141
+ elif diff < 0.0:
142
+ sign = -1.0
143
+ else:
144
+ sign = self._prev_sign
145
+ if not math.isnan(sign):
146
+ item = replace(item, side=sign)
147
+ self._prev_sign = sign
148
+ else:
149
+ self._prev_sign = item.side
150
+
151
+ self._prev_close = close
152
+ return item
153
+
154
+ def _ingest_input(self, item: TickInfo | MinuteInfo) -> None:
155
+ assert isinstance(item, MinuteInfo)
156
+ self._accumulator.add_minute(item)
157
+
158
+ # ── state ───────────────────────────────────────────────────────────
159
+
160
+ def _extra_state(self) -> dict[str, Any]:
161
+ prev_sign = self._prev_sign
162
+ return {
163
+ "prev_close": self._prev_close,
164
+ "prev_sign": None if math.isnan(prev_sign) else prev_sign,
165
+ }
166
+
167
+ def _load_extra_state(self, state: dict[str, Any]) -> None:
168
+ self._prev_close = state.get("prev_close")
169
+ prev_sign = state.get("prev_sign")
170
+ self._prev_sign = np.nan if prev_sign is None else float(prev_sign)
171
+
172
+ def _on_session_boundary(self) -> None:
173
+ """A session gap is not a tick-rule move: clear derived-sign state."""
174
+ self._prev_close = None
175
+ self._prev_sign = np.nan
176
+
177
+ # ── numba dispatch ──────────────────────────────────────────────────
178
+
179
+ def _numba_config(self) -> dict[str, float]:
180
+ estimator = self._threshold_estimator
181
+ config: dict[str, float] = {"multiplier": self.multiplier}
182
+ if isinstance(estimator, EWMAThresholdEstimator):
183
+ config["alpha"] = estimator.alpha
184
+ config["initial_ewa_t"] = estimator.ewa_t
185
+ config["initial_ewa_proportion"] = estimator.ewa_proportion
186
+ else:
187
+ config["threshold"] = estimator.current_threshold
188
+ volatility_threshold = getattr(self._accumulator, "_volatility_threshold", None)
189
+ if volatility_threshold is not None:
190
+ config["volatility_threshold"] = float(volatility_threshold)
191
+ return config
192
+
193
+ def _batch_numba(
194
+ self,
195
+ arrays: tuple[np.ndarray, ...],
196
+ watermark: np.ndarray | None = None,
197
+ ) -> pd.DataFrame:
198
+ """Run the batch loop through the minute numba kernels.
199
+
200
+ Falls back to the Python engine when numba is unavailable, the
201
+ calendar produces session boundaries, or the constructor is not
202
+ pristine. The kernels are stateless — they do not advance the Python
203
+ accumulator/estimator — so incremental batches must use the Python
204
+ backend, which is what the non-pristine fallback does.
205
+ """
206
+ import warnings
207
+
208
+ from flowbars.bars.numba_backend import _NUMBA_AVAILABLE, _get_compilation_warning
209
+
210
+ if not _NUMBA_AVAILABLE:
211
+ _get_compilation_warning()
212
+ return self._batch_python(arrays, watermark)
213
+
214
+ if self._calendar.has_sessions:
215
+ warnings.warn(
216
+ "This calendar produces session boundaries, which the numba "
217
+ "backend does not support. Falling back to the Python path.",
218
+ stacklevel=2,
219
+ )
220
+ return self._batch_python(arrays, watermark)
221
+
222
+ if not self._is_pristine():
223
+ warnings.warn(
224
+ "The numba backend requires a pristine constructor (no prior "
225
+ "update()/batch() calls and no restored state). Falling back "
226
+ "to the Python path.",
227
+ stacklevel=2,
228
+ )
229
+ return self._batch_python(arrays, watermark)
230
+
231
+ from flowbars.bars.minute.numba import numba_batch_minute
232
+
233
+ bar_type = self._accumulator._bar_type
234
+ try:
235
+ bar_data, bt = numba_batch_minute(bar_type, arrays, self._numba_config())
236
+ except Exception:
237
+ warnings.warn(
238
+ f"numba compilation/execution failed for {bar_type}. "
239
+ "Falling back to the Python path.",
240
+ stacklevel=2,
241
+ )
242
+ return self._batch_python(arrays, watermark)
243
+
244
+ return self._finalize_numba_result(bar_data, bt)