mlquantx 0.2.5__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.
Files changed (54) hide show
  1. mlquant/__init__.py +18 -0
  2. mlquant/backtest/__init__.py +14 -0
  3. mlquant/backtest/engine.py +118 -0
  4. mlquant/backtest/metrics.py +95 -0
  5. mlquant/cli/__init__.py +1 -0
  6. mlquant/cli/main.py +292 -0
  7. mlquant/configs/__init__.py +1 -0
  8. mlquant/configs/small.yaml +41 -0
  9. mlquant/data/__init__.py +88 -0
  10. mlquant/data/akshare_loader.py +168 -0
  11. mlquant/data/baostock_loader.py +137 -0
  12. mlquant/data/loaders.py +125 -0
  13. mlquant/data/panel.py +206 -0
  14. mlquant/data/synthetic.py +138 -0
  15. mlquant/data/yfinance_loader.py +101 -0
  16. mlquant/features/__init__.py +42 -0
  17. mlquant/features/_factors_add.py +288 -0
  18. mlquant/features/_factors_alpha101.py +98 -0
  19. mlquant/features/_factors_best.py +177 -0
  20. mlquant/features/_factors_better.py +385 -0
  21. mlquant/features/_factors_change.py +53 -0
  22. mlquant/features/_factors_extra.py +147 -0
  23. mlquant/features/_factors_market.py +61 -0
  24. mlquant/features/_factors_old.py +563 -0
  25. mlquant/features/_factors_original.py +215 -0
  26. mlquant/features/_factors_stock.py +270 -0
  27. mlquant/features/alpha101.py +84 -0
  28. mlquant/features/bias.py +81 -0
  29. mlquant/features/legacy_factors.py +126 -0
  30. mlquant/features/market_breadth.py +190 -0
  31. mlquant/features/neutralize.py +75 -0
  32. mlquant/features/target.py +62 -0
  33. mlquant/features/tensor_factors.py +301 -0
  34. mlquant/features.py +613 -0
  35. mlquant/models/__init__.py +8 -0
  36. mlquant/models/losses.py +88 -0
  37. mlquant/models/nets.py +62 -0
  38. mlquant/portfolio/__init__.py +5 -0
  39. mlquant/portfolio/frontier.py +49 -0
  40. mlquant/portfolio/markowitz.py +144 -0
  41. mlquant/training/__init__.py +6 -0
  42. mlquant/training/augment.py +89 -0
  43. mlquant/training/dataset.py +50 -0
  44. mlquant/training/trainer.py +108 -0
  45. mlquant/utils/__init__.py +5 -0
  46. mlquant/utils/config.py +60 -0
  47. mlquant/utils/seed.py +35 -0
  48. mlquantx-0.2.5.dist-info/METADATA +108 -0
  49. mlquantx-0.2.5.dist-info/RECORD +54 -0
  50. mlquantx-0.2.5.dist-info/WHEEL +5 -0
  51. mlquantx-0.2.5.dist-info/entry_points.txt +2 -0
  52. mlquantx-0.2.5.dist-info/licenses/LICENSE +21 -0
  53. mlquantx-0.2.5.dist-info/licenses/NOTICE.md +5 -0
  54. mlquantx-0.2.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,301 @@
1
+ """Mask-aware, GPU-vectorised factor primitives.
2
+
3
+ Every primitive takes a value tensor ``x`` of shape ``[T, N]`` plus a
4
+ boolean mask of the same shape and returns a ``(values, mask)`` pair.
5
+ ``mask[t, i] == False`` means cell ``(t, i)`` is non-tradable (halt,
6
+ limit-up before fill, pre-IPO, …) — and *the result must not depend on
7
+ the value at masked cells*.
8
+
9
+ Why this shape?
10
+ The whole repo treats markets as wide tensors. Operating in this
11
+ layout means a ``ts_corr`` over 5 000 stocks × 252 days is a single
12
+ fused GPU call, not a Python loop with ``df.rolling`` per stock.
13
+
14
+ Numerical conventions
15
+ * Float32 throughout. Accumulators promote to float64 only inside
16
+ :func:`ewma` because the recurrence is sensitive.
17
+ * Cross-sectional ranks are reported in the open interval ``(0, 1]``
18
+ with ``ascending=True`` (smaller value → smaller rank).
19
+ * Time-series rolling windows use *trailing* windows: ``window=5``
20
+ at date ``t`` looks at ``t-4 … t``.
21
+
22
+ This module deliberately avoids any IO; it only depends on torch.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from typing import Tuple
27
+
28
+ import torch
29
+ import torch.nn.functional as F
30
+
31
+ Tensor = torch.Tensor
32
+
33
+ __all__ = [
34
+ "cs_rank", "cs_zscore", "cs_scale",
35
+ "ts_rank", "ts_sum", "ts_mean", "ts_std",
36
+ "ts_min", "ts_max", "ts_corr", "ts_cov",
37
+ "ts_argmax", "ts_argmin", "ts_product",
38
+ "ewma", "delay", "delta",
39
+ "decay_linear", "signedpower",
40
+ ]
41
+
42
+
43
+ # =============================================================================
44
+ # Cross-sectional primitives (reduce along stock axis = dim 1)
45
+ # =============================================================================
46
+ def cs_rank(x: Tensor, mask: Tensor, *, descending: bool = False) -> Tuple[Tensor, Tensor]:
47
+ """Cross-sectional percentile rank in ``(0, 1]``.
48
+
49
+ Tied values receive the average rank — this matches ``pandas
50
+ rank(method='average')`` and is what alpha-101 expects.
51
+
52
+ Implementation
53
+ 1. Replace masked cells with ±inf so ``argsort`` puts them at
54
+ the end, then sort along ``dim=1``.
55
+ 2. Identify tie-groups by ``cumsum`` of strictly-greater jumps.
56
+ 3. Average ranks within each group via ``scatter_add``.
57
+ 4. Scatter back to original positions and divide by ``mask.sum``
58
+ to get a percentile.
59
+ """
60
+ fill = float("-inf") if descending else float("inf")
61
+ masked = x.masked_fill(~mask, fill)
62
+ sorted_x, idx = torch.sort(masked, dim=1, descending=descending)
63
+
64
+ # Tie groups: a new group starts where consecutive values differ.
65
+ diffs = sorted_x[:, 1:] - sorted_x[:, :-1]
66
+ new_group = (diffs > 0) if not descending else (diffs < 0)
67
+ group_id = F.pad(new_group.long(), (1, 0)).cumsum(dim=1)
68
+
69
+ # Average rank within each tie group.
70
+ ranks = torch.arange(1, x.shape[1] + 1, device=x.device).expand_as(group_id).float()
71
+ sum_ranks = torch.zeros_like(ranks).scatter_add(1, group_id, ranks)
72
+ count_ranks = torch.zeros_like(ranks).scatter_add(1, group_id, torch.ones_like(ranks))
73
+ mean_ranks = sum_ranks / count_ranks.clamp_min(1.0)
74
+ avg_rank = torch.gather(mean_ranks, 1, group_id)
75
+
76
+ # Scatter back to original column order and normalise.
77
+ out = torch.zeros_like(avg_rank).scatter(1, idx, avg_rank)
78
+ denom = mask.sum(dim=1, keepdim=True).clamp_min(1).float()
79
+ out = (out / denom).masked_fill(~mask, 0.0)
80
+ return out, mask
81
+
82
+
83
+ def cs_zscore(x: Tensor, mask: Tensor) -> Tuple[Tensor, Tensor]:
84
+ """Cross-sectional z-score with masked-aware mean/std."""
85
+ n = mask.sum(dim=1, keepdim=True).clamp_min(1).float()
86
+ xm = x.masked_fill(~mask, 0.0)
87
+ mean = xm.sum(dim=1, keepdim=True) / n
88
+ var = ((xm - mean) ** 2 * mask).sum(dim=1, keepdim=True) / n.clamp_min(2)
89
+ std = var.sqrt().clamp_min(1e-12)
90
+ out = ((x - mean) / std).masked_fill(~mask, 0.0)
91
+ return out, mask
92
+
93
+
94
+ # =============================================================================
95
+ # Time-series primitives (rolling along date axis = dim 0)
96
+ # =============================================================================
97
+ def _unfold_t(x: Tensor, window: int) -> Tensor:
98
+ """Materialise rolling windows -> shape [T-window+1, window, N]."""
99
+ return x.unfold(0, window, 1).permute(0, 2, 1)
100
+
101
+
102
+ def _unfold_mask(mask: Tensor, window: int) -> Tensor:
103
+ return mask.unfold(0, window, 1).permute(0, 2, 1)
104
+
105
+
106
+ def _pad_front(out: Tensor, window: int, value: float = 0.0) -> Tensor:
107
+ """Front-pad ``window-1`` rows with ``value`` so output shape == input shape."""
108
+ pad = torch.full((window - 1, *out.shape[1:]), value, dtype=out.dtype, device=out.device)
109
+ return torch.cat([pad, out], dim=0)
110
+
111
+
112
+ def ts_sum(x: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
113
+ xw = _unfold_t(x.masked_fill(~mask, 0.0), window)
114
+ mw = _unfold_mask(mask, window)
115
+ out_mask = mw.all(dim=1)
116
+ out = xw.sum(dim=1)
117
+ return _pad_front(out, window), _pad_front(out_mask.bool(), window, 0).bool()
118
+
119
+
120
+ def ts_mean(x: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
121
+ xw = _unfold_t(x.masked_fill(~mask, 0.0), window)
122
+ mw = _unfold_mask(mask, window).float()
123
+ n = mw.sum(dim=1).clamp_min(1.0)
124
+ out = xw.sum(dim=1) / n
125
+ out_mask = (mw.sum(dim=1) > 0)
126
+ return _pad_front(out, window), _pad_front(out_mask, window, 0).bool()
127
+
128
+
129
+ def ts_std(x: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
130
+ xw = _unfold_t(x.masked_fill(~mask, 0.0), window)
131
+ mw = _unfold_mask(mask, window).float()
132
+ n = mw.sum(dim=1).clamp_min(2.0)
133
+ mean = xw.sum(dim=1, keepdim=True) / n.unsqueeze(1)
134
+ var = (((xw - mean) ** 2) * mw).sum(dim=1) / (n - 1)
135
+ out_mask = (mw.sum(dim=1) >= 2)
136
+ return _pad_front(var.sqrt(), window), _pad_front(out_mask, window, 0).bool()
137
+
138
+
139
+ def ts_min(x: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
140
+ xw = _unfold_t(x.masked_fill(~mask, float("inf")), window)
141
+ mw = _unfold_mask(mask, window)
142
+ out = xw.min(dim=1).values
143
+ out_mask = mw.any(dim=1)
144
+ return _pad_front(out, window), _pad_front(out_mask, window, 0).bool()
145
+
146
+
147
+ def ts_max(x: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
148
+ xw = _unfold_t(x.masked_fill(~mask, float("-inf")), window)
149
+ mw = _unfold_mask(mask, window)
150
+ out = xw.max(dim=1).values
151
+ out_mask = mw.any(dim=1)
152
+ return _pad_front(out, window), _pad_front(out_mask, window, 0).bool()
153
+
154
+
155
+ def ts_rank(x: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
156
+ """Rank of the *last* element in a trailing window, normalised to ``(0, 1]``."""
157
+ xw = _unfold_t(x.masked_fill(~mask, float("-inf")), window)
158
+ last = xw[:, -1:, :]
159
+ rank = (xw <= last).sum(dim=1).float()
160
+ n = _unfold_mask(mask, window).float().sum(dim=1).clamp_min(1.0)
161
+ out = rank / n
162
+ out_mask = _unfold_mask(mask, window).all(dim=1)
163
+ return _pad_front(out, window), _pad_front(out_mask, window, 0).bool()
164
+
165
+
166
+ def ts_corr(x: Tensor, y: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
167
+ """Rolling Pearson correlation of two series, masked-aware."""
168
+ xw = _unfold_t(x.masked_fill(~mask, 0.0), window)
169
+ yw = _unfold_t(y.masked_fill(~mask, 0.0), window)
170
+ mw = _unfold_mask(mask, window).float()
171
+ n = mw.sum(dim=1, keepdim=True).clamp_min(2.0)
172
+
173
+ mx = (xw * mw).sum(dim=1, keepdim=True) / n
174
+ my = (yw * mw).sum(dim=1, keepdim=True) / n
175
+ cx = (xw - mx) * mw
176
+ cy = (yw - my) * mw
177
+ cov = (cx * cy).sum(dim=1)
178
+ sx = (cx ** 2).sum(dim=1).sqrt()
179
+ sy = (cy ** 2).sum(dim=1).sqrt()
180
+ out = cov / (sx * sy).clamp_min(1e-12)
181
+ out_mask = (mw.sum(dim=1) >= 2)
182
+ return _pad_front(out, window), _pad_front(out_mask, window, 0).bool()
183
+
184
+
185
+ def ts_cov(x: Tensor, y: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
186
+ xw = _unfold_t(x.masked_fill(~mask, 0.0), window)
187
+ yw = _unfold_t(y.masked_fill(~mask, 0.0), window)
188
+ mw = _unfold_mask(mask, window).float()
189
+ n = mw.sum(dim=1, keepdim=True).clamp_min(2.0)
190
+ mx = (xw * mw).sum(dim=1, keepdim=True) / n
191
+ my = (yw * mw).sum(dim=1, keepdim=True) / n
192
+ out = ((xw - mx) * (yw - my) * mw).sum(dim=1) / (n.squeeze(1) - 1)
193
+ out_mask = (mw.sum(dim=1) >= 2)
194
+ return _pad_front(out, window), _pad_front(out_mask, window, 0).bool()
195
+
196
+
197
+ # =============================================================================
198
+ # Other primitives
199
+ # =============================================================================
200
+ def ewma(x: Tensor, mask: Tensor, alpha: float) -> Tuple[Tensor, Tensor]:
201
+ """Mask-aware exponentially-weighted moving average.
202
+
203
+ The recurrence is computed in float64 because the simple recurrence
204
+ ``y_t = α x_t + (1-α) y_{t-1}`` accumulates round-off error in
205
+ float32 over ~3000 steps.
206
+ """
207
+ if not 0.0 < alpha <= 1.0:
208
+ raise ValueError(f"alpha must be in (0,1], got {alpha}")
209
+ x64 = x.to(torch.float64).masked_fill(~mask, 0.0)
210
+ m64 = mask.to(torch.float64)
211
+ out = torch.zeros_like(x64)
212
+ last = torch.zeros(x64.shape[1], dtype=torch.float64, device=x.device)
213
+ for t in range(x64.shape[0]):
214
+ last = alpha * x64[t] + (1.0 - alpha) * last
215
+ out[t] = last
216
+ return out.to(x.dtype) * mask.float(), mask
217
+
218
+
219
+ def delay(x: Tensor, mask: Tensor, periods: int) -> Tuple[Tensor, Tensor]:
220
+ """``x[t-periods]`` along the date axis. Front-pads with zeros.
221
+
222
+ Values at originally-masked cells are zeroed *before* shifting, so the
223
+ output never depends on garbage at non-tradable cells (see module
224
+ docstring contract).
225
+ """
226
+ if periods < 0:
227
+ raise ValueError("periods must be non-negative")
228
+ if periods == 0:
229
+ return x * mask.float(), mask
230
+ x_masked = x.masked_fill(~mask, 0.0)
231
+ out = torch.roll(x_masked, shifts=periods, dims=0)
232
+ out[:periods] = 0.0
233
+ new_mask = torch.roll(mask, shifts=periods, dims=0)
234
+ new_mask[:periods] = False
235
+ return out, new_mask
236
+
237
+
238
+ def delta(x: Tensor, mask: Tensor, periods: int) -> Tuple[Tensor, Tensor]:
239
+ """``x[t] - x[t-periods]``."""
240
+ prev, prev_mask = delay(x, mask, periods)
241
+ return (x - prev) * (mask & prev_mask).float(), mask & prev_mask
242
+
243
+
244
+ # =============================================================================
245
+ # WorldQuant-101 helpers
246
+ # =============================================================================
247
+ def cs_scale(x: Tensor, mask: Tensor, a: float = 1.0) -> Tuple[Tensor, Tensor]:
248
+ """``a * x / sum(|x|)`` cross-sectionally — the WQ ``scale`` primitive.
249
+
250
+ Masked cells are treated as 0 in both numerator and denominator so they
251
+ do not influence the L1 norm.
252
+ """
253
+ xm = x.masked_fill(~mask, 0.0)
254
+ denom = xm.abs().sum(dim=1, keepdim=True).clamp_min(1e-12)
255
+ out = (a * xm / denom).masked_fill(~mask, 0.0)
256
+ return out, mask
257
+
258
+
259
+ def signedpower(x: Tensor, p: float) -> Tensor:
260
+ """``sign(x) * |x|**p`` — the WQ ``SignedPower`` primitive."""
261
+ return torch.sign(x) * x.abs().clamp_min(1e-12).pow(p)
262
+
263
+
264
+ def ts_argmax(x: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
265
+ """1-based position of the max within a trailing ``window``."""
266
+ xw = _unfold_t(x.masked_fill(~mask, float("-inf")), window)
267
+ pos = xw.argmax(dim=1).float() + 1.0
268
+ out_mask = _unfold_mask(mask, window).any(dim=1)
269
+ return _pad_front(pos, window), _pad_front(out_mask, window, 0).bool()
270
+
271
+
272
+ def ts_argmin(x: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
273
+ """1-based position of the min within a trailing ``window``."""
274
+ xw = _unfold_t(x.masked_fill(~mask, float("inf")), window)
275
+ pos = xw.argmin(dim=1).float() + 1.0
276
+ out_mask = _unfold_mask(mask, window).any(dim=1)
277
+ return _pad_front(pos, window), _pad_front(out_mask, window, 0).bool()
278
+
279
+
280
+ def ts_product(x: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
281
+ """Rolling product over a trailing ``window``. Masked cells -> 1."""
282
+ xw = _unfold_t(x.masked_fill(~mask, 1.0), window)
283
+ out = xw.prod(dim=1)
284
+ out_mask = _unfold_mask(mask, window).all(dim=1)
285
+ return _pad_front(out, window), _pad_front(out_mask, window, 0).bool()
286
+
287
+
288
+ def decay_linear(x: Tensor, mask: Tensor, window: int) -> Tuple[Tensor, Tensor]:
289
+ """Linear-decay weighted moving average — the WQ ``decay_linear`` primitive.
290
+
291
+ ``y_t = sum_{k=0..d-1} (d - k) * x_{t-k} / sum_{k=0..d-1}(d - k)``
292
+ Masked cells are treated as 0 (their weight effectively drops out).
293
+ """
294
+ if window < 1:
295
+ raise ValueError("window must be >= 1")
296
+ xw = _unfold_t(x.masked_fill(~mask, 0.0), window)
297
+ weights = torch.arange(window, 0, -1, device=x.device, dtype=x.dtype)
298
+ weights = weights / weights.sum()
299
+ out = (xw * weights.view(1, window, 1)).sum(dim=1)
300
+ out_mask = _unfold_mask(mask, window).all(dim=1)
301
+ return _pad_front(out, window), _pad_front(out_mask, window, 0).bool()