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/kernels.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Numeric kernels.
|
|
2
|
+
|
|
3
|
+
Every time-series kernel is written **once**, as a function of a block of input rows
|
|
4
|
+
``x`` of shape ``(T_in, n)`` and a window length ``W``. It returns the ``T_in - W + 1``
|
|
5
|
+
valid output rows. The batch executor calls it on the whole history, and the live
|
|
6
|
+
executor calls it on exactly ``W`` rows (the sealed tail plus the open row). Both run
|
|
7
|
+
the *same* sequence of elementwise IEEE operations (fixed left-to-right accumulation
|
|
8
|
+
over the window, no pairwise or BLAS reductions), so a row computed live is bit-for-bit
|
|
9
|
+
identical to the same row in batch.
|
|
10
|
+
|
|
11
|
+
Cross-sectional kernels take one row ``(n,)`` and are applied row by row in batch, so the
|
|
12
|
+
same argument holds.
|
|
13
|
+
|
|
14
|
+
NaN policy: a time-series window containing any NaN yields NaN (Pipeline semantics).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
F64 = np.float64
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _views(x: np.ndarray, w: int) -> list[np.ndarray]:
|
|
27
|
+
"""Lagged windows of ``x``, oldest first.
|
|
28
|
+
|
|
29
|
+
``views[k]`` is ``x[k : k + T_out]``. Adding those views left to right is the
|
|
30
|
+
same sequence of additions a live call makes on one window of length ``w``,
|
|
31
|
+
which is why a sealed row matches batch.
|
|
32
|
+
"""
|
|
33
|
+
t_out = x.shape[0] - w + 1
|
|
34
|
+
return [x[k : k + t_out] for k in range(w)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def wsum(x: np.ndarray, w: int) -> np.ndarray:
|
|
38
|
+
"""Return the rolling sum over ``w`` rows.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
x : ndarray of shape (T, N)
|
|
43
|
+
Input values. ``T`` is the number of rows (bars) and ``N`` the number of assets.
|
|
44
|
+
w : int
|
|
45
|
+
Window length in bars. Must be at least 1, and at most ``T``.
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
ndarray of shape (T - w + 1, N)
|
|
50
|
+
Sum of each window. A window that contains a NaN is NaN, because NaN poisons
|
|
51
|
+
the sum. The sum is accumulated oldest to newest, one addition at a time.
|
|
52
|
+
"""
|
|
53
|
+
v = _views(x, w)
|
|
54
|
+
acc = v[0].astype(F64, copy=True)
|
|
55
|
+
for k in range(1, w):
|
|
56
|
+
acc += v[k]
|
|
57
|
+
return acc
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def wmean(x: np.ndarray, w: int) -> np.ndarray:
|
|
61
|
+
"""Return the rolling mean over ``w`` rows.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
x : ndarray of shape (T, N)
|
|
66
|
+
Input values.
|
|
67
|
+
w : int
|
|
68
|
+
Window length in bars.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
ndarray of shape (T - w + 1, N)
|
|
73
|
+
``wsum(x, w) / w``. A window that contains a NaN is NaN.
|
|
74
|
+
"""
|
|
75
|
+
return wsum(x, w) / w
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def wvar(x: np.ndarray, w: int, ddof: int = 1) -> np.ndarray:
|
|
79
|
+
"""Return the rolling variance over ``w`` rows.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
x : ndarray of shape (T, N)
|
|
84
|
+
Input values.
|
|
85
|
+
w : int
|
|
86
|
+
Window length in bars. Needs ``w > ddof``.
|
|
87
|
+
ddof : int, optional
|
|
88
|
+
Delta degrees of freedom. The divisor is ``w - ddof``. Default is 1
|
|
89
|
+
(the sample variance).
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
ndarray of shape (T - w + 1, N)
|
|
94
|
+
Variance of each window. NaN if the window contains a NaN or the divisor
|
|
95
|
+
is not positive.
|
|
96
|
+
|
|
97
|
+
Notes
|
|
98
|
+
-----
|
|
99
|
+
The mean is the rolling sum divided by ``w``, and the squared deviations are
|
|
100
|
+
summed oldest to newest. That order matches a live call on the same window.
|
|
101
|
+
"""
|
|
102
|
+
v = _views(x, w)
|
|
103
|
+
with np.errstate(invalid="ignore", divide="ignore", over="ignore"):
|
|
104
|
+
m = wsum(x, w) / w
|
|
105
|
+
d = v[0] - m
|
|
106
|
+
ss = d * d
|
|
107
|
+
for k in range(1, w):
|
|
108
|
+
d = v[k] - m
|
|
109
|
+
ss += d * d
|
|
110
|
+
return ss / (w - ddof) if w - ddof > 0 else np.full_like(ss, np.nan)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def wstd(x: np.ndarray, w: int, ddof: int = 1) -> np.ndarray:
|
|
114
|
+
"""Return the rolling standard deviation over ``w`` rows.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
x : ndarray of shape (T, N)
|
|
119
|
+
Input values.
|
|
120
|
+
w : int
|
|
121
|
+
Window length in bars.
|
|
122
|
+
ddof : int, optional
|
|
123
|
+
Passed to :func:`wvar`. Default is 1.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
ndarray of shape (T - w + 1, N)
|
|
128
|
+
Square root of the rolling variance.
|
|
129
|
+
"""
|
|
130
|
+
return np.sqrt(wvar(x, w, ddof))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def wmin(x: np.ndarray, w: int) -> np.ndarray:
|
|
134
|
+
"""Return the rolling minimum over ``w`` rows.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
x : ndarray of shape (T, N)
|
|
139
|
+
Input values.
|
|
140
|
+
w : int
|
|
141
|
+
Window length in bars.
|
|
142
|
+
|
|
143
|
+
Returns
|
|
144
|
+
-------
|
|
145
|
+
ndarray of shape (T - w + 1, N)
|
|
146
|
+
Minimum of each window. NaN if the window contains a NaN (``np.minimum``
|
|
147
|
+
propagates it).
|
|
148
|
+
"""
|
|
149
|
+
v = _views(x, w)
|
|
150
|
+
acc = v[0].astype(F64, copy=True)
|
|
151
|
+
for k in range(1, w):
|
|
152
|
+
np.minimum(acc, v[k], out=acc)
|
|
153
|
+
return acc
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def wmax(x: np.ndarray, w: int) -> np.ndarray:
|
|
157
|
+
"""Return the rolling maximum over ``w`` rows.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
x : ndarray of shape (T, N)
|
|
162
|
+
Input values.
|
|
163
|
+
w : int
|
|
164
|
+
Window length in bars.
|
|
165
|
+
|
|
166
|
+
Returns
|
|
167
|
+
-------
|
|
168
|
+
ndarray of shape (T - w + 1, N)
|
|
169
|
+
Maximum of each window. NaN if the window contains a NaN.
|
|
170
|
+
"""
|
|
171
|
+
v = _views(x, w)
|
|
172
|
+
acc = v[0].astype(F64, copy=True)
|
|
173
|
+
for k in range(1, w):
|
|
174
|
+
np.maximum(acc, v[k], out=acc)
|
|
175
|
+
return acc
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def wtsrank(x: np.ndarray, w: int) -> np.ndarray:
|
|
179
|
+
"""Time-series rank of the newest value inside each window.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
x : ndarray of shape (T, N)
|
|
184
|
+
Input values.
|
|
185
|
+
w : int
|
|
186
|
+
Window length in bars.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
ndarray of shape (T - w + 1, N)
|
|
191
|
+
Fraction of the window that is less than or equal to the newest value,
|
|
192
|
+
in ``(1/w, ..., 1)``. NaN if the window contains a NaN. Ties with the
|
|
193
|
+
newest value count as less than or equal, so a constant window ranks 1.
|
|
194
|
+
"""
|
|
195
|
+
v = _views(x, w)
|
|
196
|
+
last = v[-1]
|
|
197
|
+
cnt = np.zeros(last.shape, dtype=F64)
|
|
198
|
+
nan = np.isnan(last)
|
|
199
|
+
for k in range(w):
|
|
200
|
+
cnt += v[k] <= last
|
|
201
|
+
nan |= np.isnan(v[k])
|
|
202
|
+
out = cnt / w
|
|
203
|
+
out[nan] = np.nan
|
|
204
|
+
return out
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def wcov(a: np.ndarray, b: np.ndarray, w: int, ddof: int = 1) -> np.ndarray:
|
|
208
|
+
"""Return the rolling covariance of two series.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
a, b : ndarray of shape (T, N)
|
|
213
|
+
Paired inputs. Compared elementwise; column ``j`` of ``a`` is paired
|
|
214
|
+
with column ``j`` of ``b``.
|
|
215
|
+
w : int
|
|
216
|
+
Window length in bars.
|
|
217
|
+
ddof : int, optional
|
|
218
|
+
Delta degrees of freedom. Default is 1.
|
|
219
|
+
|
|
220
|
+
Returns
|
|
221
|
+
-------
|
|
222
|
+
ndarray of shape (T - w + 1, N)
|
|
223
|
+
Covariance of each window. NaN if either window contains a NaN.
|
|
224
|
+
"""
|
|
225
|
+
va, vb = _views(a, w), _views(b, w)
|
|
226
|
+
with np.errstate(invalid="ignore", divide="ignore", over="ignore"):
|
|
227
|
+
ma, mb = wsum(a, w) / w, wsum(b, w) / w
|
|
228
|
+
acc = (va[0] - ma) * (vb[0] - mb)
|
|
229
|
+
for k in range(1, w):
|
|
230
|
+
acc += (va[k] - ma) * (vb[k] - mb)
|
|
231
|
+
return acc / (w - ddof)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def wcorr(a: np.ndarray, b: np.ndarray, w: int) -> np.ndarray:
|
|
235
|
+
"""Return the rolling Pearson correlation of two series.
|
|
236
|
+
|
|
237
|
+
Parameters
|
|
238
|
+
----------
|
|
239
|
+
a, b : ndarray of shape (T, N)
|
|
240
|
+
Paired inputs, column by column.
|
|
241
|
+
w : int
|
|
242
|
+
Window length in bars. A constant window has a zero standard deviation,
|
|
243
|
+
and the correlation is NaN there.
|
|
244
|
+
|
|
245
|
+
Returns
|
|
246
|
+
-------
|
|
247
|
+
ndarray of shape (T - w + 1, N)
|
|
248
|
+
``wcov(a, b, w) / (wstd(a, w) * wstd(b, w))`` with ``ddof`` 1.
|
|
249
|
+
"""
|
|
250
|
+
c = wcov(a, b, w)
|
|
251
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
252
|
+
return c / (wstd(a, w) * wstd(b, w))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def wshift(x: np.ndarray, w: int) -> np.ndarray:
|
|
256
|
+
"""Value from ``w - 1`` rows earlier.
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
x : ndarray of shape (T, N)
|
|
261
|
+
Input values.
|
|
262
|
+
w : int
|
|
263
|
+
One more than the lag. A lag of ``n`` bars is ``w = n + 1``, so the
|
|
264
|
+
output lines up with the other windowed kernels (``T - w + 1`` rows).
|
|
265
|
+
|
|
266
|
+
Returns
|
|
267
|
+
-------
|
|
268
|
+
ndarray of shape (T - w + 1, N)
|
|
269
|
+
``x`` truncated to the rows that have a full lag. This is a copy.
|
|
270
|
+
"""
|
|
271
|
+
return x[: x.shape[0] - w + 1].copy()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def ewm_alpha(halflife: float) -> float:
|
|
275
|
+
"""Smoothing weight for an exponentially weighted mean.
|
|
276
|
+
|
|
277
|
+
Parameters
|
|
278
|
+
----------
|
|
279
|
+
halflife : float
|
|
280
|
+
Half-life in bars. Must be positive. A half-life of 1 means the previous
|
|
281
|
+
value is halved each bar.
|
|
282
|
+
|
|
283
|
+
Returns
|
|
284
|
+
-------
|
|
285
|
+
float
|
|
286
|
+
``1 - exp(-ln(2) / halflife)``, in ``(0, 1)``.
|
|
287
|
+
"""
|
|
288
|
+
return 1.0 - math.exp(-math.log(2.0) / halflife)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def ewm_step(x: np.ndarray, prev: np.ndarray, alpha: float) -> np.ndarray:
|
|
292
|
+
"""Advance an exponentially weighted mean by one row.
|
|
293
|
+
|
|
294
|
+
Parameters
|
|
295
|
+
----------
|
|
296
|
+
x : ndarray of shape (N,)
|
|
297
|
+
The new observation. A NaN keeps ``prev`` (the mean does not jump to missing).
|
|
298
|
+
prev : ndarray of shape (N,)
|
|
299
|
+
The previous mean. A NaN starts the mean at ``x``.
|
|
300
|
+
alpha : float
|
|
301
|
+
Weight on ``x``, from :func:`ewm_alpha`. The previous mean keeps ``1 - alpha``.
|
|
302
|
+
|
|
303
|
+
Returns
|
|
304
|
+
-------
|
|
305
|
+
ndarray of shape (N,)
|
|
306
|
+
The updated mean.
|
|
307
|
+
"""
|
|
308
|
+
upd = alpha * x + (1.0 - alpha) * prev
|
|
309
|
+
out = np.where(np.isnan(prev), x, upd)
|
|
310
|
+
return np.where(np.isnan(x), prev, out)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ------------------------------------------------------------------ cross-sectional
|
|
314
|
+
def _valid_groups(v: np.ndarray, mask: np.ndarray | None, groups: np.ndarray | None):
|
|
315
|
+
valid = ~np.isnan(v)
|
|
316
|
+
if mask is not None:
|
|
317
|
+
valid &= mask
|
|
318
|
+
if groups is None:
|
|
319
|
+
g = np.zeros(v.shape[0], dtype=np.int64)
|
|
320
|
+
else:
|
|
321
|
+
g = groups.astype(np.int64, copy=False)
|
|
322
|
+
valid &= g >= 0
|
|
323
|
+
return valid, g
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cs_rank(
|
|
327
|
+
v: np.ndarray, mask: np.ndarray | None, groups: np.ndarray | None, ascending: bool = True
|
|
328
|
+
) -> np.ndarray:
|
|
329
|
+
"""Ordinal rank within each group.
|
|
330
|
+
|
|
331
|
+
Parameters
|
|
332
|
+
----------
|
|
333
|
+
v : ndarray of shape (N,)
|
|
334
|
+
One cross-section of values.
|
|
335
|
+
mask : ndarray of bool, shape (N,), or None
|
|
336
|
+
Positions that take part. ``None`` keeps every finite value.
|
|
337
|
+
groups : ndarray of int, shape (N,), or None
|
|
338
|
+
Group codes. ``None`` is one group. Codes below 0 are missing and are
|
|
339
|
+
not ranked.
|
|
340
|
+
ascending : bool, optional
|
|
341
|
+
``True`` gives rank 1 to the smallest value. Default is ``True``.
|
|
342
|
+
|
|
343
|
+
Returns
|
|
344
|
+
-------
|
|
345
|
+
ndarray of shape (N,)
|
|
346
|
+
Ranks ``1 .. k`` inside each group, as float. Ties keep asset order
|
|
347
|
+
(the earlier column wins the lower rank). Invalid entries are NaN.
|
|
348
|
+
"""
|
|
349
|
+
out = np.full(v.shape[0], np.nan)
|
|
350
|
+
valid, g = _valid_groups(v, mask, groups)
|
|
351
|
+
idx = np.flatnonzero(valid)
|
|
352
|
+
if idx.size == 0:
|
|
353
|
+
return out
|
|
354
|
+
vals = v[idx] if ascending else -v[idx]
|
|
355
|
+
gi = g[idx]
|
|
356
|
+
order = np.lexsort((idx, vals, gi)) # group, then value, then column index (stable ties)
|
|
357
|
+
sg = gi[order]
|
|
358
|
+
pos = np.arange(order.size)
|
|
359
|
+
starts = np.r_[0, np.flatnonzero(sg[1:] != sg[:-1]) + 1]
|
|
360
|
+
first = np.repeat(starts, np.diff(np.r_[starts, order.size]))
|
|
361
|
+
out[idx[order]] = (pos - first + 1).astype(F64)
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _group_moments(v: np.ndarray, valid: np.ndarray, g: np.ndarray):
|
|
366
|
+
gv = g[valid]
|
|
367
|
+
x = v[valid]
|
|
368
|
+
ng = int(gv.max()) + 1 if gv.size else 0
|
|
369
|
+
cnt = np.bincount(gv, minlength=ng).astype(F64)
|
|
370
|
+
s = np.bincount(gv, weights=x, minlength=ng)
|
|
371
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
372
|
+
mean = s / cnt
|
|
373
|
+
return gv, x, cnt, mean
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def cs_demean(v: np.ndarray, mask: np.ndarray | None, groups: np.ndarray | None) -> np.ndarray:
|
|
377
|
+
"""Subtract the group mean.
|
|
378
|
+
|
|
379
|
+
Parameters
|
|
380
|
+
----------
|
|
381
|
+
v : ndarray of shape (N,)
|
|
382
|
+
One cross-section.
|
|
383
|
+
mask : ndarray of bool, shape (N,), or None
|
|
384
|
+
Positions that enter the mean and receive a result.
|
|
385
|
+
groups : ndarray of int, shape (N,), or None
|
|
386
|
+
Group codes. ``None`` is one group. Codes below 0 are missing.
|
|
387
|
+
|
|
388
|
+
Returns
|
|
389
|
+
-------
|
|
390
|
+
ndarray of shape (N,)
|
|
391
|
+
``v`` minus its group's mean. Invalid entries are NaN. A group of one
|
|
392
|
+
is demeaned to 0.
|
|
393
|
+
"""
|
|
394
|
+
out = np.full(v.shape[0], np.nan)
|
|
395
|
+
valid, g = _valid_groups(v, mask, groups)
|
|
396
|
+
if not valid.any():
|
|
397
|
+
return out
|
|
398
|
+
gv, x, _cnt, mean = _group_moments(v, valid, g)
|
|
399
|
+
out[valid] = x - mean[gv]
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def cs_zscore(v: np.ndarray, mask: np.ndarray | None, groups: np.ndarray | None) -> np.ndarray:
|
|
404
|
+
"""Standardize by the group mean and sample standard deviation.
|
|
405
|
+
|
|
406
|
+
Parameters
|
|
407
|
+
----------
|
|
408
|
+
v : ndarray of shape (N,)
|
|
409
|
+
One cross-section.
|
|
410
|
+
mask : ndarray of bool, shape (N,), or None
|
|
411
|
+
Positions that enter the moments and receive a result.
|
|
412
|
+
groups : ndarray of int, shape (N,), or None
|
|
413
|
+
Group codes. ``None`` is one group. Codes below 0 are missing.
|
|
414
|
+
|
|
415
|
+
Returns
|
|
416
|
+
-------
|
|
417
|
+
ndarray of shape (N,)
|
|
418
|
+
``(v - mean) / std`` with the sample standard deviation (divisor
|
|
419
|
+
``count - 1``). A group with fewer than two valid values, or with zero
|
|
420
|
+
dispersion, is NaN.
|
|
421
|
+
"""
|
|
422
|
+
out = np.full(v.shape[0], np.nan)
|
|
423
|
+
valid, g = _valid_groups(v, mask, groups)
|
|
424
|
+
if not valid.any():
|
|
425
|
+
return out
|
|
426
|
+
gv, x, cnt, mean = _group_moments(v, valid, g)
|
|
427
|
+
dev = x - mean[gv]
|
|
428
|
+
ss = np.bincount(gv, weights=dev * dev, minlength=cnt.size)
|
|
429
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
430
|
+
sd = np.sqrt(ss / (cnt - 1.0))
|
|
431
|
+
z = dev / sd[gv]
|
|
432
|
+
z[cnt[gv] < 2] = np.nan
|
|
433
|
+
out[valid] = z
|
|
434
|
+
return out
|