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.
- mlquant/__init__.py +18 -0
- mlquant/backtest/__init__.py +14 -0
- mlquant/backtest/engine.py +118 -0
- mlquant/backtest/metrics.py +95 -0
- mlquant/cli/__init__.py +1 -0
- mlquant/cli/main.py +292 -0
- mlquant/configs/__init__.py +1 -0
- mlquant/configs/small.yaml +41 -0
- mlquant/data/__init__.py +88 -0
- mlquant/data/akshare_loader.py +168 -0
- mlquant/data/baostock_loader.py +137 -0
- mlquant/data/loaders.py +125 -0
- mlquant/data/panel.py +206 -0
- mlquant/data/synthetic.py +138 -0
- mlquant/data/yfinance_loader.py +101 -0
- mlquant/features/__init__.py +42 -0
- mlquant/features/_factors_add.py +288 -0
- mlquant/features/_factors_alpha101.py +98 -0
- mlquant/features/_factors_best.py +177 -0
- mlquant/features/_factors_better.py +385 -0
- mlquant/features/_factors_change.py +53 -0
- mlquant/features/_factors_extra.py +147 -0
- mlquant/features/_factors_market.py +61 -0
- mlquant/features/_factors_old.py +563 -0
- mlquant/features/_factors_original.py +215 -0
- mlquant/features/_factors_stock.py +270 -0
- mlquant/features/alpha101.py +84 -0
- mlquant/features/bias.py +81 -0
- mlquant/features/legacy_factors.py +126 -0
- mlquant/features/market_breadth.py +190 -0
- mlquant/features/neutralize.py +75 -0
- mlquant/features/target.py +62 -0
- mlquant/features/tensor_factors.py +301 -0
- mlquant/features.py +613 -0
- mlquant/models/__init__.py +8 -0
- mlquant/models/losses.py +88 -0
- mlquant/models/nets.py +62 -0
- mlquant/portfolio/__init__.py +5 -0
- mlquant/portfolio/frontier.py +49 -0
- mlquant/portfolio/markowitz.py +144 -0
- mlquant/training/__init__.py +6 -0
- mlquant/training/augment.py +89 -0
- mlquant/training/dataset.py +50 -0
- mlquant/training/trainer.py +108 -0
- mlquant/utils/__init__.py +5 -0
- mlquant/utils/config.py +60 -0
- mlquant/utils/seed.py +35 -0
- mlquantx-0.2.5.dist-info/METADATA +108 -0
- mlquantx-0.2.5.dist-info/RECORD +54 -0
- mlquantx-0.2.5.dist-info/WHEEL +5 -0
- mlquantx-0.2.5.dist-info/entry_points.txt +2 -0
- mlquantx-0.2.5.dist-info/licenses/LICENSE +21 -0
- mlquantx-0.2.5.dist-info/licenses/NOTICE.md +5 -0
- 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()
|