flowbars 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flowbars/__init__.py +163 -0
- flowbars/_version.py +8 -0
- flowbars/adapters/__init__.py +29 -0
- flowbars/adapters/polars.py +1115 -0
- flowbars/bars/__init__.py +3 -0
- flowbars/bars/accumulators.py +731 -0
- flowbars/bars/constructor.py +1254 -0
- flowbars/bars/information/__init__.py +41 -0
- flowbars/bars/information/imbalance_dollar_bars.py +200 -0
- flowbars/bars/information/imbalance_tick_bars.py +206 -0
- flowbars/bars/information/imbalance_volume_bars.py +200 -0
- flowbars/bars/information/run_dollar_bars.py +225 -0
- flowbars/bars/information/run_tick_bars.py +231 -0
- flowbars/bars/information/run_volume_bars.py +225 -0
- flowbars/bars/minute/__init__.py +99 -0
- flowbars/bars/minute/accumulators.py +303 -0
- flowbars/bars/minute/engine.py +244 -0
- flowbars/bars/minute/information.py +622 -0
- flowbars/bars/minute/numba.py +1043 -0
- flowbars/bars/minute/registry.py +22 -0
- flowbars/bars/minute/standard.py +261 -0
- flowbars/bars/minute/structural.py +474 -0
- flowbars/bars/numba_backend.py +1227 -0
- flowbars/bars/output.py +106 -0
- flowbars/bars/registry.py +47 -0
- flowbars/bars/spec.py +367 -0
- flowbars/bars/standard/__init__.py +17 -0
- flowbars/bars/standard/dollar_bars.py +168 -0
- flowbars/bars/standard/tick_bars.py +166 -0
- flowbars/bars/standard/time_bars.py +192 -0
- flowbars/bars/standard/volume_bars.py +168 -0
- flowbars/benchmarks.py +420 -0
- flowbars/calendars.py +368 -0
- flowbars/core.py +109 -0
- flowbars/py.typed +0 -0
- flowbars/quality.py +393 -0
- flowbars/sample_data/__init__.py +28 -0
- flowbars/sample_data/tick_data.csv +501 -0
- flowbars/schema.py +476 -0
- flowbars/thresholds.py +1167 -0
- flowbars/tick_rule.py +89 -0
- flowbars-0.1.0.dist-info/METADATA +650 -0
- flowbars-0.1.0.dist-info/RECORD +46 -0
- flowbars-0.1.0.dist-info/WHEEL +5 -0
- flowbars-0.1.0.dist-info/licenses/LICENSE +21 -0
- flowbars-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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()
|