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/live.py ADDED
@@ -0,0 +1,554 @@
1
+ """Incremental executor: one open bar that ticks revise, sealed rows that never change.
2
+
3
+ State per node is a small ring buffer holding the last ``lookback - 1`` sealed rows plus one
4
+ *open slot*. ``revise()`` writes the open bar's inputs into the open slots and recomputes the
5
+ graph's last row in place, touching no sealed state, so revising is a pure function of
6
+ (sealed history, open bar). ``seal()`` makes the open slot permanent and opens the next bar.
7
+
8
+ Only dirty columns are recomputed for column-wise terms (elementwise and time-series), and a
9
+ node whose output did not change stops propagation (exact cutoff, NaN-aware). Cross-sectional
10
+ and row-only terms recompute the full row when any input column changed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Callable, Mapping, Sequence
16
+ from dataclasses import dataclass, field
17
+
18
+ import numpy as np
19
+ import polars as pl
20
+
21
+ from factorflow.bars import FIELDS, BarState
22
+ from factorflow.batch import BatchEngine
23
+ from factorflow.graph import Graph, Pipeline, compile_pipeline
24
+ from factorflow.grid import BarGrid
25
+ from factorflow.panel import Panel
26
+ from factorflow.terms import Term, _Input
27
+
28
+ BAR_INPUTS = (*FIELDS, "has_trade")
29
+
30
+
31
+ class RowBuffer:
32
+ """Sealed tail plus one open slot, stored so a window is a view.
33
+
34
+ Parameters
35
+ ----------
36
+ keep : int
37
+ How many sealed rows to retain. This is ``lookback - 1``: the rows
38
+ before the open row that a consumer's window still needs.
39
+ n : int
40
+ Number of assets.
41
+ dtype : numpy dtype
42
+ Stored dtype. Factors are float64, filters bool, classifiers int64.
43
+ missing
44
+ Value used to fill slots that history did not cover. NaN, ``False``,
45
+ or ``-1`` depending on the term.
46
+ history : ndarray of shape (T, N) or None
47
+ Sealed output to warm from. The last ``keep`` rows are copied in.
48
+ ``None`` or a short history leaves the older slots at ``missing``,
49
+ which is the warmup a batch run would also show.
50
+ """
51
+
52
+ __slots__ = ("cap", "data", "keep", "pos")
53
+
54
+ def __init__(self, keep: int, n: int, dtype, missing, history: np.ndarray | None):
55
+ """Allocate the ring and copy the sealed tail.
56
+
57
+ Parameters
58
+ ----------
59
+ keep : int
60
+ Sealed rows to retain.
61
+ n : int
62
+ Number of assets.
63
+ dtype : numpy dtype
64
+ Stored dtype.
65
+ missing
66
+ Fill value for slots history does not cover.
67
+ history : ndarray or None
68
+ Prior sealed output. Only the last ``keep`` rows are kept.
69
+ """
70
+ self.keep = keep
71
+ self.cap = max(2 * (keep + 1), keep + 64)
72
+ self.data = np.full((self.cap, n), missing, dtype=dtype)
73
+ self.pos = keep # index of the open slot
74
+ if history is not None and keep:
75
+ tail = history[-keep:]
76
+ self.data[keep - tail.shape[0] : keep] = tail
77
+
78
+ def window(self, w: int) -> np.ndarray:
79
+ """Return the last ``w`` rows, ending at the open slot.
80
+
81
+ Parameters
82
+ ----------
83
+ w : int
84
+ Window length in bars, including the open row. The live engine
85
+ passes a term's own ``window``, so the view is exactly what
86
+ batch would pad out to the same length.
87
+
88
+ Returns
89
+ -------
90
+ ndarray of shape (w, N)
91
+ A view. Row ``-1`` is the open slot.
92
+ """
93
+ return self.data[self.pos - w + 1 : self.pos + 1]
94
+
95
+ @property
96
+ def open(self) -> np.ndarray:
97
+ """The open slot.
98
+
99
+ Returns
100
+ -------
101
+ ndarray of shape (N,)
102
+ A view. Writes here are the provisional row.
103
+ """
104
+ return self.data[self.pos]
105
+
106
+ def seal(self) -> None:
107
+ """Commit the open slot and open the next one.
108
+
109
+ The new open slot starts as a copy of the row just sealed, which is
110
+ the forward-filled close for bar fields until a tick overwrites it.
111
+ When the buffer is full, the last ``keep`` sealed rows slide to the
112
+ front. Older rows are dropped; consumers only needed ``keep`` of them.
113
+ """
114
+ self.pos += 1
115
+ if self.pos == self.cap:
116
+ k = self.keep
117
+ self.data[:k] = self.data[self.pos - k : self.pos]
118
+ self.pos = k
119
+ self.data[self.pos] = self.data[self.pos - 1] # open slot starts as a copy (overwritten)
120
+
121
+
122
+ @dataclass
123
+ class Snapshot:
124
+ """One pipeline row, either provisional or sealed.
125
+
126
+ Parameters
127
+ ----------
128
+ ts : int
129
+ Bar end, nanoseconds UTC. For an open clock bar this is the end the
130
+ bar will have once it seals, not the timestamp of the last trade.
131
+ is_open : bool
132
+ ``True`` while the bar can still change. ``False`` after :meth:`LiveEngine.seal`.
133
+ values : dict of str to ndarray
134
+ Named outputs, each shape ``(N,)``. Copies, so later ticks do not
135
+ mutate a snapshot the caller kept.
136
+ screen : ndarray of bool, shape (N,), or None
137
+ The screen on this row, or ``None`` if the pipeline has no screen.
138
+ changed : frozenset of str
139
+ Output names whose values differ from the previous snapshot. Empty
140
+ when nothing a watcher cares about moved. NaN equals NaN for this test.
141
+ assets : tuple of str, optional
142
+ Asset names, column order of the arrays.
143
+ """
144
+
145
+ ts: int # bar end (label), ns UTC
146
+ is_open: bool # True = provisional (open bar), False = sealed
147
+ values: dict[str, np.ndarray]
148
+ screen: np.ndarray | None
149
+ changed: frozenset[str] # outputs whose values changed since the previous snapshot
150
+ assets: tuple[str, ...] = field(default=())
151
+
152
+ def frame(self) -> pl.DataFrame:
153
+ """Stack this row into a one-bar long frame.
154
+
155
+ Returns
156
+ -------
157
+ polars.DataFrame
158
+ Columns ``ts``, ``asset``, ``is_open``, and each output. Assets
159
+ that fail the screen are omitted. With no screen, every asset is
160
+ present.
161
+ """
162
+ keep = self.screen if self.screen is not None else np.ones(len(self.assets), dtype=bool)
163
+ idx = np.flatnonzero(keep)
164
+ data = {
165
+ "ts": pl.Series([self.ts] * idx.size, dtype=pl.Int64).cast(pl.Datetime("ns", "UTC")),
166
+ "asset": [self.assets[i] for i in idx],
167
+ "is_open": [self.is_open] * idx.size,
168
+ }
169
+ for k, v in self.values.items():
170
+ data[k] = v[idx]
171
+ return pl.DataFrame(data)
172
+
173
+
174
+ Observer = Callable[[Snapshot], None]
175
+
176
+
177
+ def _same(a: np.ndarray, b: np.ndarray) -> np.ndarray:
178
+ if a.dtype.kind == "f":
179
+ return (a == b) | (np.isnan(a) & np.isnan(b))
180
+ return a == b
181
+
182
+
183
+ class LiveEngine:
184
+ """Run a pipeline incrementally on top of a sealed history.
185
+
186
+ With a ``grid``, ``tick()`` routes trades by timestamp and bars seal automatically once
187
+ ``ts >= end + grace`` (``advance(now)`` does the same from a timer). Without a grid the
188
+ caller drives the clock with ``seal(ts)``; that is how event bars (tick/volume/dollar)
189
+ drive a per-asset pipeline.
190
+ """
191
+
192
+ def __init__(
193
+ self,
194
+ pipeline: Pipeline | Graph,
195
+ history: Panel,
196
+ grid: BarGrid | None = None,
197
+ observers: Sequence[Observer] = (),
198
+ partial_threshold: float = 0.0,
199
+ ):
200
+ """Warm from ``history`` and open the next bar.
201
+
202
+ Parameters
203
+ ----------
204
+ pipeline : Pipeline or Graph
205
+ What to run. Compiled once, here.
206
+ history : Panel
207
+ Sealed bars. Must include every input the pipeline reads, and
208
+ should be at least as long as the longest window or the early
209
+ live rows stay missing, the same way batch warmup is missing.
210
+ grid : BarGrid or None, optional
211
+ Clock that seals bars from timestamps. ``None`` means the caller
212
+ seals with :meth:`seal` (event bars do this).
213
+ observers : sequence of callable, optional
214
+ Each is called with a :class:`Snapshot` when a revise changes an
215
+ output, and on every seal. Default is none.
216
+ partial_threshold : float, optional
217
+ Fraction of assets. Column-wise terms recompute only the dirty
218
+ columns when fewer than this fraction changed. ``0`` recomputes
219
+ the whole row, which is faster at a few hundred names because
220
+ the Python loop over lags dominates. Default is ``0``.
221
+
222
+ Raises
223
+ ------
224
+ KeyError
225
+ If ``history`` lacks a column the pipeline reads. Raised while
226
+ the batch warm-up runs.
227
+ """
228
+ self.graph = pipeline if isinstance(pipeline, Graph) else compile_pipeline(pipeline)
229
+ self.assets = history.assets
230
+ self.asset_index = {a: i for i, a in enumerate(self.assets)}
231
+ self.n = n = len(self.assets)
232
+ self.grid = grid
233
+ self.observers: list[Observer] = list(observers)
234
+ self.partial_threshold = partial_threshold
235
+ g = self.graph
236
+
237
+ warm = BatchEngine().run(g, history)
238
+ self.bufs: dict[Term, RowBuffer] = {}
239
+ for t in g.order:
240
+ keep = g.lookback[t] - 1
241
+ self.bufs[t] = RowBuffer(keep, n, t.dtype, t.missing, warm.nodes[t])
242
+ self.prev: dict[Term, np.ndarray | None] = {
243
+ t: warm.recursive_state.get(t) for t in g.order if t.recursive
244
+ }
245
+ self._consumers_window = g.lookback
246
+
247
+ # open-row inputs
248
+ self.bar = BarState(n)
249
+ last_close = (
250
+ history.columns["close"][-1].astype(np.float64)
251
+ if "close" in history.columns and len(history.times)
252
+ else np.full(n, np.nan)
253
+ )
254
+ self.bar.start(last_close)
255
+ self.extra: dict[str, np.ndarray] = {}
256
+ for t in g.inputs:
257
+ name = t.name # type: ignore[attr-defined]
258
+ if name not in BAR_INPUTS:
259
+ col = history.columns.get(name)
260
+ if col is not None and len(history.times):
261
+ self.extra[name] = np.asarray(col[-1], dtype=t.dtype).copy()
262
+ else:
263
+ self.extra[name] = np.full(n, t.missing, dtype=t.dtype)
264
+ self._dirty = np.zeros(n, dtype=bool)
265
+ self._fresh = True
266
+ self._last_out: dict[str, np.ndarray] = {}
267
+ self.late_ticks = 0
268
+ self._pending: list[tuple[int, int, float, float]] = []
269
+
270
+ self.last_sealed_ts = int(history.times[-1]) if len(history.times) else None
271
+ self.open_start: int | None = None
272
+ self.open_end: int | None = None
273
+ if grid is not None:
274
+ ref = self.last_sealed_ts if self.last_sealed_ts is not None else 0
275
+ s, e = grid.bars_after(ref, 1)
276
+ self.open_start, self.open_end = int(s[0]), int(e[0])
277
+
278
+ # ------------------------------------------------------------------ inputs
279
+ def _j(self, asset: str | int) -> int:
280
+ return asset if isinstance(asset, (int, np.integer)) else self.asset_index[asset] # type: ignore[return-value]
281
+
282
+ def tick(self, asset: str | int, ts: int, price: float, size: float = 0.0) -> None:
283
+ """Fold one trade into the open bar.
284
+
285
+ Parameters
286
+ ----------
287
+ asset : str or int
288
+ Asset name, or a column index.
289
+ ts : int
290
+ Trade time, nanoseconds UTC.
291
+ price : float
292
+ Trade price, in the caller's price units.
293
+ size : float, optional
294
+ Trade size. Default is 0, which still counts as a tick and moves
295
+ the close. Volume does not increase.
296
+
297
+ Notes
298
+ -----
299
+ With a grid, bars whose ``end + grace`` is at or before ``ts`` are
300
+ sealed first. A trade stamped at or after the open bar's end is held
301
+ and applied after that seal. A trade older than the last sealed bar
302
+ is dropped and counted in :attr:`late_ticks`.
303
+ """
304
+ j = self._j(asset)
305
+ if self.grid is not None:
306
+ self.advance(ts)
307
+ assert self.open_end is not None
308
+ if ts >= self.open_end: # inside the grace period: belongs to a later bar
309
+ self._pending.append((j, ts, price, size))
310
+ return
311
+ if self.last_sealed_ts is not None and ts < self.last_sealed_ts:
312
+ self.late_ticks += 1
313
+ return
314
+ self.bar.tick(j, price, size)
315
+ self._dirty[j] = True
316
+
317
+ def set_bar(
318
+ self,
319
+ asset: str | int,
320
+ open: float,
321
+ high: float,
322
+ low: float,
323
+ close: float,
324
+ volume: float,
325
+ dollar: float | None = None,
326
+ n_ticks: float | None = None,
327
+ ) -> None:
328
+ """Replace one asset's open OHLCV bar.
329
+
330
+ Parameters
331
+ ----------
332
+ asset : str or int
333
+ Asset name, or a column index.
334
+ open, high, low, close : float
335
+ The in-progress bar's prices.
336
+ volume : float
337
+ Volume so far, in the caller's size units.
338
+ dollar : float or None, optional
339
+ Dollar volume. ``None`` stores ``close * volume``.
340
+ n_ticks : float or None, optional
341
+ Trade count. ``None`` stores 1 when ``volume > 0``, else 0.
342
+
343
+ Notes
344
+ -----
345
+ Does not seal. Call :meth:`revise` to recompute factors. The asset
346
+ is marked dirty.
347
+ """
348
+ j = self._j(asset)
349
+ self.bar.set_bar(j, open, high, low, close, volume, dollar, n_ticks)
350
+ self._dirty[j] = True
351
+
352
+ def set_open(self, column: str, values: np.ndarray | Mapping[str | int, float]) -> None:
353
+ """Set a column of the open bar that is not implied by trades.
354
+
355
+ Parameters
356
+ ----------
357
+ column : str
358
+ An input the pipeline reads. Bar fields (``open``, ``close``, ...)
359
+ and extra columns (a sector code, a signal) are both accepted.
360
+ The column must already be an input; this does not add outputs.
361
+ values : ndarray of shape (N,) or mapping
362
+ A full row, or ``{asset: value}`` for a subset. Dtype follows the
363
+ stored column (float64 for factors, int64 for classifiers).
364
+
365
+ Notes
366
+ -----
367
+ Assets whose value changed are marked dirty. Call :meth:`revise` afterwards.
368
+ """
369
+ f = self.bar.f[column] if column in BAR_INPUTS else self.extra[column]
370
+ if isinstance(values, Mapping):
371
+ for a, v in values.items():
372
+ j = self._j(a)
373
+ f[j] = v
374
+ self._dirty[j] = True
375
+ else:
376
+ arr = np.asarray(values, dtype=f.dtype)
377
+ self._dirty |= ~_same(arr, f)
378
+ f[:] = arr
379
+
380
+ def set_open_bar_fields(self, fields: Mapping[str, np.ndarray]) -> None:
381
+ """Set several open-bar columns at once.
382
+
383
+ Parameters
384
+ ----------
385
+ fields : mapping of str to ndarray
386
+ Each value is a full row, passed to :meth:`set_open`.
387
+ """
388
+ for k, v in fields.items():
389
+ self.set_open(k, v)
390
+
391
+ # ------------------------------------------------------------------ compute
392
+ def _input_row(self, name: str) -> np.ndarray:
393
+ if name in BAR_INPUTS:
394
+ return self.bar.row(name)
395
+ return self.extra[name]
396
+
397
+ def _recompute(self) -> dict[Term, np.ndarray]:
398
+ g, n = self.graph, self.n
399
+ fresh = self._fresh
400
+ changed: dict[Term, np.ndarray] = {}
401
+ all_cols = np.ones(n, dtype=bool)
402
+ for t in g.order:
403
+ buf = self.bufs[t]
404
+ if isinstance(t, _Input):
405
+ buf.open[:] = self._input_row(t.name) # type: ignore[attr-defined]
406
+ changed[t] = all_cols if fresh else self._dirty
407
+ continue
408
+ if fresh:
409
+ mask = all_cols
410
+ else:
411
+ mask = changed[t.inputs[0]]
412
+ for i in t.inputs[1:]:
413
+ mask = mask | changed[i]
414
+ if not mask.any():
415
+ changed[t] = mask
416
+ continue
417
+ if t.rowwise:
418
+ rows = [self.bufs[i].window(1) for i in t.inputs]
419
+ new = t.compute_row(rows, self.prev.get(t))
420
+ cols = None
421
+ else:
422
+ w = t.window
423
+ k = int(mask.sum())
424
+ if t.columnwise and not fresh and k < self.partial_threshold * n:
425
+ cols = np.flatnonzero(mask)
426
+ blocks = [self.bufs[i].window(w)[:, cols] for i in t.inputs]
427
+ else:
428
+ cols = None
429
+ blocks = [self.bufs[i].window(w) for i in t.inputs]
430
+ # ``compute`` returns ``T_in - window + 1`` rows. This call
431
+ # passes exactly ``window`` rows (sealed tail + open slot), so
432
+ # the result has one row: the same arithmetic as the last row
433
+ # of a batch run that treats this open bar as final.
434
+ new = t.compute(blocks)[0]
435
+ out = buf.open
436
+ if fresh:
437
+ out[:] = new
438
+ changed[t] = all_cols
439
+ elif cols is None:
440
+ diff = ~_same(new, out)
441
+ out[:] = new
442
+ changed[t] = diff
443
+ else:
444
+ old = out[cols]
445
+ d = ~_same(new, old)
446
+ out[cols] = new
447
+ m = np.zeros(n, dtype=bool)
448
+ m[cols[d]] = True
449
+ changed[t] = m
450
+ self._dirty[:] = False
451
+ self._fresh = False
452
+ return changed
453
+
454
+ def _snapshot(self, changed: dict[Term, np.ndarray], is_open: bool, ts: int | None) -> Snapshot:
455
+ g = self.graph
456
+ vals = {k: self.bufs[t].open.copy() for k, t in g.outputs.items()}
457
+ names = frozenset(k for k, t in g.outputs.items() if changed[t].any())
458
+ screen = self.bufs[g.screen].open.copy() if g.screen is not None else None
459
+ stamp = ts if ts is not None else (self.open_end if self.open_end is not None else -1)
460
+ return Snapshot(int(stamp), is_open, vals, screen, names, self.assets)
461
+
462
+ def revise(self) -> Snapshot:
463
+ """Recompute the open bar.
464
+
465
+ Returns
466
+ -------
467
+ Snapshot
468
+ ``is_open`` is ``True``. Sealed rows are not modified, so calling
469
+ this twice with no new input returns the same values. Observers
470
+ run only when an output changed.
471
+
472
+ Notes
473
+ -----
474
+ A revision equals a batch run on the sealed history plus this open
475
+ bar appended as a final row.
476
+ """
477
+ changed = self._recompute()
478
+ snap = self._snapshot(changed, True, None)
479
+ if snap.changed:
480
+ for ob in self.observers:
481
+ ob(snap)
482
+ return snap
483
+
484
+ def seal(self, ts: int | None = None) -> Snapshot:
485
+ """Commit the open bar and start the next one.
486
+
487
+ Parameters
488
+ ----------
489
+ ts : int or None, optional
490
+ Bar end, nanoseconds UTC. ``None`` uses the grid's open-bar end.
491
+ Required when the engine was built without a grid.
492
+
493
+ Returns
494
+ -------
495
+ Snapshot
496
+ The row just sealed, with ``is_open`` False. Observers always run.
497
+
498
+ Raises
499
+ ------
500
+ ValueError
501
+ If ``ts`` is omitted and there is no grid.
502
+
503
+ Notes
504
+ -----
505
+ Assets with no trade keep the previous close and get zero volume.
506
+ Trades that arrived during the grace period, stamped into the next
507
+ bar, are applied after the seal.
508
+ """
509
+ changed = self._recompute()
510
+ if ts is None:
511
+ if self.open_end is None:
512
+ raise ValueError("seal(ts) needs a timestamp when the engine has no grid")
513
+ ts = self.open_end
514
+ snap = self._snapshot(changed, False, ts)
515
+ for t in self.graph.order:
516
+ if t.recursive:
517
+ self.prev[t] = self.bufs[t].open.copy()
518
+ self.bufs[t].seal()
519
+ self.last_sealed_ts = int(ts)
520
+ self.bar.start(self.bar.f["close"].copy())
521
+ self._fresh = True
522
+ if self.grid is not None:
523
+ s, e = self.grid.bars_after(int(ts), 1)
524
+ self.open_start, self.open_end = int(s[0]), int(e[0])
525
+ for ob in self.observers:
526
+ ob(snap)
527
+ if self._pending:
528
+ pend, self._pending = self._pending, []
529
+ for j, t_, p, q in pend:
530
+ self.tick(j, t_, p, q)
531
+ return snap
532
+
533
+ def advance(self, now: int) -> list[Snapshot]:
534
+ """Seal every bar whose grace has elapsed.
535
+
536
+ Parameters
537
+ ----------
538
+ now : int
539
+ Current time, nanoseconds UTC. A bar seals when
540
+ ``now >= end + grace``. Empty bars are sealed too, carrying the
541
+ close forward.
542
+
543
+ Returns
544
+ -------
545
+ list of Snapshot
546
+ One sealed snapshot per bar closed by this call, in time order.
547
+ Empty if nothing was due, or if the engine has no grid.
548
+ """
549
+ out: list[Snapshot] = []
550
+ if self.grid is None:
551
+ return out
552
+ while self.open_end is not None and now >= self.open_end + self.grid.grace_ns:
553
+ out.append(self.seal())
554
+ return out