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/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