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