factorflow 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.
- factorflow/__init__.py +59 -0
- factorflow/bars.py +246 -0
- factorflow/batch.py +221 -0
- factorflow/events.py +610 -0
- factorflow/graph.py +177 -0
- factorflow/grid.py +298 -0
- factorflow/kernels.py +434 -0
- factorflow/live.py +554 -0
- factorflow/panel.py +181 -0
- factorflow/py.typed +0 -0
- factorflow/sim/__init__.py +22 -0
- factorflow/sim/__main__.py +210 -0
- factorflow/sim/bench.py +130 -0
- factorflow/sim/market.py +669 -0
- factorflow/sim/pipelines.py +147 -0
- factorflow/sim/replay.py +385 -0
- factorflow/sim/validate.py +464 -0
- factorflow/terms.py +1921 -0
- factorflow-0.1.0.dist-info/METADATA +368 -0
- factorflow-0.1.0.dist-info/RECORD +23 -0
- factorflow-0.1.0.dist-info/WHEEL +4 -0
- factorflow-0.1.0.dist-info/licenses/LICENSE +202 -0
- factorflow-0.1.0.dist-info/licenses/NOTICE +14 -0
factorflow/events.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Information-driven bars (López de Prado, *AFML* ch. 2): tick, volume and dollar bars.
|
|
2
|
+
|
|
3
|
+
An event bar closes when the running *measure* reaches a threshold:
|
|
4
|
+
|
|
5
|
+
========= ===========================
|
|
6
|
+
kind measure added per trade
|
|
7
|
+
========= ===========================
|
|
8
|
+
``tick`` 1
|
|
9
|
+
``volume`` size
|
|
10
|
+
``dollar`` price × size
|
|
11
|
+
========= ===========================
|
|
12
|
+
|
|
13
|
+
Event bars are **per asset** (each asset has its own clock). Use them for time-series
|
|
14
|
+
factors through a per-asset pipeline (:class:`EventPipeline`), and use :func:`sample_asof` /
|
|
15
|
+
:class:`AsofSampler` to put event-bar values onto a common clock grid, as of the last
|
|
16
|
+
*sealed* event bar, before taking cross-sectional ranks.
|
|
17
|
+
|
|
18
|
+
Overshoot (the trade that crosses the threshold): ``"close"`` (default) puts the whole
|
|
19
|
+
trade in the bar it completes, so bars hold whole trades and ``measure >= threshold``;
|
|
20
|
+
``"split"`` cuts the trade so each bar holds exactly ``threshold`` and the remainder starts
|
|
21
|
+
the next bar(s). A very large trade can then fill several bars that share its timestamp.
|
|
22
|
+
|
|
23
|
+
Thresholds are :class:`Fixed` or :class:`AdaptiveDaily`. The adaptive threshold for a day is
|
|
24
|
+
``mean(measure per day over the previous lookback days) / target_bars_per_day``, using only
|
|
25
|
+
days that are complete before the day's first trade (point-in-time, no lookahead). A new
|
|
26
|
+
threshold takes effect at the first trade of a new day; the open bar is not reset at
|
|
27
|
+
day boundaries.
|
|
28
|
+
|
|
29
|
+
Batch and incremental construction run the same state machine (:class:`EventBarBuilder`),
|
|
30
|
+
so batch output equals incremental output by construction; the tests check it anyway on
|
|
31
|
+
random streams with random chunking.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from collections import deque
|
|
37
|
+
from collections.abc import Callable, Iterable
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
from factorflow.graph import Graph, Pipeline
|
|
43
|
+
from factorflow.grid import NS
|
|
44
|
+
from factorflow.live import LiveEngine, Snapshot
|
|
45
|
+
from factorflow.panel import Panel
|
|
46
|
+
|
|
47
|
+
EVENT_FIELDS = ("start", "end", "open", "high", "low", "close", "volume", "dollar", "n_ticks", "threshold")
|
|
48
|
+
KINDS = ("tick", "volume", "dollar")
|
|
49
|
+
OVERSHOOT = ("close", "split")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def utc_day(ts: int) -> int:
|
|
53
|
+
"""Return the UTC day index of a timestamp.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
ts : int
|
|
58
|
+
Nanoseconds since the Unix epoch, UTC.
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
int
|
|
63
|
+
``ts // (86400 * 1e9)``. The default day key for adaptive thresholds.
|
|
64
|
+
"""
|
|
65
|
+
return int(ts) // (86400 * NS)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Fixed:
|
|
70
|
+
"""A threshold that does not change.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
value : float
|
|
75
|
+
The threshold, in the bar's measure (ticks, size, or price times size). Must be positive.
|
|
76
|
+
|
|
77
|
+
Raises
|
|
78
|
+
------
|
|
79
|
+
ValueError
|
|
80
|
+
If ``value`` is not positive. Raised from ``__post_init__``.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
value: float
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
"""Reject a non-positive threshold.
|
|
87
|
+
|
|
88
|
+
Raises
|
|
89
|
+
------
|
|
90
|
+
ValueError
|
|
91
|
+
If ``value`` is not greater than 0.
|
|
92
|
+
"""
|
|
93
|
+
if not self.value > 0:
|
|
94
|
+
raise ValueError("threshold must be > 0")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class AdaptiveDaily:
|
|
99
|
+
"""Target ``bars_per_day`` from the trailing mean daily measure (prior complete days only)."""
|
|
100
|
+
|
|
101
|
+
bars_per_day: float
|
|
102
|
+
lookback_days: int = 20
|
|
103
|
+
initial: float | None = None # threshold before any complete day exists
|
|
104
|
+
min_days: int = 1 # complete days required before adapting (else ``initial``)
|
|
105
|
+
floor: float = 0.0 # lower bound on the threshold
|
|
106
|
+
|
|
107
|
+
def __post_init__(self) -> None:
|
|
108
|
+
"""Reject a non-positive target or a non-positive lookback.
|
|
109
|
+
|
|
110
|
+
Raises
|
|
111
|
+
------
|
|
112
|
+
ValueError
|
|
113
|
+
If ``bars_per_day``, ``lookback_days``, or ``min_days`` is not positive.
|
|
114
|
+
"""
|
|
115
|
+
if self.bars_per_day <= 0 or self.lookback_days < 1 or self.min_days < 1:
|
|
116
|
+
raise ValueError("bad AdaptiveDaily parameters")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
Threshold = Fixed | AdaptiveDaily
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class EventBar:
|
|
124
|
+
"""One sealed or open information-driven bar for a single asset.
|
|
125
|
+
|
|
126
|
+
Notes
|
|
127
|
+
-----
|
|
128
|
+
Prices are in the caller's price units. ``volume`` is the sum of trade sizes.
|
|
129
|
+
``dollar`` is the sum of price times size. ``n_ticks`` is the trade count.
|
|
130
|
+
``threshold`` is the threshold that was in force when the bar closed, or the
|
|
131
|
+
current threshold if the bar is still open. Times are nanoseconds UTC.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
start: int
|
|
135
|
+
end: int
|
|
136
|
+
open: float
|
|
137
|
+
high: float
|
|
138
|
+
low: float
|
|
139
|
+
close: float
|
|
140
|
+
volume: float
|
|
141
|
+
dollar: float
|
|
142
|
+
n_ticks: int
|
|
143
|
+
threshold: float
|
|
144
|
+
|
|
145
|
+
def as_tuple(self) -> tuple:
|
|
146
|
+
"""Return the fields in ``EVENT_FIELDS`` order.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
tuple
|
|
151
|
+
start, end, open, high, low, close, volume, dollar, n_ticks, threshold.
|
|
152
|
+
"""
|
|
153
|
+
return tuple(getattr(self, k) for k in EVENT_FIELDS)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class EventBarBuilder:
|
|
157
|
+
"""Incremental per-asset event-bar builder. ``update`` returns the bars it sealed."""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
kind: str = "volume",
|
|
162
|
+
threshold: Threshold | float = 1000.0,
|
|
163
|
+
overshoot: str = "close",
|
|
164
|
+
day_key: Callable[[int], int] = utc_day,
|
|
165
|
+
):
|
|
166
|
+
"""Start an empty builder.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
kind : str, optional
|
|
171
|
+
``tick``, ``volume``, or ``dollar``. Default is ``volume``.
|
|
172
|
+
threshold : Fixed, AdaptiveDaily, or float, optional
|
|
173
|
+
A float becomes ``Fixed(threshold)``. Default is 1000.
|
|
174
|
+
overshoot : str, optional
|
|
175
|
+
``close`` (default) keeps the whole crossing trade in the bar it
|
|
176
|
+
finishes. ``split`` cuts that trade so each bar holds exactly the
|
|
177
|
+
threshold. Tick bars cannot split.
|
|
178
|
+
day_key : callable, optional
|
|
179
|
+
``ts -> day id`` for adaptive thresholds. Default is :func:`utc_day`.
|
|
180
|
+
|
|
181
|
+
Raises
|
|
182
|
+
------
|
|
183
|
+
ValueError
|
|
184
|
+
If ``kind`` or ``overshoot`` is unknown, or an adaptive rule has no ``initial`` threshold.
|
|
185
|
+
"""
|
|
186
|
+
if kind not in KINDS:
|
|
187
|
+
raise ValueError(f"kind must be one of {KINDS}")
|
|
188
|
+
if overshoot not in OVERSHOOT:
|
|
189
|
+
raise ValueError(f"overshoot must be one of {OVERSHOOT}")
|
|
190
|
+
if kind == "tick" and overshoot == "split":
|
|
191
|
+
overshoot = "close" # a tick is indivisible; both policies coincide
|
|
192
|
+
self.kind, self.overshoot, self.day_key = kind, overshoot, day_key
|
|
193
|
+
self.rule: Threshold = Fixed(float(threshold)) if isinstance(threshold, (int, float)) else threshold
|
|
194
|
+
if isinstance(self.rule, AdaptiveDaily) and self.rule.initial is None:
|
|
195
|
+
raise ValueError("AdaptiveDaily needs an initial threshold for the first days")
|
|
196
|
+
self._days: deque[float] = deque(maxlen=getattr(self.rule, "lookback_days", 1))
|
|
197
|
+
self._day: int | None = None
|
|
198
|
+
self._day_total = 0.0
|
|
199
|
+
self.threshold = self._threshold()
|
|
200
|
+
self._bar: EventBar | None = None
|
|
201
|
+
self._cum = 0.0
|
|
202
|
+
self.n_sealed = 0
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------- threshold
|
|
205
|
+
def _threshold(self) -> float:
|
|
206
|
+
r = self.rule
|
|
207
|
+
if isinstance(r, Fixed):
|
|
208
|
+
return r.value
|
|
209
|
+
if len(self._days) < r.min_days:
|
|
210
|
+
return float(r.initial) # type: ignore[arg-type]
|
|
211
|
+
avg = sum(self._days) / len(self._days)
|
|
212
|
+
return max(avg / r.bars_per_day, r.floor) if avg > 0 else float(r.initial) # type: ignore[arg-type]
|
|
213
|
+
|
|
214
|
+
def _roll_day(self, ts: int) -> None:
|
|
215
|
+
d = self.day_key(ts)
|
|
216
|
+
if d == self._day:
|
|
217
|
+
return
|
|
218
|
+
if self._day is not None:
|
|
219
|
+
# only days that had trades count (weekends/holidays are not zero-volume days)
|
|
220
|
+
self._days.append(self._day_total)
|
|
221
|
+
self._day, self._day_total = d, 0.0
|
|
222
|
+
self.threshold = self._threshold()
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------- bars
|
|
225
|
+
def _add(self, ts: int, price: float, size: float) -> None:
|
|
226
|
+
b = self._bar
|
|
227
|
+
if b is None:
|
|
228
|
+
self._bar = EventBar(ts, ts, price, price, price, price, size, price * size, 1, self.threshold)
|
|
229
|
+
return
|
|
230
|
+
b.end = ts
|
|
231
|
+
if price > b.high:
|
|
232
|
+
b.high = price
|
|
233
|
+
if price < b.low:
|
|
234
|
+
b.low = price
|
|
235
|
+
b.close = price
|
|
236
|
+
b.volume += size
|
|
237
|
+
b.dollar += price * size
|
|
238
|
+
b.n_ticks += 1
|
|
239
|
+
|
|
240
|
+
def _seal(self, out: list[EventBar]) -> None:
|
|
241
|
+
assert self._bar is not None
|
|
242
|
+
self._bar.threshold = self.threshold
|
|
243
|
+
out.append(self._bar)
|
|
244
|
+
self._bar = None
|
|
245
|
+
self._cum = 0.0
|
|
246
|
+
self.n_sealed += 1
|
|
247
|
+
|
|
248
|
+
def _measure(self, price: float, size: float) -> float:
|
|
249
|
+
if self.kind == "tick":
|
|
250
|
+
return 1.0
|
|
251
|
+
if self.kind == "volume":
|
|
252
|
+
return size
|
|
253
|
+
return price * size
|
|
254
|
+
|
|
255
|
+
def update(self, ts: int, price: float, size: float) -> list[EventBar]:
|
|
256
|
+
"""Fold one trade into the open bar.
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
ts : int
|
|
261
|
+
Trade time, nanoseconds UTC.
|
|
262
|
+
price : float
|
|
263
|
+
Trade price.
|
|
264
|
+
size : float
|
|
265
|
+
Trade size. The measure is 1, ``size``, or ``price * size`` depending on ``kind``.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
list of EventBar
|
|
270
|
+
The bars this trade sealed. Usually empty or one. ``split`` can
|
|
271
|
+
seal several when one trade is larger than the threshold. The
|
|
272
|
+
still-open bar is not included; read :attr:`open_bar`.
|
|
273
|
+
"""
|
|
274
|
+
ts, price, size = int(ts), float(price), float(size)
|
|
275
|
+
self._roll_day(ts)
|
|
276
|
+
out: list[EventBar] = []
|
|
277
|
+
m = self._measure(price, size)
|
|
278
|
+
self._day_total += m
|
|
279
|
+
thr = self.threshold
|
|
280
|
+
if self.overshoot == "close" or m <= 0:
|
|
281
|
+
self._add(ts, price, size)
|
|
282
|
+
self._cum += m
|
|
283
|
+
if self._cum >= thr:
|
|
284
|
+
self._seal(out)
|
|
285
|
+
return out
|
|
286
|
+
# split: each bar takes exactly what it needs; the remainder carries on
|
|
287
|
+
remaining = m
|
|
288
|
+
eps = 1e-12 * thr
|
|
289
|
+
while remaining > eps:
|
|
290
|
+
need = thr - self._cum
|
|
291
|
+
take = need if remaining >= need - eps else remaining
|
|
292
|
+
frac = take / m
|
|
293
|
+
self._add(ts, price, size * frac)
|
|
294
|
+
self._cum += take
|
|
295
|
+
remaining -= take
|
|
296
|
+
if self._cum >= thr - eps:
|
|
297
|
+
self._seal(out)
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
def update_many(self, ts: Iterable[int], price: Iterable[float], size: Iterable[float]) -> list[EventBar]:
|
|
301
|
+
"""Fold a stream of trades, in order.
|
|
302
|
+
|
|
303
|
+
Parameters
|
|
304
|
+
----------
|
|
305
|
+
ts : iterable of int
|
|
306
|
+
Trade times, nanoseconds UTC.
|
|
307
|
+
price : iterable of float
|
|
308
|
+
Trade prices.
|
|
309
|
+
size : iterable of float
|
|
310
|
+
Trade sizes. Must be the same length as ``ts`` and ``price``.
|
|
311
|
+
|
|
312
|
+
Returns
|
|
313
|
+
-------
|
|
314
|
+
list of EventBar
|
|
315
|
+
Every bar sealed by the stream, in order.
|
|
316
|
+
|
|
317
|
+
Raises
|
|
318
|
+
------
|
|
319
|
+
ValueError
|
|
320
|
+
If the three iterables have different lengths.
|
|
321
|
+
"""
|
|
322
|
+
out: list[EventBar] = []
|
|
323
|
+
for t, p, q in zip(ts, price, size, strict=True):
|
|
324
|
+
out.extend(self.update(t, p, q))
|
|
325
|
+
return out
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def open_bar(self) -> EventBar | None:
|
|
329
|
+
"""The provisional bar (a copy), or None if no trades since the last seal."""
|
|
330
|
+
if self._bar is None:
|
|
331
|
+
return None
|
|
332
|
+
b = self._bar
|
|
333
|
+
return EventBar(
|
|
334
|
+
b.start, b.end, b.open, b.high, b.low, b.close, b.volume, b.dollar, b.n_ticks, self.threshold
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def progress(self) -> float:
|
|
339
|
+
"""Fraction of the threshold the open bar has filled."""
|
|
340
|
+
return self._cum / self.threshold
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass
|
|
344
|
+
class EventBars:
|
|
345
|
+
"""Struct-of-arrays event bars for one asset."""
|
|
346
|
+
|
|
347
|
+
data: dict[str, np.ndarray]
|
|
348
|
+
|
|
349
|
+
def __len__(self) -> int:
|
|
350
|
+
"""Return the number of sealed bars.
|
|
351
|
+
|
|
352
|
+
Returns
|
|
353
|
+
-------
|
|
354
|
+
int
|
|
355
|
+
Length of the ``end`` array.
|
|
356
|
+
"""
|
|
357
|
+
return int(self.data["end"].size)
|
|
358
|
+
|
|
359
|
+
def __getitem__(self, k: str) -> np.ndarray:
|
|
360
|
+
"""Return one field for every sealed bar.
|
|
361
|
+
|
|
362
|
+
Parameters
|
|
363
|
+
----------
|
|
364
|
+
k : str
|
|
365
|
+
A field name: start, end, open, high, low, close, volume, dollar, n_ticks, threshold.
|
|
366
|
+
|
|
367
|
+
Returns
|
|
368
|
+
-------
|
|
369
|
+
ndarray
|
|
370
|
+
That field, one entry per bar.
|
|
371
|
+
"""
|
|
372
|
+
return self.data[k]
|
|
373
|
+
|
|
374
|
+
@classmethod
|
|
375
|
+
def from_bars(cls, bars: list[EventBar]) -> EventBars:
|
|
376
|
+
"""Stack a list of bars into arrays.
|
|
377
|
+
|
|
378
|
+
Parameters
|
|
379
|
+
----------
|
|
380
|
+
bars : list of EventBar
|
|
381
|
+
Sealed bars, in time order. May be empty.
|
|
382
|
+
|
|
383
|
+
Returns
|
|
384
|
+
-------
|
|
385
|
+
EventBars
|
|
386
|
+
One array per field.
|
|
387
|
+
"""
|
|
388
|
+
dt = {"start": np.int64, "end": np.int64, "n_ticks": np.int64}
|
|
389
|
+
return cls(
|
|
390
|
+
{k: np.array([getattr(b, k) for b in bars], dtype=dt.get(k, np.float64)) for k in EVENT_FIELDS}
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def panel(self, asset: str = "asset") -> Panel:
|
|
394
|
+
"""Return a one-asset panel whose rows are these event bars.
|
|
395
|
+
|
|
396
|
+
Parameters
|
|
397
|
+
----------
|
|
398
|
+
asset : str, optional
|
|
399
|
+
The single asset name. Default is ``"asset"``.
|
|
400
|
+
|
|
401
|
+
Returns
|
|
402
|
+
-------
|
|
403
|
+
Panel
|
|
404
|
+
Rows are labeled by bar end. Columns are the OHLCV fields plus
|
|
405
|
+
``has_trade``, which is True on every sealed event bar.
|
|
406
|
+
"""
|
|
407
|
+
cols = {
|
|
408
|
+
k: self.data[k].astype(np.float64)[:, None]
|
|
409
|
+
for k in ("open", "high", "low", "close", "volume", "dollar", "n_ticks")
|
|
410
|
+
}
|
|
411
|
+
cols["has_trade"] = np.ones((len(self), 1), dtype=bool)
|
|
412
|
+
return Panel(self.data["end"], (asset,), cols, self.data["start"])
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def build_event_bars(
|
|
416
|
+
ts,
|
|
417
|
+
price,
|
|
418
|
+
size,
|
|
419
|
+
kind: str = "volume",
|
|
420
|
+
threshold: Threshold | float = 1000.0,
|
|
421
|
+
overshoot: str = "close",
|
|
422
|
+
day_key: Callable[[int], int] = utc_day,
|
|
423
|
+
) -> tuple[EventBars, EventBar | None]:
|
|
424
|
+
"""Batch: all sealed bars for one asset's trade stream, plus the open bar."""
|
|
425
|
+
b = EventBarBuilder(kind, threshold, overshoot, day_key)
|
|
426
|
+
bars = b.update_many(
|
|
427
|
+
np.asarray(ts, np.int64).tolist(),
|
|
428
|
+
np.asarray(price, np.float64).tolist(),
|
|
429
|
+
np.asarray(size, np.float64).tolist(),
|
|
430
|
+
)
|
|
431
|
+
return EventBars.from_bars(bars), b.open_bar
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ====================================================================== clock alignment
|
|
435
|
+
def sample_asof(
|
|
436
|
+
series_end: np.ndarray, values: np.ndarray, grid_ends: np.ndarray, missing: float = np.nan
|
|
437
|
+
) -> np.ndarray:
|
|
438
|
+
"""Return the last event value strictly before each grid-bar end.
|
|
439
|
+
|
|
440
|
+
Parameters
|
|
441
|
+
----------
|
|
442
|
+
series_end : ndarray of int64
|
|
443
|
+
Event-bar ends, nanoseconds UTC, non-decreasing.
|
|
444
|
+
values : ndarray
|
|
445
|
+
One value per event bar, aligned with ``series_end``.
|
|
446
|
+
grid_ends : ndarray of int64
|
|
447
|
+
Clock-bar ends, nanoseconds UTC.
|
|
448
|
+
missing : float, optional
|
|
449
|
+
Value used when no event bar ends before the grid bar. Default is NaN.
|
|
450
|
+
|
|
451
|
+
Returns
|
|
452
|
+
-------
|
|
453
|
+
ndarray of float64
|
|
454
|
+
Shape ``(len(grid_ends),)``. The comparison is strict (``end < grid_end``)
|
|
455
|
+
so a bar sealed by a trade at ``ts`` is visible in the clock bar that
|
|
456
|
+
contains ``ts``, matching the ``[start, end)`` clock convention.
|
|
457
|
+
"""
|
|
458
|
+
grid_ends = np.asarray(grid_ends, np.int64)
|
|
459
|
+
v = np.asarray(values, dtype=np.float64)
|
|
460
|
+
if v.size == 0:
|
|
461
|
+
return np.full(grid_ends.size, missing, dtype=np.float64)
|
|
462
|
+
idx = np.searchsorted(np.asarray(series_end, np.int64), grid_ends, side="left") - 1
|
|
463
|
+
return np.where(idx >= 0, v[np.clip(idx, 0, None)], missing).astype(np.float64)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def sample_panel(
|
|
467
|
+
per_asset: dict[str, tuple[np.ndarray, dict[str, np.ndarray]]],
|
|
468
|
+
grid_ends: np.ndarray,
|
|
469
|
+
assets: tuple[str, ...] | None = None,
|
|
470
|
+
) -> Panel:
|
|
471
|
+
"""Batch as-of sampling of per-asset event series onto a clock grid -> :class:`Panel`.
|
|
472
|
+
|
|
473
|
+
``per_asset[asset] = (end_times, {column: values})``.
|
|
474
|
+
"""
|
|
475
|
+
names = tuple(assets) if assets is not None else tuple(per_asset)
|
|
476
|
+
grid_ends = np.asarray(grid_ends, np.int64)
|
|
477
|
+
cols: dict[str, np.ndarray] = {}
|
|
478
|
+
for j, a in enumerate(names):
|
|
479
|
+
ends, vals = per_asset[a]
|
|
480
|
+
for k, v in vals.items():
|
|
481
|
+
arr = cols.setdefault(k, np.full((grid_ends.size, len(names)), np.nan))
|
|
482
|
+
arr[:, j] = sample_asof(ends, v, grid_ends)
|
|
483
|
+
return Panel(grid_ends, names, cols)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class AsofSampler:
|
|
487
|
+
"""Incremental as-of sampling: feed sealed event rows, read a clock-grid row at any time."""
|
|
488
|
+
|
|
489
|
+
def __init__(self, assets: tuple[str, ...], columns: tuple[str, ...]):
|
|
490
|
+
"""Prepare an empty sampler.
|
|
491
|
+
|
|
492
|
+
Parameters
|
|
493
|
+
----------
|
|
494
|
+
assets : tuple of str
|
|
495
|
+
Column order of every row this sampler returns.
|
|
496
|
+
columns : tuple of str
|
|
497
|
+
Value names each pushed row may contain.
|
|
498
|
+
"""
|
|
499
|
+
self.assets = tuple(assets)
|
|
500
|
+
self.index = {a: i for i, a in enumerate(self.assets)}
|
|
501
|
+
self.columns = tuple(columns)
|
|
502
|
+
self._rows: list[deque[tuple[int, dict[str, float]]]] = [deque() for _ in self.assets]
|
|
503
|
+
|
|
504
|
+
def push(self, asset: str, end: int, values: dict[str, float]) -> None:
|
|
505
|
+
"""Record one sealed event bar.
|
|
506
|
+
|
|
507
|
+
Parameters
|
|
508
|
+
----------
|
|
509
|
+
asset : str
|
|
510
|
+
Must be one of the assets passed to the constructor.
|
|
511
|
+
end : int
|
|
512
|
+
Bar end, nanoseconds UTC. Must be at least the previous end for this asset.
|
|
513
|
+
values : dict of str to float
|
|
514
|
+
Column values. Missing names become NaN when sampled.
|
|
515
|
+
|
|
516
|
+
Raises
|
|
517
|
+
------
|
|
518
|
+
ValueError
|
|
519
|
+
If ``end`` is earlier than the previous bar for this asset.
|
|
520
|
+
KeyError
|
|
521
|
+
If ``asset`` was not declared.
|
|
522
|
+
"""
|
|
523
|
+
q = self._rows[self.index[asset]]
|
|
524
|
+
if q and end < q[-1][0]:
|
|
525
|
+
raise ValueError("event rows must arrive in end-time order")
|
|
526
|
+
q.append((int(end), dict(values)))
|
|
527
|
+
|
|
528
|
+
def row(self, grid_end: int) -> dict[str, np.ndarray]:
|
|
529
|
+
"""Return values as of the last sealed event bar before ``grid_end``.
|
|
530
|
+
|
|
531
|
+
Parameters
|
|
532
|
+
----------
|
|
533
|
+
grid_end : int
|
|
534
|
+
Clock-bar end, nanoseconds UTC. Call with non-decreasing values:
|
|
535
|
+
rows older than the one used are dropped.
|
|
536
|
+
|
|
537
|
+
Returns
|
|
538
|
+
-------
|
|
539
|
+
dict of str to ndarray
|
|
540
|
+
One ``(N,)`` array per column. The sample is the last event row
|
|
541
|
+
with ``end < grid_end``. Assets with nothing that old are NaN.
|
|
542
|
+
"""
|
|
543
|
+
out = {k: np.full(len(self.assets), np.nan) for k in self.columns}
|
|
544
|
+
for j, q in enumerate(self._rows):
|
|
545
|
+
while len(q) > 1 and q[1][0] < grid_end:
|
|
546
|
+
q.popleft()
|
|
547
|
+
if q and q[0][0] < grid_end:
|
|
548
|
+
for k in self.columns:
|
|
549
|
+
out[k][j] = q[0][1].get(k, np.nan)
|
|
550
|
+
return out
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class EventPipeline:
|
|
554
|
+
"""A per-asset pipeline driven by event bars: each sealed event bar seals one row.
|
|
555
|
+
|
|
556
|
+
The open event bar is the pipeline's open row, so ``update`` returns provisional values
|
|
557
|
+
(``is_open=True``) between bar closes, exactly like the clock-time engine.
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
def __init__(
|
|
561
|
+
self,
|
|
562
|
+
pipeline: Pipeline | Graph,
|
|
563
|
+
builder: EventBarBuilder,
|
|
564
|
+
history: EventBars | None = None,
|
|
565
|
+
asset: str = "asset",
|
|
566
|
+
):
|
|
567
|
+
"""Warm a one-asset live engine from sealed event bars.
|
|
568
|
+
|
|
569
|
+
Parameters
|
|
570
|
+
----------
|
|
571
|
+
pipeline : Pipeline or Graph
|
|
572
|
+
Factors to run. Inputs are the usual bar fields.
|
|
573
|
+
builder : EventBarBuilder
|
|
574
|
+
The bar state machine. If it already holds an open bar, that bar
|
|
575
|
+
is the pipeline's open row immediately.
|
|
576
|
+
history : EventBars or None, optional
|
|
577
|
+
Sealed bars to warm from. ``None`` starts empty, so windows stay missing until enough bars seal.
|
|
578
|
+
asset : str, optional
|
|
579
|
+
Name of the single asset. Default is ``"asset"``.
|
|
580
|
+
"""
|
|
581
|
+
self.builder = builder
|
|
582
|
+
hist = history.panel(asset) if history is not None else EventBars.from_bars([]).panel(asset)
|
|
583
|
+
self.engine = LiveEngine(pipeline, hist)
|
|
584
|
+
self.asset = asset
|
|
585
|
+
if builder.open_bar is not None: # a builder resumed mid-bar: its open bar is the open row
|
|
586
|
+
self._write_open(builder.open_bar)
|
|
587
|
+
|
|
588
|
+
def revise(self) -> Snapshot:
|
|
589
|
+
"""Provisional values for the current open event bar without feeding a trade."""
|
|
590
|
+
return self.engine.revise()
|
|
591
|
+
|
|
592
|
+
def _write_open(self, b: EventBar | None) -> None:
|
|
593
|
+
f = self.engine.bar
|
|
594
|
+
if b is None:
|
|
595
|
+
f.start(f.f["close"].copy())
|
|
596
|
+
self.engine._dirty[:] = True
|
|
597
|
+
return
|
|
598
|
+
self.engine.set_bar(0, b.open, b.high, b.low, b.close, b.volume, b.dollar, float(b.n_ticks))
|
|
599
|
+
|
|
600
|
+
def update(self, ts: int, price: float, size: float) -> list[Snapshot]:
|
|
601
|
+
"""Feed one trade. Returns a sealed snapshot per closed bar, then the open revision."""
|
|
602
|
+
out: list[Snapshot] = []
|
|
603
|
+
for bar in self.builder.update(ts, price, size):
|
|
604
|
+
self._write_open(bar)
|
|
605
|
+
out.append(self.engine.seal(bar.end))
|
|
606
|
+
ob = self.builder.open_bar
|
|
607
|
+
if ob is not None:
|
|
608
|
+
self._write_open(ob)
|
|
609
|
+
out.append(self.engine.revise())
|
|
610
|
+
return out
|