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