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,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)
|