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/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""factorflow: point-in-time factor pipelines that run the same in batch and live.
|
|
2
|
+
|
|
3
|
+
Bars are rows; the newest row may be *open* (provisional) and is revised by ticks until it
|
|
4
|
+
is sealed. Batch and incremental execution give bit-identical results for sealed rows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from factorflow.bars import BarState, Trades, build_time_bars
|
|
8
|
+
from factorflow.batch import BatchEngine, BatchResult
|
|
9
|
+
from factorflow.events import (
|
|
10
|
+
AdaptiveDaily,
|
|
11
|
+
AsofSampler,
|
|
12
|
+
EventBar,
|
|
13
|
+
EventBarBuilder,
|
|
14
|
+
EventBars,
|
|
15
|
+
EventPipeline,
|
|
16
|
+
Fixed,
|
|
17
|
+
build_event_bars,
|
|
18
|
+
sample_asof,
|
|
19
|
+
sample_panel,
|
|
20
|
+
)
|
|
21
|
+
from factorflow.graph import Pipeline, compile_pipeline
|
|
22
|
+
from factorflow.grid import BarGrid, to_ns
|
|
23
|
+
from factorflow.live import LiveEngine, Snapshot
|
|
24
|
+
from factorflow.panel import Panel
|
|
25
|
+
from factorflow.terms import BarData, Classifier, Column, Factor, Filter, Term, where
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AdaptiveDaily",
|
|
31
|
+
"AsofSampler",
|
|
32
|
+
"BarData",
|
|
33
|
+
"BarGrid",
|
|
34
|
+
"BarState",
|
|
35
|
+
"BatchEngine",
|
|
36
|
+
"BatchResult",
|
|
37
|
+
"Classifier",
|
|
38
|
+
"Column",
|
|
39
|
+
"EventBar",
|
|
40
|
+
"EventBarBuilder",
|
|
41
|
+
"EventBars",
|
|
42
|
+
"EventPipeline",
|
|
43
|
+
"Factor",
|
|
44
|
+
"Filter",
|
|
45
|
+
"Fixed",
|
|
46
|
+
"LiveEngine",
|
|
47
|
+
"Panel",
|
|
48
|
+
"Pipeline",
|
|
49
|
+
"Snapshot",
|
|
50
|
+
"Term",
|
|
51
|
+
"Trades",
|
|
52
|
+
"build_event_bars",
|
|
53
|
+
"build_time_bars",
|
|
54
|
+
"compile_pipeline",
|
|
55
|
+
"sample_asof",
|
|
56
|
+
"sample_panel",
|
|
57
|
+
"to_ns",
|
|
58
|
+
"where",
|
|
59
|
+
]
|
factorflow/bars.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Clock-time bar construction (trades -> OHLCV bars on a :class:`BarGrid`).
|
|
2
|
+
|
|
3
|
+
One accumulator, :class:`BarState`, is shared by the batch builder and the live engine,
|
|
4
|
+
so a bar built live from a trade stream is identical to the batch-built bar.
|
|
5
|
+
|
|
6
|
+
Bar rules (docs/BARS.md):
|
|
7
|
+
|
|
8
|
+
* a trade at ``ts`` belongs to the first bar with ``end > ts`` (bars are ``[start, end)``,
|
|
9
|
+
labeled by ``end``);
|
|
10
|
+
* a bar with no trades carries the previous close forward: ``open = high = low = close =
|
|
11
|
+
previous close`` and ``volume = dollar = n_ticks = 0`` (NaN before an asset's first trade);
|
|
12
|
+
* ``has_trade`` is ``n_ticks > 0``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from factorflow.panel import Panel
|
|
23
|
+
|
|
24
|
+
FIELDS = ("open", "high", "low", "close", "volume", "dollar", "n_ticks")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Trades:
|
|
29
|
+
"""A trade stream: parallel arrays sorted by ``ts`` (int64 ns UTC).
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
asset : array of int
|
|
34
|
+
Index into the asset list passed to :func:`build_time_bars`.
|
|
35
|
+
ts : array of int64
|
|
36
|
+
Trade time, nanoseconds UTC, non-decreasing.
|
|
37
|
+
price : array of float
|
|
38
|
+
Trade price, in the same units the caller uses everywhere else.
|
|
39
|
+
size : array of float
|
|
40
|
+
Trade size. The sign is not special: volume bars add ``size`` as given.
|
|
41
|
+
|
|
42
|
+
Raises
|
|
43
|
+
------
|
|
44
|
+
ValueError
|
|
45
|
+
If ``ts`` goes backwards.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
asset: np.ndarray # int asset index
|
|
49
|
+
ts: np.ndarray
|
|
50
|
+
price: np.ndarray
|
|
51
|
+
size: np.ndarray
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
"""Cast the arrays and require non-decreasing timestamps.
|
|
55
|
+
|
|
56
|
+
Raises
|
|
57
|
+
------
|
|
58
|
+
ValueError
|
|
59
|
+
If a timestamp is earlier than the one before it.
|
|
60
|
+
"""
|
|
61
|
+
self.asset = np.asarray(self.asset, dtype=np.int64)
|
|
62
|
+
self.ts = np.asarray(self.ts, dtype=np.int64)
|
|
63
|
+
self.price = np.asarray(self.price, dtype=np.float64)
|
|
64
|
+
self.size = np.asarray(self.size, dtype=np.float64)
|
|
65
|
+
if self.ts.size > 1 and not np.all(np.diff(self.ts) >= 0):
|
|
66
|
+
raise ValueError("trades must be sorted by ts")
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
"""Return the number of trades."""
|
|
70
|
+
return int(self.ts.size)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BarState:
|
|
74
|
+
"""The open (provisional) bar for ``N`` assets.
|
|
75
|
+
|
|
76
|
+
Batch (:func:`build_time_bars`) and live (:class:`~factorflow.live.LiveEngine`)
|
|
77
|
+
both fold trades through this object, so a bar built either way is the same
|
|
78
|
+
object updated by the same rules.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
n : int
|
|
83
|
+
Number of assets.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, n: int):
|
|
87
|
+
"""Allocate one open bar and start it from a missing previous close.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
n : int
|
|
92
|
+
Number of assets.
|
|
93
|
+
"""
|
|
94
|
+
self.n = n
|
|
95
|
+
self.f = {k: np.full(n, np.nan) for k in FIELDS}
|
|
96
|
+
self.start(np.full(n, np.nan))
|
|
97
|
+
|
|
98
|
+
def start(self, prev_close: np.ndarray) -> None:
|
|
99
|
+
"""Open a new bar from the previous close.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
prev_close : ndarray of shape (N,)
|
|
104
|
+
Close to carry forward. OHLC start at this value. Volume, dollar
|
|
105
|
+
value, and trade count start at 0. A NaN previous close stays NaN
|
|
106
|
+
until the first trade, which is an asset that has not traded yet.
|
|
107
|
+
"""
|
|
108
|
+
pc = np.asarray(prev_close, dtype=np.float64)
|
|
109
|
+
for k in ("open", "high", "low", "close"):
|
|
110
|
+
self.f[k][:] = pc
|
|
111
|
+
for k in ("volume", "dollar", "n_ticks"):
|
|
112
|
+
self.f[k][:] = 0.0
|
|
113
|
+
|
|
114
|
+
def tick(self, j: int, price: float, size: float) -> None:
|
|
115
|
+
"""Apply one trade to asset ``j``.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
j : int
|
|
120
|
+
Asset column.
|
|
121
|
+
price : float
|
|
122
|
+
Trade price. The first trade in the bar sets open, high, and low.
|
|
123
|
+
Later trades expand high and low and replace the close.
|
|
124
|
+
size : float
|
|
125
|
+
Added to volume. Dollar volume increases by ``price * size``.
|
|
126
|
+
The trade count increases by 1.
|
|
127
|
+
"""
|
|
128
|
+
f = self.f
|
|
129
|
+
if f["n_ticks"][j] == 0:
|
|
130
|
+
f["open"][j] = f["high"][j] = f["low"][j] = price
|
|
131
|
+
else:
|
|
132
|
+
if price > f["high"][j]:
|
|
133
|
+
f["high"][j] = price
|
|
134
|
+
if price < f["low"][j]:
|
|
135
|
+
f["low"][j] = price
|
|
136
|
+
f["close"][j] = price
|
|
137
|
+
f["volume"][j] += size
|
|
138
|
+
f["dollar"][j] += price * size
|
|
139
|
+
f["n_ticks"][j] += 1
|
|
140
|
+
|
|
141
|
+
def set_bar(
|
|
142
|
+
self,
|
|
143
|
+
j: int,
|
|
144
|
+
open: float,
|
|
145
|
+
high: float,
|
|
146
|
+
low: float,
|
|
147
|
+
close: float,
|
|
148
|
+
volume: float,
|
|
149
|
+
dollar: float | None = None,
|
|
150
|
+
n_ticks: float | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Replace asset ``j``'s open bar with values the caller already aggregated.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
j : int
|
|
157
|
+
Asset column.
|
|
158
|
+
open, high, low, close, volume : float
|
|
159
|
+
The in-progress bar. Prices are in the caller's price units.
|
|
160
|
+
``volume`` is in the caller's size units.
|
|
161
|
+
dollar : float or None, optional
|
|
162
|
+
Price times size accumulated so far. ``None`` stores ``close * volume``.
|
|
163
|
+
n_ticks : float or None, optional
|
|
164
|
+
Trade count. ``None`` stores 1 when ``volume > 0``, else 0.
|
|
165
|
+
|
|
166
|
+
Notes
|
|
167
|
+
-----
|
|
168
|
+
This is the path for a feed that publishes the current bar instead of
|
|
169
|
+
individual trades. It does not check that high and low bracket the close.
|
|
170
|
+
"""
|
|
171
|
+
f = self.f
|
|
172
|
+
f["open"][j], f["high"][j], f["low"][j], f["close"][j] = open, high, low, close
|
|
173
|
+
f["volume"][j] = volume
|
|
174
|
+
f["dollar"][j] = close * volume if dollar is None else dollar
|
|
175
|
+
f["n_ticks"][j] = (1.0 if volume > 0 else 0.0) if n_ticks is None else n_ticks
|
|
176
|
+
|
|
177
|
+
def row(self, name: str) -> np.ndarray:
|
|
178
|
+
"""Return one field of the open bar.
|
|
179
|
+
|
|
180
|
+
Parameters
|
|
181
|
+
----------
|
|
182
|
+
name : str
|
|
183
|
+
``"open"``, ``"high"``, ``"low"``, ``"close"``, ``"volume"``,
|
|
184
|
+
``"dollar"``, ``"n_ticks"``, or ``"has_trade"``. ``has_trade`` is
|
|
185
|
+
``n_ticks > 0`` and is not stored.
|
|
186
|
+
|
|
187
|
+
Returns
|
|
188
|
+
-------
|
|
189
|
+
ndarray of shape (N,)
|
|
190
|
+
A view of the field, except ``has_trade``, which is a new bool array.
|
|
191
|
+
"""
|
|
192
|
+
if name == "has_trade":
|
|
193
|
+
return self.f["n_ticks"] > 0
|
|
194
|
+
return self.f[name]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def build_time_bars(
|
|
198
|
+
trades: Trades,
|
|
199
|
+
assets: Sequence[str],
|
|
200
|
+
starts: np.ndarray,
|
|
201
|
+
ends: np.ndarray,
|
|
202
|
+
prev_close: np.ndarray | None = None,
|
|
203
|
+
) -> Panel:
|
|
204
|
+
"""Aggregate trades into clock bars.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
trades : Trades
|
|
209
|
+
Sorted by timestamp. ``trades.asset`` indexes ``assets``. A trade
|
|
210
|
+
belongs to the first bar with ``end > ts``.
|
|
211
|
+
assets : sequence of str
|
|
212
|
+
Asset names, length ``N``.
|
|
213
|
+
starts, ends : ndarray of int64
|
|
214
|
+
Bar bounds, nanoseconds UTC, from :meth:`BarGrid.bars`. Same length.
|
|
215
|
+
prev_close : ndarray of shape (N,) or None, optional
|
|
216
|
+
Close before the first bar. ``None`` starts from NaN, so an asset
|
|
217
|
+
with no trade in the first bars stays missing.
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
Panel
|
|
222
|
+
Columns ``open``, ``high``, ``low``, ``close``, ``volume``, ``dollar``,
|
|
223
|
+
``n_ticks``, and bool ``has_trade``. An empty bar repeats the previous
|
|
224
|
+
close and stores zero volume. ``times`` is ``ends``.
|
|
225
|
+
|
|
226
|
+
Notes
|
|
227
|
+
-----
|
|
228
|
+
The loop is the same :class:`BarState` the live engine ticks. Trades must
|
|
229
|
+
be in time order. Out-of-order trades are not repaired here.
|
|
230
|
+
"""
|
|
231
|
+
ends = np.asarray(ends, dtype=np.int64)
|
|
232
|
+
t, n = len(ends), len(assets)
|
|
233
|
+
out = {k: np.empty((t, n)) for k in FIELDS}
|
|
234
|
+
st = BarState(n)
|
|
235
|
+
st.start(np.full(n, np.nan) if prev_close is None else prev_close)
|
|
236
|
+
bar_of = np.searchsorted(ends, trades.ts, side="right")
|
|
237
|
+
cuts = np.searchsorted(bar_of, np.arange(t + 1), side="left")
|
|
238
|
+
for r in range(t):
|
|
239
|
+
for i in range(cuts[r], cuts[r + 1]):
|
|
240
|
+
st.tick(int(trades.asset[i]), float(trades.price[i]), float(trades.size[i]))
|
|
241
|
+
for k in FIELDS:
|
|
242
|
+
out[k][r] = st.f[k]
|
|
243
|
+
st.start(st.f["close"].copy())
|
|
244
|
+
cols: dict[str, np.ndarray] = dict(out)
|
|
245
|
+
cols["has_trade"] = out["n_ticks"] > 0
|
|
246
|
+
return Panel(ends, tuple(assets), cols, np.asarray(starts, dtype=np.int64))
|
factorflow/batch.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Batch executor: run a compiled pipeline over a whole :class:`~factorflow.panel.Panel`.
|
|
2
|
+
|
|
3
|
+
Batch and live share kernels. For a windowed term, this module prepends
|
|
4
|
+
``window - 1`` missing rows (the warmup) and calls ``compute`` once on the whole
|
|
5
|
+
history. The live engine instead calls the same ``compute`` on exactly ``window``
|
|
6
|
+
rows and keeps row 0 of the result. The arithmetic is the same; only the slice
|
|
7
|
+
of history differs.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import polars as pl
|
|
16
|
+
|
|
17
|
+
from factorflow.graph import Graph, Pipeline, compile_pipeline
|
|
18
|
+
from factorflow.panel import Panel
|
|
19
|
+
from factorflow.terms import Term, _Input
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _pad(x: np.ndarray, rows: int, missing) -> np.ndarray:
|
|
23
|
+
"""Prepend ``rows`` copies of ``missing`` so a window of length ``w`` can start at row 0.
|
|
24
|
+
|
|
25
|
+
The first ``w - 1`` output rows then see a window that contains the missing
|
|
26
|
+
value and come out missing. That is the warmup. ``rows`` is 0 for a term
|
|
27
|
+
whose window is 1.
|
|
28
|
+
"""
|
|
29
|
+
if rows <= 0:
|
|
30
|
+
return x
|
|
31
|
+
pad = np.full((rows, *x.shape[1:]), missing, dtype=x.dtype)
|
|
32
|
+
return np.concatenate([pad, x], axis=0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def input_array(panel: Panel, t: Term) -> np.ndarray:
|
|
36
|
+
"""Read one input term out of a panel.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
panel : Panel
|
|
41
|
+
History the pipeline is running on.
|
|
42
|
+
t : Term
|
|
43
|
+
An input term (a :class:`~factorflow.terms.Column` node). ``t.name``
|
|
44
|
+
is the column name.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
ndarray of shape (T, N)
|
|
49
|
+
Contiguous array in the term's dtype.
|
|
50
|
+
|
|
51
|
+
Raises
|
|
52
|
+
------
|
|
53
|
+
KeyError
|
|
54
|
+
If the panel has no column of that name.
|
|
55
|
+
"""
|
|
56
|
+
name = t.name # type: ignore[attr-defined]
|
|
57
|
+
if name not in panel.columns:
|
|
58
|
+
raise KeyError(f"panel has no column {name!r} (needed by the pipeline)")
|
|
59
|
+
return np.ascontiguousarray(panel.columns[name], dtype=t.dtype)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compute_node(
|
|
63
|
+
t: Term, blocks: list[np.ndarray], prev: np.ndarray | None
|
|
64
|
+
) -> tuple[np.ndarray, np.ndarray | None]:
|
|
65
|
+
"""Evaluate one term on a whole history.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
t : Term
|
|
70
|
+
The node to run. Not an input term.
|
|
71
|
+
blocks : list of ndarray
|
|
72
|
+
One ``(T, N)`` array per input, already aligned to the panel.
|
|
73
|
+
prev : ndarray of shape (N,) or None
|
|
74
|
+
Previous output for a recursive term. Batch starts with ``None``,
|
|
75
|
+
so an EWMA starts at the first finite observation.
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
values : ndarray of shape (T, N)
|
|
80
|
+
One output row per panel row.
|
|
81
|
+
last : ndarray of shape (N,) or None
|
|
82
|
+
The last row, kept as recursive state. ``None`` if the term is not
|
|
83
|
+
recursive or the history is empty.
|
|
84
|
+
|
|
85
|
+
Notes
|
|
86
|
+
-----
|
|
87
|
+
Row-wise terms (cross-section, EWMA) loop one row at a time, which is the
|
|
88
|
+
same loop the live engine runs for a single open row. Windowed terms are
|
|
89
|
+
padded by ``window - 1`` missing rows and then handed to ``compute``.
|
|
90
|
+
"""
|
|
91
|
+
n_rows = blocks[0].shape[0]
|
|
92
|
+
if t.rowwise:
|
|
93
|
+
out = np.empty(blocks[0].shape, dtype=t.dtype)
|
|
94
|
+
p = prev
|
|
95
|
+
for r in range(n_rows):
|
|
96
|
+
row = t.compute_row([b[r : r + 1] for b in blocks], p)
|
|
97
|
+
out[r] = row
|
|
98
|
+
p = row if t.recursive else None
|
|
99
|
+
return out, (out[-1].copy() if (t.recursive and n_rows) else prev)
|
|
100
|
+
w = t.window
|
|
101
|
+
padded = [_pad(b, w - 1, inp.missing) for b, inp in zip(blocks, t.inputs, strict=True)]
|
|
102
|
+
return np.asarray(t.compute(padded), dtype=t.dtype), None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class BatchResult:
|
|
107
|
+
"""Everything :meth:`BatchEngine.run` computed.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
times : ndarray of int64
|
|
112
|
+
Bar ends, nanoseconds UTC. Same as the panel.
|
|
113
|
+
assets : tuple of str
|
|
114
|
+
Asset names, column order.
|
|
115
|
+
values : dict of str to ndarray
|
|
116
|
+
Named pipeline outputs, each ``(T, N)``. These are the full arrays,
|
|
117
|
+
including rows a screen would drop and including warmup NaNs.
|
|
118
|
+
screen : ndarray of bool, shape (T, N), or None
|
|
119
|
+
The pipeline screen, or ``None`` if the pipeline has no screen.
|
|
120
|
+
nodes : dict of Term to ndarray
|
|
121
|
+
Every node's ``(T, N)`` values, including inputs. The live engine
|
|
122
|
+
warms its ring buffers from this.
|
|
123
|
+
recursive_state : dict of Term to ndarray or None
|
|
124
|
+
Last sealed row of each recursive term.
|
|
125
|
+
|
|
126
|
+
Examples
|
|
127
|
+
--------
|
|
128
|
+
>>> import numpy as np
|
|
129
|
+
>>> import factorflow as ff
|
|
130
|
+
>>> close = np.arange(6, dtype=float).reshape(3, 2)
|
|
131
|
+
>>> panel = ff.Panel(np.arange(3), ("A", "B"), {"close": close})
|
|
132
|
+
>>> out = ff.BatchEngine().run(ff.Pipeline({"lag": ff.Column("close").shift(1)}), panel)
|
|
133
|
+
>>> out.values["lag"][2].tolist()
|
|
134
|
+
[2.0, 3.0]
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
times: np.ndarray
|
|
138
|
+
assets: tuple[str, ...]
|
|
139
|
+
values: dict[str, np.ndarray]
|
|
140
|
+
screen: np.ndarray | None
|
|
141
|
+
nodes: dict[Term, np.ndarray]
|
|
142
|
+
recursive_state: dict[Term, np.ndarray | None]
|
|
143
|
+
|
|
144
|
+
def frame(self, open_last: bool = False) -> pl.DataFrame:
|
|
145
|
+
"""Stack the screened outputs into a long frame.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
open_last : bool, optional
|
|
150
|
+
If ``True``, the last bar is flagged ``is_open``. Use that when the
|
|
151
|
+
last row was an open bar treated as final. Default is ``False``
|
|
152
|
+
(every row sealed).
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
polars.DataFrame
|
|
157
|
+
Columns ``ts`` (UTC datetime, ns), ``asset``, ``is_open``, then
|
|
158
|
+
each output. Rows that fail the screen are omitted. With no screen,
|
|
159
|
+
every asset and bar is present.
|
|
160
|
+
"""
|
|
161
|
+
t, n = len(self.times), len(self.assets)
|
|
162
|
+
keep = self.screen if self.screen is not None else np.ones((t, n), dtype=bool)
|
|
163
|
+
ti, ai = np.nonzero(keep)
|
|
164
|
+
is_open = np.zeros(ti.shape, dtype=bool)
|
|
165
|
+
if open_last and t:
|
|
166
|
+
is_open = ti == t - 1
|
|
167
|
+
data = {
|
|
168
|
+
"ts": pl.Series(self.times[ti]).cast(pl.Datetime("ns", "UTC")),
|
|
169
|
+
"asset": [self.assets[i] for i in ai],
|
|
170
|
+
"is_open": is_open,
|
|
171
|
+
}
|
|
172
|
+
for k, v in self.values.items():
|
|
173
|
+
data[k] = v[ti, ai]
|
|
174
|
+
return pl.DataFrame(data)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class BatchEngine:
|
|
178
|
+
"""Run a pipeline on a whole panel in one pass.
|
|
179
|
+
|
|
180
|
+
There is no constructor state. :meth:`run` compiles the pipeline if needed
|
|
181
|
+
and evaluates nodes in height order.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def run(self, pipeline: Pipeline | Graph, panel: Panel) -> BatchResult:
|
|
185
|
+
"""Evaluate ``pipeline`` on every row of ``panel``.
|
|
186
|
+
|
|
187
|
+
Parameters
|
|
188
|
+
----------
|
|
189
|
+
pipeline : Pipeline or Graph
|
|
190
|
+
A :class:`~factorflow.graph.Pipeline`, or the graph from
|
|
191
|
+
:func:`~factorflow.graph.compile_pipeline`.
|
|
192
|
+
panel : Panel
|
|
193
|
+
Must contain every input column the pipeline reads.
|
|
194
|
+
|
|
195
|
+
Returns
|
|
196
|
+
-------
|
|
197
|
+
BatchResult
|
|
198
|
+
Full ``(T, N)`` outputs. The first ``window - 1`` rows of a
|
|
199
|
+
windowed factor are missing.
|
|
200
|
+
|
|
201
|
+
Raises
|
|
202
|
+
------
|
|
203
|
+
KeyError
|
|
204
|
+
If a required column is absent.
|
|
205
|
+
TypeError
|
|
206
|
+
If a pipeline column is a bare constant. Raised while compiling.
|
|
207
|
+
"""
|
|
208
|
+
g = pipeline if isinstance(pipeline, Graph) else compile_pipeline(pipeline)
|
|
209
|
+
vals: dict[Term, np.ndarray] = {}
|
|
210
|
+
rec: dict[Term, np.ndarray | None] = {}
|
|
211
|
+
for t in g.order:
|
|
212
|
+
if isinstance(t, _Input):
|
|
213
|
+
vals[t] = input_array(panel, t)
|
|
214
|
+
continue
|
|
215
|
+
out, last = compute_node(t, [vals[i] for i in t.inputs], None)
|
|
216
|
+
vals[t] = out
|
|
217
|
+
if t.recursive:
|
|
218
|
+
rec[t] = last
|
|
219
|
+
outs = {k: vals[v] for k, v in g.outputs.items()}
|
|
220
|
+
screen = vals[g.screen] if g.screen is not None else None
|
|
221
|
+
return BatchResult(panel.times, panel.assets, outs, screen, vals, rec)
|