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.
@@ -0,0 +1,147 @@
1
+ """Example pipelines used by the demo, the validation and the benchmarks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from factorflow.graph import Pipeline
6
+ from factorflow.sim.market import STYLES
7
+ from factorflow.terms import BarData, Classifier, Column, Factor, Filter, where
8
+
9
+ close, volume, dollar = BarData.close, BarData.volume, BarData.dollar
10
+ high, low = BarData.high, BarData.low
11
+ industry: Classifier = Column("industry", Classifier)
12
+ signal: Factor = Column("signal")
13
+
14
+
15
+ def beta_window_for(history_days: int) -> int:
16
+ """200 days, or the history less a month when that is shorter (at least 20)."""
17
+ return max(20, min(200, history_days - 30))
18
+
19
+
20
+ def daily_pipeline(n_assets: int, beta_window: int = 200) -> Pipeline:
21
+ """Return the daily research pipeline.
22
+
23
+ Outputs are returns, beta to the equal-weight market, styles, the planted
24
+ signal, and decile screens.
25
+
26
+ Parameters
27
+ ----------
28
+ n_assets : int
29
+ Names in the book. Sets the decile width to ``max(1, n_assets // 10)``.
30
+ beta_window : int, optional
31
+ Rolling beta length in daily bars. Default is 200.
32
+
33
+ Returns
34
+ -------
35
+ Pipeline
36
+ """
37
+ r = close.pct_change(1)
38
+ logret = (close / close.shift(1)).log()
39
+ mkt = r - r.demean() # equal-weight market return, broadcast to every asset
40
+ universe: Filter = (dollar.rolling_mean(20) > 0) & close.notnull()
41
+ decile = max(1, n_assets // 10)
42
+ cols = {
43
+ "ret": r,
44
+ "logret": logret,
45
+ "mkt": mkt,
46
+ "beta": r.rolling_beta(mkt, beta_window),
47
+ "vol20": r.rolling_std(20),
48
+ "mom20": close.pct_change(20),
49
+ "ind_rel": r.demean(groupby=industry),
50
+ "signal_z": signal.zscore(mask=universe),
51
+ "long": signal.top(decile, mask=universe),
52
+ "short": signal.bottom(decile, mask=universe),
53
+ **{f"{k}_z": Column(k).zscore(mask=universe) for k in STYLES},
54
+ }
55
+ return Pipeline(cols, screen=universe)
56
+
57
+
58
+ def minute_pipeline() -> Pipeline:
59
+ """Intraday pipeline shown live by the demo (about 40 graph nodes)."""
60
+ r = close.pct_change(1)
61
+ liquid: Filter = volume.rolling_mean(30) > 0
62
+ vwap30 = dollar.rolling_sum(30) / volume.rolling_sum(30)
63
+ mom30 = close.pct_change(30)
64
+ vol30 = r.rolling_std(30)
65
+ score = (
66
+ mom30.zscore(groupby=industry, mask=liquid) + signal.zscore(mask=liquid) - vol30.zscore(mask=liquid)
67
+ )
68
+ cols = {
69
+ "close": close,
70
+ "ret1": r,
71
+ "mom30": mom30,
72
+ "vol30": vol30,
73
+ "vwap_dev": close / vwap30 - 1.0,
74
+ "rel_vol": volume / volume.rolling_mean(60),
75
+ "range30": (high.rolling_max(30) - low.rolling_min(30)) / close,
76
+ "ewm_ret": r.ewm_mean(10.0),
77
+ "score": score,
78
+ "rank": score.rank(mask=liquid, ascending=False),
79
+ "long": where(score.top(10, mask=liquid), 1.0, 0.0),
80
+ }
81
+ return Pipeline(cols, screen=liquid)
82
+
83
+
84
+ def crypto_pipeline() -> Pipeline:
85
+ """24/7 minute pipeline for the crypto majors (beta to BTC via the cross-section)."""
86
+ r = close.pct_change(1)
87
+ btc = r - r.demean() # with 3 names this is the equal-weight crypto market
88
+ return Pipeline(
89
+ {
90
+ "close": close,
91
+ "ret1": r,
92
+ "mom60": close.pct_change(60),
93
+ "vol60": r.rolling_std(60),
94
+ "beta240": r.rolling_beta(btc, 240),
95
+ "dollar60": dollar.rolling_sum(60),
96
+ "mom_rank": close.pct_change(60).rank(ascending=False),
97
+ }
98
+ )
99
+
100
+
101
+ def wide_pipeline(n_factors: int = 100) -> Pipeline:
102
+ """Build the wide benchmark pipeline.
103
+
104
+ This is the 100-factor pipeline used by ``benchmarks/bench_minute.py``,
105
+ including industry groups. ``n_factors`` sets how many style-like terms
106
+ are added.
107
+ """
108
+ c, v, h, lo = close, volume, high, low
109
+ r = c.pct_change(1)
110
+ liquid = v.rolling_mean(30) > 0
111
+ windows = [5, 10, 15, 20, 30, 60, 90, 120]
112
+ gens = [
113
+ lambda w: c.pct_change(w),
114
+ lambda w: r.rolling_std(w),
115
+ lambda w: c / c.rolling_mean(w) - 1.0,
116
+ lambda w: v.rolling_mean(w),
117
+ lambda w: (c - lo.rolling_min(w)) / (h.rolling_max(w) - lo.rolling_min(w)),
118
+ lambda w: c.ts_rank(w),
119
+ lambda w: r.rolling_corr(v.pct_change(1), w),
120
+ lambda w: r.ewm_mean(w / 2),
121
+ lambda w: c.pct_change(w).zscore(mask=liquid, groupby=industry),
122
+ lambda w: r.rolling_std(w).rank(mask=liquid),
123
+ lambda w: (r.rolling_mean(w) / r.rolling_std(w)).demean(groupby=industry),
124
+ lambda w: r.rolling_beta(r.demean(), w),
125
+ ]
126
+ cols: dict = {}
127
+ i = 0
128
+ while len(cols) < n_factors:
129
+ g = gens[i % len(gens)]
130
+ w = windows[(i // len(gens)) % len(windows)]
131
+ cols[f"f{len(cols):03d}"] = g(w)
132
+ i += 1
133
+ return Pipeline(cols, screen=liquid)
134
+
135
+
136
+ def event_pipeline() -> Pipeline:
137
+ """Per-asset time-series pipeline for event (tick/volume/dollar) bars."""
138
+ r = close.pct_change(1)
139
+ return Pipeline(
140
+ {
141
+ "ret": r,
142
+ "vol20": r.rolling_std(20),
143
+ "mom10": close.pct_change(10),
144
+ "ewm": r.ewm_mean(5.0),
145
+ "logc": close.log(),
146
+ }
147
+ )
@@ -0,0 +1,385 @@
1
+ """Replay a trade stream through :class:`~factorflow.LiveEngine` and check it against batch.
2
+
3
+ ``replay`` streams trades in timestamp order. Before each trade it calls ``advance(ts)``
4
+ (sealing every bar whose ``end + grace`` has passed, forward-filling empty bars), then
5
+ ``tick``. The open bar is revised on a market-time cadence (``revise_every_ns``, like a
6
+ dashboard refresh) and/or every ``revise_every_ticks`` trades. With ``speed`` set, the
7
+ driver sleeps so that market time runs ``speed`` times faster than the wall clock (1.0 =
8
+ real time); without it, it runs as fast as possible.
9
+
10
+ Non-bar inputs (signal, classifiers, style exposures) come from ``extras``: a
11
+ ``(times, {column: (T, N) array})`` pair aligned to bar ends; whenever a new bar opens, its
12
+ row is written with ``set_open``, exactly as a feed handler would.
13
+
14
+ ``verify_replay`` runs the batch engine on the same bars and asserts that every sealed live
15
+ row is bit-for-bit equal to the batch row (and every sampled open revision equals a batch run
16
+ on sealed history + the open bar treated as final).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from collections.abc import Callable, Mapping
23
+ from dataclasses import dataclass, field
24
+
25
+ import numpy as np
26
+
27
+ from factorflow.bars import FIELDS, Trades
28
+ from factorflow.batch import BatchEngine
29
+ from factorflow.events import EventBarBuilder, EventBars, EventPipeline, build_event_bars
30
+ from factorflow.graph import Graph, Pipeline, compile_pipeline
31
+ from factorflow.grid import NS, BarGrid
32
+ from factorflow.live import LiveEngine, Snapshot
33
+ from factorflow.panel import Panel
34
+
35
+
36
+ @dataclass
37
+ class ReplayStats:
38
+ """Counters from one :func:`replay` call.
39
+
40
+ Notes
41
+ -----
42
+ ``tick_s`` is seconds spent inside ``LiveEngine.tick``. ``revise_ms`` and
43
+ ``seal_ms`` are per-call latencies in milliseconds. ``sealed`` keeps every
44
+ sealed snapshot, in order. ``market_s`` is the span of trade timestamps, in seconds.
45
+ """
46
+
47
+ n_ticks: int = 0
48
+ wall_s: float = 0.0
49
+ market_s: float = 0.0
50
+ tick_s: float = 0.0 # time inside tick() (bar accumulation + routing)
51
+ revise_ms: list[float] = field(default_factory=list)
52
+ seal_ms: list[float] = field(default_factory=list)
53
+ sealed: list[Snapshot] = field(default_factory=list)
54
+ open_checked: int = 0
55
+
56
+ @property
57
+ def ticks_per_s(self) -> float:
58
+ """Return trades processed per wall-clock second.
59
+
60
+ Returns
61
+ -------
62
+ float
63
+ NaN if no wall time was recorded.
64
+ """
65
+ return self.n_ticks / self.wall_s if self.wall_s > 0 else float("nan")
66
+
67
+ def _q(self, xs: list[float], q: float) -> float:
68
+ return float(np.quantile(xs, q)) if xs else float("nan")
69
+
70
+ def summary(self) -> dict[str, float]:
71
+ """Return the counters as a flat dict of floats.
72
+
73
+ Returns
74
+ -------
75
+ dict of str to float
76
+ Includes tick throughput, speedup against real time, tick
77
+ microseconds, and revise and seal percentiles.
78
+ """
79
+ return {
80
+ "ticks": self.n_ticks,
81
+ "wall_s": self.wall_s,
82
+ "ticks_per_s": self.ticks_per_s,
83
+ "speedup_vs_realtime": self.market_s / self.wall_s if self.wall_s else float("nan"),
84
+ "tick_us": 1e6 * self.tick_s / self.n_ticks if self.n_ticks else float("nan"),
85
+ "revises": len(self.revise_ms),
86
+ "revise_ms_p50": self._q(self.revise_ms, 0.5),
87
+ "revise_ms_p95": self._q(self.revise_ms, 0.95),
88
+ "revise_ms_p99": self._q(self.revise_ms, 0.99),
89
+ "seals": len(self.seal_ms),
90
+ "seal_ms_p50": self._q(self.seal_ms, 0.5),
91
+ "seal_ms_p95": self._q(self.seal_ms, 0.95),
92
+ }
93
+
94
+
95
+ class Extras:
96
+ """Row lookup for non-bar input columns: the row of the first bar end >= ``open_end``."""
97
+
98
+ def __init__(self, times: np.ndarray, columns: Mapping[str, np.ndarray]):
99
+ """Store non-bar columns aligned to bar ends.
100
+
101
+ Parameters
102
+ ----------
103
+ times : array of int64
104
+ Bar ends, nanoseconds UTC, non-decreasing.
105
+ columns : mapping of str to ndarray
106
+ Each array has shape ``(T, N)``.
107
+ """
108
+ self.times = np.asarray(times, np.int64)
109
+ self.columns = dict(columns)
110
+
111
+ def row(self, open_end: int) -> dict[str, np.ndarray]:
112
+ """Return the column row for the bar that contains ``open_end``.
113
+
114
+ Parameters
115
+ ----------
116
+ open_end : int
117
+ Open bar end, nanoseconds UTC.
118
+
119
+ Returns
120
+ -------
121
+ dict of str to ndarray
122
+ One ``(N,)`` row per column. If ``open_end`` is past the last time, the last row is used.
123
+ """
124
+ r = min(int(np.searchsorted(self.times, open_end, side="left")), len(self.times) - 1)
125
+ return {k: v[r] for k, v in self.columns.items()}
126
+
127
+
128
+ def replay(
129
+ engine: LiveEngine,
130
+ trades: Trades,
131
+ *,
132
+ start: int = 0,
133
+ stop: int | None = None,
134
+ revise_every_ns: int | None = NS,
135
+ revise_every_ticks: int | None = None,
136
+ extras: Extras | None = None,
137
+ speed: float | None = None,
138
+ until: int | None = None,
139
+ on_revise: Callable[[Snapshot], None] | None = None,
140
+ on_seal: Callable[[Snapshot], None] | None = None,
141
+ check_open: Callable[[LiveEngine, Snapshot], None] | None = None,
142
+ check_open_every: int = 0,
143
+ stats: ReplayStats | None = None,
144
+ ) -> ReplayStats:
145
+ """Stream ``trades[start:stop]`` through ``engine`` (which must have a grid).
146
+
147
+ ``until`` (ns) finally advances the clock to seal every bar ending by then.
148
+ ``check_open(engine, snap)`` is called on every ``check_open_every``-th revision.
149
+ """
150
+ if engine.grid is None:
151
+ raise ValueError("replay needs a LiveEngine with a BarGrid")
152
+ st = stats or ReplayStats()
153
+ stop = len(trades) if stop is None else stop
154
+ a_, t_, p_, q_ = trades.asset, trades.ts, trades.price, trades.size
155
+ pc = time.perf_counter
156
+ wall0 = pc()
157
+ t_first = int(t_[start]) if stop > start else 0
158
+ last_open = None
159
+
160
+ def sealed_now(snaps: list[Snapshot], dt: float) -> None:
161
+ if not snaps:
162
+ return
163
+ per = 1e3 * dt / len(snaps)
164
+ for s in snaps:
165
+ st.seal_ms.append(per)
166
+ st.sealed.append(s)
167
+ if on_seal is not None:
168
+ on_seal(s)
169
+
170
+ def open_bar_inputs() -> None:
171
+ nonlocal last_open
172
+ if extras is not None and engine.open_end != last_open:
173
+ for k, v in extras.row(int(engine.open_end)).items(): # type: ignore[arg-type]
174
+ if k in engine.extra: # columns the pipeline does not read are skipped
175
+ engine.set_open(k, v)
176
+ last_open = engine.open_end
177
+
178
+ open_bar_inputs()
179
+ next_revise = (t_first // revise_every_ns + 1) * revise_every_ns if revise_every_ns else None
180
+ since = 0
181
+ n_rev = 0
182
+ for i in range(start, stop):
183
+ ts = int(t_[i])
184
+ if speed:
185
+ # pace market time; keep sealing bars on the clock while no trades arrive
186
+ while (lag := (ts - t_first) / NS / speed - (pc() - wall0)) > 0:
187
+ time.sleep(min(lag, 0.05))
188
+ now = t_first + int((pc() - wall0) * speed * NS)
189
+ t0 = pc()
190
+ snaps = engine.advance(min(now, ts))
191
+ sealed_now(snaps, pc() - t0)
192
+ if snaps:
193
+ open_bar_inputs()
194
+ # market-time revisions due before this trade
195
+ if next_revise is not None and ts >= next_revise:
196
+ if since:
197
+ n_rev += 1
198
+ _timed_revise(engine, st, on_revise, check_open, check_open_every, n_rev)
199
+ since = 0
200
+ next_revise = (ts // revise_every_ns + 1) * revise_every_ns # type: ignore[operator]
201
+ t0 = pc()
202
+ snaps = engine.advance(ts)
203
+ sealed_now(snaps, pc() - t0)
204
+ if snaps:
205
+ open_bar_inputs()
206
+ t0 = pc()
207
+ engine.tick(int(a_[i]), ts, float(p_[i]), float(q_[i]))
208
+ st.tick_s += pc() - t0
209
+ since += 1
210
+ if revise_every_ticks and since >= revise_every_ticks:
211
+ n_rev += 1
212
+ _timed_revise(engine, st, on_revise, check_open, check_open_every, n_rev)
213
+ since = 0
214
+ if since:
215
+ n_rev += 1
216
+ _timed_revise(engine, st, on_revise, check_open, check_open_every, n_rev)
217
+ if until is not None:
218
+ t0 = pc()
219
+ snaps = engine.advance(until)
220
+ sealed_now(snaps, pc() - t0)
221
+ st.n_ticks += stop - start
222
+ st.wall_s += pc() - wall0
223
+ if stop > start:
224
+ st.market_s += (int(t_[stop - 1]) - t_first) / NS
225
+ return st
226
+
227
+
228
+ def _timed_revise(engine, st, on_revise, check_open, every, n) -> None:
229
+ t0 = time.perf_counter()
230
+ snap = engine.revise()
231
+ st.revise_ms.append(1e3 * (time.perf_counter() - t0))
232
+ if on_revise is not None:
233
+ on_revise(snap)
234
+ if check_open is not None and every and n % every == 0:
235
+ check_open(engine, snap)
236
+ st.open_checked += 1
237
+
238
+
239
+ # ====================================================================== verification
240
+ def _equal(a: np.ndarray, b: np.ndarray) -> bool:
241
+ return bool(np.array_equal(a, b, equal_nan=a.dtype.kind == "f"))
242
+
243
+
244
+ @dataclass
245
+ class VerifyResult:
246
+ """The outcome of comparing a live replay with batch.
247
+
248
+ Notes
249
+ -----
250
+ ``mismatches`` is empty when every checked row matched.
251
+ ``sealed_rows`` is how many bars the live engine sealed.
252
+ """
253
+
254
+ grid: str
255
+ sealed_rows: int
256
+ open_checked: int
257
+ mismatches: list[str]
258
+ stats: ReplayStats
259
+
260
+ @property
261
+ def ok(self) -> bool:
262
+ """Return whether the replay matched and sealed at least one row.
263
+
264
+ Returns
265
+ -------
266
+ bool
267
+ ``True`` when ``mismatches`` is empty and ``sealed_rows > 0``.
268
+ """
269
+ return not self.mismatches and self.sealed_rows > 0
270
+
271
+
272
+ def verify_replay(
273
+ pipeline: Pipeline | Graph,
274
+ full: Panel,
275
+ trades: Trades,
276
+ grid: BarGrid,
277
+ history_rows: int,
278
+ *,
279
+ extra_columns: tuple[str, ...] = (),
280
+ revise_every_ns: int | None = NS,
281
+ revise_every_ticks: int | None = None,
282
+ check_open_every: int = 0,
283
+ partial_threshold: float = 0.0,
284
+ label: str = "",
285
+ ) -> VerifyResult:
286
+ """Live replay of the trades after ``full.head(history_rows)`` vs batch on ``full``.
287
+
288
+ ``full`` must be the batch bars of the same trades on ``grid`` (``build_time_bars``),
289
+ optionally preceded by history rows, plus any ``extra_columns``.
290
+ """
291
+ g = pipeline if isinstance(pipeline, Graph) else compile_pipeline(pipeline)
292
+ batch = BatchEngine().run(g, full)
293
+ hist = full.head(history_rows)
294
+ live = LiveEngine(g, hist, grid, partial_threshold=partial_threshold)
295
+ extras = Extras(full.times, {k: full.columns[k] for k in extra_columns}) if extra_columns else None
296
+ start = int(np.searchsorted(trades.ts, int(full.times[history_rows - 1]), side="left"))
297
+ mism: list[str] = []
298
+
299
+ def check_open(eng: LiveEngine, snap: Snapshot) -> None:
300
+ n_sealed = history_rows + len(stats.sealed)
301
+ bar_cols = (*FIELDS, "has_trade")
302
+ row = {}
303
+ for k in full.columns:
304
+ if k in bar_cols:
305
+ row[k] = eng.bar.row(k)
306
+ elif k in eng.extra:
307
+ row[k] = eng.extra[k]
308
+ else: # not a pipeline input; any value will do
309
+ row[k] = full.columns[k][min(n_sealed, len(full.times) - 1)]
310
+ ref = BatchEngine().run(g, full.head(n_sealed).with_row(row, int(eng.open_end))) # type: ignore[arg-type]
311
+ for k, v in snap.values.items():
312
+ if not _equal(v, ref.values[k][n_sealed]):
313
+ mism.append(f"open row {n_sealed} column {k}")
314
+
315
+ stats = ReplayStats()
316
+ replay(
317
+ live,
318
+ trades,
319
+ start=start,
320
+ revise_every_ns=revise_every_ns,
321
+ revise_every_ticks=revise_every_ticks,
322
+ extras=extras,
323
+ until=int(full.times[-1]) + grid.grace_ns,
324
+ check_open=check_open if check_open_every else None,
325
+ check_open_every=check_open_every,
326
+ stats=stats,
327
+ )
328
+ sealed = stats.sealed
329
+ expect = len(full.times) - history_rows
330
+ if len(sealed) != expect:
331
+ mism.append(f"sealed {len(sealed)} bars, expected {expect}")
332
+ for j, snap in enumerate(sealed[:expect]):
333
+ r = history_rows + j
334
+ if snap.ts != int(full.times[r]):
335
+ mism.append(f"sealed bar {j} stamped {snap.ts}, batch row {r} is {int(full.times[r])}")
336
+ break
337
+ for k, v in snap.values.items():
338
+ if not _equal(v, batch.values[k][r]):
339
+ mism.append(f"sealed row {r} column {k}")
340
+ if batch.screen is not None and snap.screen is not None and not _equal(snap.screen, batch.screen[r]):
341
+ mism.append(f"sealed row {r} screen")
342
+ return VerifyResult(label or repr(grid), len(sealed), stats.open_checked, mism, stats)
343
+
344
+
345
+ def verify_event_pipeline(
346
+ pipeline: Pipeline | Graph,
347
+ trades: Trades,
348
+ asset: int,
349
+ builder_factory: Callable[[], EventBarBuilder],
350
+ history_frac: float = 0.3,
351
+ label: str = "",
352
+ ) -> VerifyResult:
353
+ """One asset's event bars (tick/volume/dollar): live EventPipeline vs batch."""
354
+ g = pipeline if isinstance(pipeline, Graph) else compile_pipeline(pipeline)
355
+ m = trades.asset == asset
356
+ ts, px, sz = trades.ts[m], trades.price[m], trades.size[m]
357
+ b = builder_factory()
358
+ bars, _ = build_event_bars(ts, px, sz, b.kind, b.rule, b.overshoot, b.day_key)
359
+ batch = BatchEngine().run(g, bars.panel())
360
+ cut = int(len(ts) * history_frac)
361
+ hb = builder_factory()
362
+ hist_bars = EventBars.from_bars(hb.update_many(ts[:cut].tolist(), px[:cut].tolist(), sz[:cut].tolist()))
363
+ # resume the same builder state: replay the history trades into it, then go live
364
+ ep = EventPipeline(g, hb, history=hist_bars)
365
+ stats = ReplayStats()
366
+ t0 = time.perf_counter()
367
+ sealed: list[Snapshot] = []
368
+ for t, p, q in zip(ts[cut:].tolist(), px[cut:].tolist(), sz[cut:].tolist(), strict=True):
369
+ for s in ep.update(t, p, q):
370
+ if not s.is_open:
371
+ sealed.append(s)
372
+ stats.wall_s = time.perf_counter() - t0
373
+ stats.n_ticks = len(ts) - cut
374
+ mism: list[str] = []
375
+ h = len(hist_bars)
376
+ if h + len(sealed) != len(bars):
377
+ mism.append(f"live sealed {h + len(sealed)} bars, batch has {len(bars)}")
378
+ for j, s in enumerate(sealed):
379
+ r = h + j
380
+ if r >= len(bars):
381
+ break
382
+ for k, v in s.values.items():
383
+ if not _equal(v, batch.values[k][r]):
384
+ mism.append(f"event row {r} column {k}")
385
+ return VerifyResult(label or f"event[{asset}]", len(sealed), 0, mism, stats)