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