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/graph.py ADDED
@@ -0,0 +1,177 @@
1
+ """Compile a pipeline's terms into a deduplicated, height-ordered graph.
2
+
3
+ Compilation does three things the executors rely on:
4
+
5
+ * common-subexpression elimination, so ``close.rolling_mean(5)`` written twice
6
+ is one node;
7
+ * a height (topological) order, so a node runs after its inputs;
8
+ * a lookback per node, the number of sealed rows a consumer needs before the
9
+ open row. The live engine keeps that many rows in a ring buffer.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ from factorflow.terms import Constant, ConstFilter, Filter, Term, _Input
17
+
18
+
19
+ @dataclass(eq=False)
20
+ class Pipeline:
21
+ """Named outputs, and an optional screen, over a term graph.
22
+
23
+ Parameters
24
+ ----------
25
+ columns : dict of str to Term
26
+ Output name to term. Names become columns of
27
+ :meth:`~factorflow.batch.BatchResult.frame` and keys of
28
+ :attr:`~factorflow.live.Snapshot.values`. A column must be a real
29
+ term, not a Python number.
30
+ screen : Filter or None, optional
31
+ Rows that are ``False`` are dropped from frames. The arrays in
32
+ ``values`` are still the full cross-section. Default is no screen.
33
+
34
+ Raises
35
+ ------
36
+ TypeError
37
+ If a column is not a :class:`~factorflow.terms.Term`, or is a constant.
38
+
39
+ Examples
40
+ --------
41
+ >>> import factorflow as ff
42
+ >>> close = ff.Column("close")
43
+ >>> pipe = ff.Pipeline({"mom": close.rolling_mean(5)}, screen=close > 0)
44
+ >>> pipe.columns["mom"].window
45
+ 5
46
+ """
47
+
48
+ columns: dict[str, Term]
49
+ screen: Filter | None = None
50
+
51
+ def __post_init__(self) -> None:
52
+ """Reject constants and non-terms used as pipeline columns.
53
+
54
+ Raises
55
+ ------
56
+ TypeError
57
+ If any column is not a non-constant term.
58
+ """
59
+ for name, t in self.columns.items():
60
+ if not isinstance(t, Term) or isinstance(t, (Constant, ConstFilter)):
61
+ raise TypeError(f"pipeline column {name!r} must be a non-constant term")
62
+
63
+
64
+ @dataclass(eq=False)
65
+ class Graph:
66
+ """A compiled pipeline.
67
+
68
+ Parameters
69
+ ----------
70
+ order : list of Term
71
+ Nodes in increasing height, inputs first.
72
+ inputs : list of Term
73
+ Leaf terms. Each has a ``name`` attribute, the panel column it reads.
74
+ outputs : dict of str to Term
75
+ The pipeline's named columns, after common-subexpression elimination.
76
+ screen : Term or None
77
+ The screen, or ``None``.
78
+ lookback : dict of Term to int
79
+ How many rows of this node's output its consumers need. At least 1.
80
+ The live ring buffer keeps ``lookback - 1`` sealed rows plus the open slot.
81
+ heights : dict of Term to int
82
+ ``1 + max(input heights)``, and 0 for inputs.
83
+ consumers : dict of Term to list of Term
84
+ Nodes that read this node.
85
+ """
86
+
87
+ order: list[Term] # topological, by height
88
+ inputs: list[Term] # input (leaf) terms
89
+ outputs: dict[str, Term]
90
+ screen: Term | None
91
+ lookback: dict[Term, int] = field(default_factory=dict) # rows of each node's output its consumers need
92
+ heights: dict[Term, int] = field(default_factory=dict)
93
+ consumers: dict[Term, list[Term]] = field(default_factory=dict)
94
+
95
+ @property
96
+ def input_names(self) -> list[str]:
97
+ """Panel columns this graph reads, in execution order.
98
+
99
+ Returns
100
+ -------
101
+ list of str
102
+ Names of the input terms.
103
+ """
104
+ return [t.name for t in self.inputs] # type: ignore[attr-defined]
105
+
106
+
107
+ def _canonical(t: Term, memo: dict[int, Term], table: dict[tuple, Term]) -> Term:
108
+ """Common-subexpression elimination: structurally equal terms map to one node."""
109
+ if id(t) in memo:
110
+ return memo[id(t)]
111
+ if isinstance(t, _Input):
112
+ key: tuple = ("input", type(t).__name__, t.name)
113
+ else:
114
+ new_inputs = tuple(_canonical(i, memo, table) for i in t.inputs)
115
+ if new_inputs != t.inputs and any(a is not b for a, b in zip(new_inputs, t.inputs, strict=True)):
116
+ # rebuild with canonical inputs (terms are immutable, so copy and swap)
117
+ clone = object.__new__(type(t))
118
+ clone.__dict__.update(t.__dict__)
119
+ clone.inputs = new_inputs
120
+ if hasattr(t, "operands"):
121
+ rep = dict(zip([id(x) for x in t.inputs], new_inputs, strict=True))
122
+ clone.operands = tuple(rep.get(id(o), o) for o in t.operands) # type: ignore[attr-defined]
123
+ t = clone
124
+ key = t._key()
125
+ out = table.setdefault(key, t)
126
+ memo[id(t)] = out
127
+ return out
128
+
129
+
130
+ def compile_pipeline(p: Pipeline) -> Graph:
131
+ """Turn a pipeline into a :class:`Graph`.
132
+
133
+ Parameters
134
+ ----------
135
+ p : Pipeline
136
+ The expression to compile. Compiling twice is safe; nodes are immutable.
137
+
138
+ Returns
139
+ -------
140
+ Graph
141
+ Deduplicated nodes, topological order, and per-node lookback.
142
+
143
+ Notes
144
+ -----
145
+ Two terms are the same node when their class, parameters, window, and
146
+ input identities match. Inputs are keyed by type and column name, so
147
+ ``Column("close")`` is one node no matter how many times it is written.
148
+ """
149
+ memo: dict[int, Term] = {}
150
+ table: dict[tuple, Term] = {}
151
+ outputs = {k: _canonical(v, memo, table) for k, v in p.columns.items()}
152
+ screen = _canonical(p.screen, memo, table) if p.screen is not None else None
153
+
154
+ heights: dict[Term, int] = {}
155
+ consumers: dict[Term, list[Term]] = {}
156
+ order: list[Term] = []
157
+
158
+ def visit(t: Term) -> int:
159
+ if t in heights:
160
+ return heights[t]
161
+ h = 0
162
+ for i in t.inputs:
163
+ h = max(h, visit(i) + 1)
164
+ consumers.setdefault(i, [])
165
+ if t not in consumers[i]:
166
+ consumers[i].append(t)
167
+ heights[t] = h
168
+ order.append(t)
169
+ return h
170
+
171
+ roots = list(outputs.values()) + ([screen] if screen is not None else [])
172
+ for r in roots:
173
+ visit(r)
174
+ order.sort(key=lambda t: heights[t])
175
+ lookback = {t: max([c.window for c in consumers.get(t, [])] + [1]) for t in order}
176
+ inputs: list[Term] = [t for t in order if isinstance(t, _Input)]
177
+ return Graph(order, inputs, outputs, screen, lookback, heights, consumers)
factorflow/grid.py ADDED
@@ -0,0 +1,298 @@
1
+ """Clock-time bar grids: a trading calendar × a bar frequency.
2
+
3
+ ``BarGrid("XNYS", "1min")`` gives the regular-session minute bars of the NYSE calendar
4
+ (holidays and early closes from ``exchange_calendars``); ``BarGrid("24/7", "1min")`` is a
5
+ continuous UTC clock for crypto. Bars are half-open ``[start, end)`` and labeled by ``end``.
6
+ The last bar of a session is clipped to the session close.
7
+
8
+ ``grace`` is how long after a bar's end the live engine waits before sealing it (late prints
9
+ for the bar are still accepted during the grace period; trades stamped ``>= end`` that
10
+ arrive during it are held for the next bar). Default: 2 s intraday, 0 for daily bars.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from datetime import UTC, date, datetime, timedelta
17
+ from functools import cache
18
+
19
+ import numpy as np
20
+
21
+ NS = 1_000_000_000
22
+ _UNITS = {"s": 1, "sec": 1, "min": 60, "m": 60, "h": 3600, "d": 86400}
23
+
24
+
25
+ def parse_freq(freq: str) -> int:
26
+ """Parse a bar size into nanoseconds.
27
+
28
+ Parameters
29
+ ----------
30
+ freq : str
31
+ A count and a unit, for example ``"1min"``, ``"5min"``, ``"30s"``,
32
+ ``"1h"``, ``"1d"``. The count defaults to 1. Units are ``s``/``sec``,
33
+ ``min``/``m``, ``h``, and ``d``.
34
+
35
+ Returns
36
+ -------
37
+ int
38
+ Length of one bar in nanoseconds.
39
+
40
+ Raises
41
+ ------
42
+ ValueError
43
+ If ``freq`` is not of that form. Multi-day bars are rejected later by
44
+ :class:`BarGrid`, not here.
45
+ """
46
+ m = re.fullmatch(r"(\d*)\s*([a-z]+)", freq.strip().lower())
47
+ if not m or m.group(2) not in _UNITS:
48
+ raise ValueError(f"bad bar frequency {freq!r} (use e.g. '1min', '5min', '1h', '1d')")
49
+ return int(m.group(1) or 1) * _UNITS[m.group(2)] * NS
50
+
51
+
52
+ def to_ns(x: str | date | datetime | int | np.datetime64) -> int:
53
+ """Convert a timestamp to nanoseconds since the Unix epoch, UTC.
54
+
55
+ Parameters
56
+ ----------
57
+ x : str, datetime.date, datetime.datetime, int, or numpy.datetime64
58
+ An int is already nanoseconds and is returned unchanged. A naive
59
+ datetime is treated as UTC. A date is midnight UTC. A string is
60
+ parsed with :meth:`datetime.datetime.fromisoformat` (a trailing ``Z``
61
+ is not accepted; use ``+00:00``).
62
+
63
+ Returns
64
+ -------
65
+ int
66
+ Nanoseconds UTC.
67
+
68
+ Examples
69
+ --------
70
+ >>> to_ns("2026-11-30T14:30:00+00:00")
71
+ 1796049000000000000
72
+ """
73
+ if isinstance(x, (int, np.integer)):
74
+ return int(x)
75
+ if isinstance(x, np.datetime64):
76
+ return int(x.astype("datetime64[ns]").astype(np.int64))
77
+ if isinstance(x, str):
78
+ x = datetime.fromisoformat(x)
79
+ if isinstance(x, datetime):
80
+ if x.tzinfo is None:
81
+ x = x.replace(tzinfo=UTC)
82
+ return int(x.timestamp()) * NS + x.microsecond * 1000
83
+ return int(datetime(x.year, x.month, x.day, tzinfo=UTC).timestamp()) * NS
84
+
85
+
86
+ @cache
87
+ def _calendar(name: str):
88
+ import exchange_calendars as xcals
89
+
90
+ return xcals.get_calendar(name)
91
+
92
+
93
+ class BarGrid:
94
+ """A calendar crossed with a bar frequency.
95
+
96
+ Parameters
97
+ ----------
98
+ calendar : str, optional
99
+ An :mod:`exchange_calendars` name, such as ``"XNYS"``, or ``"24/7"``
100
+ (also ``"247"``, ``"ALWAYS_OPEN"``, ``"CRYPTO"``) for a clock that
101
+ does not close. Default is ``"XNYS"``.
102
+ freq : str, optional
103
+ Bar size, parsed by :func:`parse_freq`. Default is ``"1d"``. Only
104
+ ``"1d"`` is allowed for daily bars; ``"2d"`` and longer are rejected.
105
+ grace : float or None, optional
106
+ Seconds after a bar's end before the live engine seals it. ``None``
107
+ means 0 for daily bars and 2 for anything finer. Late prints inside
108
+ the grace are still applied to the bar. A trade stamped at or after
109
+ ``end`` is held for the next bar.
110
+
111
+ Raises
112
+ ------
113
+ ValueError
114
+ If ``freq`` is a multi-day size.
115
+
116
+ Examples
117
+ --------
118
+ A full NYSE session is 390 one-minute bars. The day after Thanksgiving
119
+ 2026 closes at 13:00 America/New_York and has 210::
120
+
121
+ grid = BarGrid("XNYS", "1min")
122
+ len(grid.bars("2026-11-30", "2026-11-30")[1]) # 390
123
+ len(grid.bars("2026-11-27", "2026-11-27")[1]) # 210
124
+ """
125
+
126
+ def __init__(self, calendar: str = "XNYS", freq: str = "1d", grace: float | None = None):
127
+ """Store the calendar, the bar length, and the seal grace.
128
+
129
+ Parameters
130
+ ----------
131
+ calendar : str, optional
132
+ Exchange calendar name, or a 24/7 alias. Default ``"XNYS"``.
133
+ freq : str, optional
134
+ Bar size. Default ``"1d"``.
135
+ grace : float or None, optional
136
+ Seal delay in seconds. ``None`` selects the default for ``freq``.
137
+
138
+ Raises
139
+ ------
140
+ ValueError
141
+ If ``freq`` spans more than one day.
142
+ """
143
+ self.calendar = calendar
144
+ self.freq = freq
145
+ self.step = parse_freq(freq)
146
+ self.daily = self.step >= 86400 * NS
147
+ self.continuous = calendar.upper() in ("24/7", "247", "ALWAYS_OPEN", "CRYPTO")
148
+ g = grace if grace is not None else (0.0 if self.daily else 2.0)
149
+ self.grace_ns = round(g * NS)
150
+ if self.daily and self.step != 86400 * NS:
151
+ raise ValueError("multi-day bars are not supported; use '1d'")
152
+ self._cache_s: np.ndarray | None = None
153
+ self._cache_e: np.ndarray | None = None
154
+
155
+ def __repr__(self) -> str:
156
+ """Return ``BarGrid(calendar, freq, grace=...s)``."""
157
+ return f"BarGrid({self.calendar!r}, {self.freq!r}, grace={self.grace_ns / NS}s)"
158
+
159
+ # ------------------------------------------------------------------ sessions
160
+ def _sessions(self, d0: date, d1: date) -> tuple[np.ndarray, np.ndarray]:
161
+ """Session (open, close) ns for session dates in [d0, d1]."""
162
+ if self.continuous:
163
+ days = np.arange(np.datetime64(d0, "D"), np.datetime64(d1, "D") + np.timedelta64(1, "D"))
164
+ opens = days.astype("datetime64[ns]").astype(np.int64)
165
+ return opens, opens + 86400 * NS
166
+ cal = _calendar(self.calendar)
167
+ lo = max(d0, cal.first_session.date())
168
+ hi = min(d1, cal.last_session.date())
169
+ if lo > hi:
170
+ return np.empty(0, np.int64), np.empty(0, np.int64)
171
+ sch = cal.schedule.loc[str(lo) : str(hi)]
172
+ opens = _ns(sch["open"])
173
+ closes = _ns(sch["close"])
174
+ return opens, closes
175
+
176
+ def bars(self, start, end) -> tuple[np.ndarray, np.ndarray]:
177
+ """List every bar whose session date is between ``start`` and ``end``.
178
+
179
+ Parameters
180
+ ----------
181
+ start, end
182
+ Inclusive session dates. Anything :func:`to_ns` accepts, or a
183
+ ``datetime.date``. The calendar's own first and last sessions
184
+ clip the range.
185
+
186
+ Returns
187
+ -------
188
+ starts, ends : ndarray of int64
189
+ Bar start and end, nanoseconds UTC, half-open ``[start, end)``.
190
+ Daily bars use the session open and the official close, so an
191
+ early close is a shorter bar rather than a missing one. Intraday
192
+ bars step by :attr:`step`; the last bar of a session is clipped
193
+ to the close and dropped if that would make it empty.
194
+
195
+ Notes
196
+ -----
197
+ A holiday inside the range contributes no bars. ``"24/7"`` has no
198
+ holidays: every UTC date from ``start`` through ``end`` is included.
199
+ """
200
+ d0 = _as_date(start)
201
+ d1 = _as_date(end)
202
+ opens, closes = self._sessions(d0, d1)
203
+ if self.daily:
204
+ return opens.copy(), closes.copy()
205
+ ss, es = [], []
206
+ for o, c in zip(opens, closes, strict=True):
207
+ e = np.arange(o + self.step, c + self.step, self.step, dtype=np.int64)
208
+ # Clip the last bar to the official close. An early close therefore
209
+ # shortens the day. Drop the bar if the clip leaves it empty.
210
+ e[-1] = min(e[-1], c)
211
+ if e.size > 1 and e[-1] <= e[-2]:
212
+ e = e[:-1]
213
+ s = np.r_[o, e[:-1]]
214
+ ss.append(s)
215
+ es.append(e)
216
+ if not es:
217
+ return np.empty(0, np.int64), np.empty(0, np.int64)
218
+ return np.concatenate(ss), np.concatenate(es)
219
+
220
+ def bars_after(self, after_end: int, count: int = 1) -> tuple[np.ndarray, np.ndarray]:
221
+ """Return the next bars after a sealed bar.
222
+
223
+ Parameters
224
+ ----------
225
+ after_end : int
226
+ A bar end, nanoseconds UTC. The returned bars have ends strictly
227
+ greater than this.
228
+ count : int, optional
229
+ How many bars. Default is 1. The live engine asks for one bar
230
+ on every seal.
231
+
232
+ Returns
233
+ -------
234
+ starts, ends : ndarray of int64
235
+ The next ``count`` bars.
236
+
237
+ Raises
238
+ ------
239
+ ValueError
240
+ If the calendar runs out before ``count`` bars exist (more than
241
+ about 4000 days ahead).
242
+
243
+ Notes
244
+ -----
245
+ A contiguous block of about a month is cached. Repeated seals inside
246
+ that block do not rebuild the exchange schedule.
247
+ """
248
+ ce = self._cache_e
249
+ if ce is not None and ce.size and ce[0] <= after_end:
250
+ k = int(np.searchsorted(ce, after_end, side="right"))
251
+ if ce.size - k >= count:
252
+ return self._cache_s[k : k + count].copy(), ce[k : k + count].copy() # type: ignore[index]
253
+ d = _as_date(after_end) - timedelta(days=1)
254
+ span = 35
255
+ while True:
256
+ s, e = self.bars(d, d + timedelta(days=span))
257
+ k = int(np.searchsorted(e, after_end, side="right"))
258
+ if e.size - k >= count:
259
+ self._cache_s, self._cache_e = s, e
260
+ return s[k : k + count].copy(), e[k : k + count].copy()
261
+ span *= 2
262
+ if span > 4000:
263
+ raise ValueError("calendar exhausted; extend the exchange_calendars range")
264
+
265
+ def bar_containing(self, ts: int) -> tuple[int, int]:
266
+ """Return the bar that contains ``ts``.
267
+
268
+ Parameters
269
+ ----------
270
+ ts : int
271
+ Nanoseconds UTC.
272
+
273
+ Returns
274
+ -------
275
+ start, end : int
276
+ The bar with ``start <= ts < end`` if ``ts`` falls in a session.
277
+ Implemented as the first bar whose end is strictly after ``ts``,
278
+ which is the ``[start, end)`` rule. A timestamp in a closed
279
+ session still resolves to the next open bar.
280
+ """
281
+ s, e = self.bars_after(ts, 1)
282
+ return int(s[0]), int(e[0])
283
+
284
+
285
+ def _ns(col) -> np.ndarray:
286
+ import pandas as pd
287
+
288
+ return np.asarray(pd.DatetimeIndex(col.array).tz_convert("UTC").as_unit("ns").asi8, dtype=np.int64)
289
+
290
+
291
+ def _as_date(x) -> date:
292
+ if isinstance(x, datetime):
293
+ return x.astimezone(UTC).date() if x.tzinfo else x.date()
294
+ if isinstance(x, date):
295
+ return x
296
+ if isinstance(x, str):
297
+ return datetime.fromisoformat(x).date()
298
+ return datetime.fromtimestamp(int(x) / NS, tz=UTC).date()