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,126 @@
1
+ """Complete port of legacy factor zoo onto masked tensors.
2
+
3
+ Ports all ~150 factors from ``legacy/features/Feature.py`` to the modern
4
+ :mod:`mlquant.features.tensor_factors` primitives, with a uniform
5
+ ``[T, N]`` mask-aware signature.
6
+
7
+ Each factor:
8
+ - Takes a :class:`Panel` object
9
+ - Returns ``(values_tensor, mask_tensor)``
10
+ - Uses primitives from tensor_factors.py (ts_mean, cs_rank, delta, etc.)
11
+
12
+ Factor families:
13
+ - best_* : close-location momentum variants (21 factors)
14
+ - stock_* : per-stock derived series (22 factors)
15
+ - extra_* : turnover + amount features (14 factors)
16
+ - add_* : additional factor variants (30 factors)
17
+ - change_* : short-window change-of-velocity (5 factors)
18
+ - original_*: direct close/volume statistics (28 factors)
19
+ - old_* : legacy alpha signals (50 factors)
20
+
21
+ All factors are auto-registered into :data:`LEGACY_REGISTRY` via
22
+ :func:`register_legacy_factor`. Use :func:`compute_legacy_set` to
23
+ compute all or a subset as a stacked ``[T, N, F]`` tensor.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from typing import Callable, Dict, Tuple
28
+
29
+ import torch
30
+
31
+ from ..data.panel import Panel
32
+ from .tensor_factors import (
33
+ cs_rank, cs_zscore,
34
+ delay, delta,
35
+ ewma,
36
+ ts_corr, ts_max, ts_mean, ts_min, ts_rank, ts_std, ts_sum,
37
+ )
38
+
39
+
40
+ Tensor = torch.Tensor
41
+ LegacyFn = Callable[[Panel], Tuple[Tensor, Tensor]]
42
+ LEGACY_REGISTRY: Dict[str, LegacyFn] = {}
43
+
44
+
45
+ def register_legacy_factor(name: str) -> Callable[[LegacyFn], LegacyFn]:
46
+ """Decorator to register a factor function into the global registry."""
47
+ def deco(fn: LegacyFn) -> LegacyFn:
48
+ if name in LEGACY_REGISTRY:
49
+ raise ValueError(f"duplicate legacy factor name: {name}")
50
+ LEGACY_REGISTRY[name] = fn
51
+ return fn
52
+ return deco
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Helpers
57
+ # ---------------------------------------------------------------------------
58
+ def _safe_div(num: Tensor, den: Tensor, eps: float = 1e-12) -> Tensor:
59
+ return num / den.abs().clamp_min(eps).copysign(den + eps)
60
+
61
+
62
+ def _close_loc(panel: Panel) -> Tensor:
63
+ """Where in the day's range did the close land? (-1 = at low, +1 = at high)."""
64
+ rng = (panel.high - panel.low).clamp_min(1e-9)
65
+ return (2.0 * panel.close - panel.high - panel.low) / rng
66
+
67
+
68
+ def _ts_mean_close_loc(panel: Panel, window: int) -> Tuple[Tensor, Tensor]:
69
+ cl = _close_loc(panel)
70
+ return ts_mean(cl, panel.mask, window)
71
+
72
+
73
+ def _ret(panel: Panel) -> Tuple[Tensor, Tensor]:
74
+ """1-day return."""
75
+ d, dm = delta(panel.close, panel.mask, 1)
76
+ return d / panel.close.clamp_min(1e-9), dm
77
+
78
+
79
+ def _amount(panel: Panel) -> Tensor:
80
+ """Amount (turnover) — fallback to vwap * volume."""
81
+ return panel.amount if panel.amount is not None else panel.vwap * panel.volume
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Import factor groups (each group registers its factors on import)
86
+ # ---------------------------------------------------------------------------
87
+ from . import _factors_best # noqa: E402, F401
88
+ from . import _factors_stock # noqa: E402, F401
89
+ from . import _factors_extra # noqa: E402, F401
90
+ from . import _factors_add # noqa: E402, F401
91
+ from . import _factors_change # noqa: E402, F401
92
+ from . import _factors_original # noqa: E402, F401
93
+ from . import _factors_old # noqa: E402, F401
94
+ from . import _factors_better # noqa: E402, F401
95
+ from . import _factors_market # noqa: E402, F401
96
+ from . import _factors_alpha101 # noqa: E402, F401
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Driver
101
+ # ---------------------------------------------------------------------------
102
+ def compute_legacy_set(
103
+ panel: Panel,
104
+ *,
105
+ names: tuple[str, ...] | None = None,
106
+ neutralize: bool = True,
107
+ ) -> Tuple[Tensor, Tensor, list[str]]:
108
+ """Stack registered legacy factors into a ``[T, N, F]`` tensor."""
109
+ names = names or tuple(LEGACY_REGISTRY)
110
+ cols, joint = [], None
111
+ for n in names:
112
+ v, m = LEGACY_REGISTRY[n](panel)
113
+ v = torch.where(torch.isfinite(v), v, torch.zeros_like(v))
114
+ if neutralize:
115
+ v, _ = cs_zscore(v, m)
116
+ cols.append(v)
117
+ joint = m if joint is None else joint & m
118
+ factors = torch.stack(cols, dim=-1)
119
+ return factors, joint, list(names)
120
+
121
+
122
+ __all__ = [
123
+ "LEGACY_REGISTRY",
124
+ "register_legacy_factor",
125
+ "compute_legacy_set",
126
+ ]
@@ -0,0 +1,190 @@
1
+ """Market-breadth / cross-sectional return-distribution features.
2
+
3
+ These are the "大盘涨跌分布信号" the legacy ``cuda_features.py`` exposes
4
+ under names like ``CR_-10 .. CR_10``, ``OR_-10 .. OR_10`` and
5
+ ``cs_rank_{close,open,high,low,avg,amount}``. They are *cross-sectional
6
+ broadcast* signals — every stock on date ``t`` sees the *same value*
7
+ because the signal is a property of the whole market on that day.
8
+
9
+ Two families are produced:
10
+
11
+ 1. **Per-stock cross-sectional ranks** of OHLCV/avg/amount within each
12
+ trading day, mirroring the legacy ``calc_basedata`` output.
13
+ 2. **Cross-sectional return-distribution buckets**: for each integer
14
+ bucket ``b ∈ [-10, 10]`` we publish the fraction of tradable stocks
15
+ whose rounded close-on-close return (in %) equals ``b``. These 21
16
+ numbers form the daily "rise/fall histogram" that the legacy
17
+ pipeline used as a market regime descriptor. The same is computed
18
+ for open-on-close (``OR_*``).
19
+
20
+ Why broadcast?
21
+ The downstream alpha set takes ``[T, N]`` inputs, so a market-only
22
+ signal is replicated across the stock axis. This wastes a bit of
23
+ memory but keeps the data layout uniform and mask-aware.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from typing import Dict, List, Tuple
28
+
29
+ import torch
30
+
31
+ from ..data.panel import Panel
32
+ from .tensor_factors import cs_rank
33
+
34
+
35
+ Tensor = torch.Tensor
36
+
37
+ # Bucket grid: -10 % .. +10 % in 1 % steps -> 21 buckets.
38
+ _BUCKETS: tuple[int, ...] = tuple(range(-10, 11))
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Per-stock cross-sectional ranks
43
+ # ---------------------------------------------------------------------------
44
+ def cs_rank_field(panel: Panel, name: str) -> Tuple[Tensor, Tensor]:
45
+ """Cross-sectional percentile rank of ``panel.<name>`` within each date.
46
+
47
+ The legacy code uses ``ascending=False`` (largest → rank 1.0). We
48
+ keep that convention for parity with the pickled feature tables.
49
+ """
50
+ field = getattr(panel, name, None)
51
+ if field is None:
52
+ raise KeyError(f"panel has no field {name!r}")
53
+ return cs_rank(field, panel.mask, descending=True)
54
+
55
+
56
+ def cs_rank_avg(panel: Panel) -> Tuple[Tensor, Tensor]:
57
+ """Cross-sectional rank of the daily VWAP (``S_DQ_AVGPRICE``)."""
58
+ return cs_rank(panel.vwap, panel.mask, descending=True)
59
+
60
+
61
+ def cs_rank_amount(panel: Panel) -> Tuple[Tensor, Tensor]:
62
+ """Cross-sectional rank of the daily turnover ``amount``.
63
+
64
+ Falls back to ``vwap * volume`` when :attr:`Panel.amount` is absent.
65
+ """
66
+ if panel.amount is not None:
67
+ amt = panel.amount
68
+ else:
69
+ amt = panel.vwap * panel.volume
70
+ return cs_rank(amt, panel.mask, descending=True)
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Cross-sectional return-distribution buckets (CR_*, OR_*)
75
+ # ---------------------------------------------------------------------------
76
+ def _bucketize_pct(ret: Tensor, mask: Tensor) -> Tensor:
77
+ """Round ``ret`` into integer percentage buckets, clipped to ``[-10, 10]``.
78
+
79
+ Returns an ``int64`` tensor with the same shape as ``ret``; cells where
80
+ ``mask`` is False are set to a sentinel value ``-100`` so the caller
81
+ can drop them.
82
+ """
83
+ pct = (ret * 100.0).round().to(torch.int64)
84
+ pct = pct.clamp(min=_BUCKETS[0], max=_BUCKETS[-1])
85
+ return pct.masked_fill(~mask, -100)
86
+
87
+
88
+ def _broadcast_histogram(buckets: Tensor, mask: Tensor) -> Dict[int, Tensor]:
89
+ """For every date, compute the fraction of tradable stocks in each bucket.
90
+
91
+ Returns a dict ``{b: tensor[T, N]}`` where each tensor is the daily
92
+ fraction broadcast across the stock axis.
93
+ """
94
+ T, N = buckets.shape
95
+ out: Dict[int, Tensor] = {}
96
+ n_valid = mask.sum(dim=1, keepdim=True).clamp_min(1).float() # [T, 1]
97
+ for b in _BUCKETS:
98
+ count = ((buckets == b) & mask).sum(dim=1, keepdim=True).float() # [T, 1]
99
+ frac = count / n_valid # [T, 1]
100
+ out[b] = frac.expand(T, N).contiguous()
101
+ return out
102
+
103
+
104
+ def close_return_distribution(panel: Panel) -> Dict[int, Tensor]:
105
+ """``CR_-10..CR_10`` — daily fraction of stocks at each rounded close-return.
106
+
107
+ Definition (matches legacy)
108
+ ---------------------------
109
+ ``ret_t = close_t / last_close_t - 1``, clipped to ±10 %, rounded to
110
+ the nearest integer percent. Buckets index 21 disjoint events; the
111
+ sum of the 21 ``CR_*`` series at any date equals 1.
112
+
113
+ Returns
114
+ -------
115
+ dict[int, Tensor]
116
+ Mapping bucket → ``[T, N]`` tensor (broadcast across stocks).
117
+ """
118
+ ret = panel.returns # last_close-aware
119
+ mask = panel.mask
120
+ buckets = _bucketize_pct(ret, mask)
121
+ return _broadcast_histogram(buckets, mask)
122
+
123
+
124
+ def open_return_distribution(panel: Panel) -> Dict[int, Tensor]:
125
+ """``OR_-10..OR_10`` — same idea but on ``(open_t - last_close_t) / last_close_t``."""
126
+ if panel.last_close is not None:
127
+ prev = panel.last_close.clamp_min(1e-9)
128
+ valid_prev = panel.last_close > 0
129
+ else:
130
+ prev = torch.roll(panel.close, shifts=1, dims=0).clamp_min(1e-9)
131
+ valid_prev = torch.ones_like(panel.mask)
132
+ valid_prev[0] = False
133
+ ret = panel.open / prev - 1.0
134
+ mask = panel.mask & valid_prev
135
+ buckets = _bucketize_pct(ret, mask)
136
+ return _broadcast_histogram(buckets, mask)
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Driver
141
+ # ---------------------------------------------------------------------------
142
+ def compute_market_breadth(panel: Panel) -> Tuple[Tensor, Tensor, List[str]]:
143
+ """Compute every market-breadth feature into a stacked ``[T, N, F]`` tensor.
144
+
145
+ Output columns
146
+ --------------
147
+ * ``cs_rank_close``, ``cs_rank_open``, ``cs_rank_high``,
148
+ ``cs_rank_low``, ``cs_rank_avg``, ``cs_rank_amount``
149
+ * ``CR_-10`` .. ``CR_10`` (21 buckets)
150
+ * ``OR_-10`` .. ``OR_10`` (21 buckets)
151
+
152
+ => 6 + 21 + 21 = 48 columns total.
153
+ """
154
+ cols: List[Tensor] = []
155
+ names: List[str] = []
156
+ joint_mask: Tensor = panel.mask.clone()
157
+
158
+ for fname in ("close", "open", "high", "low"):
159
+ v, m = cs_rank_field(panel, fname)
160
+ cols.append(v)
161
+ names.append(f"cs_rank_{fname}")
162
+ joint_mask = joint_mask & m
163
+
164
+ v, m = cs_rank_avg(panel)
165
+ cols.append(v); names.append("cs_rank_avg"); joint_mask = joint_mask & m
166
+ v, m = cs_rank_amount(panel)
167
+ cols.append(v); names.append("cs_rank_amount"); joint_mask = joint_mask & m
168
+
169
+ cr = close_return_distribution(panel)
170
+ for b in _BUCKETS:
171
+ cols.append(cr[b])
172
+ names.append(f"CR_{b}")
173
+
174
+ or_ = open_return_distribution(panel)
175
+ for b in _BUCKETS:
176
+ cols.append(or_[b])
177
+ names.append(f"OR_{b}")
178
+
179
+ factors = torch.stack(cols, dim=-1) # [T, N, F]
180
+ return factors, joint_mask, names
181
+
182
+
183
+ __all__ = [
184
+ "cs_rank_field",
185
+ "cs_rank_avg",
186
+ "cs_rank_amount",
187
+ "close_return_distribution",
188
+ "open_return_distribution",
189
+ "compute_market_breadth",
190
+ ]
@@ -0,0 +1,75 @@
1
+ """Cross-sectional and industry/size neutralisation.
2
+
3
+ The simplest and most defensible neutralisation is a per-date
4
+ cross-sectional z-score, which removes the market-mean drift while
5
+ preserving the cross-sectional ordering — exactly the information a
6
+ long-short alpha consumes downstream. We expose two variants:
7
+
8
+ * :func:`neutralize_cs` — z-score across all tradable stocks per date.
9
+ * :func:`neutralize_industry` — regress out a one-hot industry matrix
10
+ per date and return the residuals.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Optional
15
+
16
+ import torch
17
+
18
+ from .tensor_factors import cs_zscore
19
+
20
+
21
+ def neutralize_cs(x: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
22
+ """Per-date cross-sectional z-score, masked-aware."""
23
+ out, _ = cs_zscore(x, mask)
24
+ return out
25
+
26
+
27
+ def neutralize_industry(
28
+ x: torch.Tensor,
29
+ mask: torch.Tensor,
30
+ industry_onehot: torch.Tensor,
31
+ *,
32
+ log_size: Optional[torch.Tensor] = None,
33
+ ) -> torch.Tensor:
34
+ """Per-date OLS-residualise ``x`` against industry dummies (+ optionally
35
+ log market cap).
36
+
37
+ Parameters
38
+ ----------
39
+ x : Tensor [T, N]
40
+ mask : Tensor [T, N] bool
41
+ industry_onehot : Tensor [T, N, K] one-hot per stock-day
42
+ log_size : Tensor [T, N], optional if given, added as a continuous
43
+ regressor.
44
+
45
+ Returns
46
+ -------
47
+ Tensor [T, N] — residuals, masked cells set to 0.
48
+ """
49
+ T, N = x.shape
50
+ K = industry_onehot.shape[-1]
51
+ out = torch.zeros_like(x)
52
+
53
+ for t in range(T):
54
+ m = mask[t]
55
+ if m.sum() < 2:
56
+ continue
57
+ y = x[t][m]
58
+ Xs = [industry_onehot[t][m].float()]
59
+ if log_size is not None:
60
+ Xs.append(log_size[t][m].unsqueeze(1))
61
+ X = torch.cat(Xs, dim=1)
62
+ # least-squares Xβ = y via lstsq for numerical stability
63
+ beta = torch.linalg.lstsq(X, y.unsqueeze(1)).solution.squeeze(1)
64
+ residual = y - X @ beta
65
+ # Degeneracy guard: a nearly perfect fit can leave float32 numerical
66
+ # noise that should not be amplified to unit variance. Compare against
67
+ # the signal's own scale so genuinely small signals remain valid.
68
+ residual = residual - residual.mean()
69
+ residual_scale = residual.std(unbiased=False)
70
+ signal_scale = (y - y.mean()).std(unbiased=False)
71
+ if signal_scale <= 0 or residual_scale <= signal_scale * 1e-6:
72
+ continue
73
+ out[t][m] = residual / residual_scale
74
+
75
+ return out
@@ -0,0 +1,62 @@
1
+ """Forward-return labels (training targets).
2
+
3
+ The legacy ``cuda_features.py`` ships a single label, ``target_01``,
4
+ which the paper uses throughout::
5
+
6
+ target_01[t, i] = close[t+1, i] / close[t, i] - 1
7
+
8
+ masked by ``mask[t, i] & mask[t+1, i]``. Concretely, this is the
9
+ realised one-day forward simple return, computed only on stock-days
10
+ where the stock was tradable both at signal time *t* and at execution
11
+ time *t+1*. The last row is always masked because there is no ``t+1``.
12
+
13
+ We expose two helpers:
14
+
15
+ * :func:`target_01` — vectorised tensor implementation.
16
+ * :func:`forward_return` — generic ``k``-day forward return.
17
+
18
+ Both return ``(values, mask)`` to fit the rest of the feature engine.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from typing import Tuple
23
+
24
+ import torch
25
+
26
+ from ..data.panel import Panel
27
+
28
+
29
+ Tensor = torch.Tensor
30
+
31
+
32
+ def forward_return(panel: Panel, horizon: int = 1) -> Tuple[Tensor, Tensor]:
33
+ """``close[t+horizon] / close[t] - 1`` masked by tradability at both ends.
34
+
35
+ Parameters
36
+ ----------
37
+ panel : Panel
38
+ horizon : int
39
+ Number of periods to look ahead. Must be >= 1.
40
+ """
41
+ if horizon < 1:
42
+ raise ValueError(f"horizon must be >= 1, got {horizon}")
43
+ T, N = panel.close.shape
44
+ out = torch.zeros_like(panel.close)
45
+ out_mask = torch.zeros_like(panel.mask)
46
+
47
+ fut_close = panel.close[horizon:]
48
+ cur_close = panel.close[:-horizon].clamp_min(1e-9)
49
+ fut_mask = panel.mask[horizon:]
50
+ cur_mask = panel.mask[:-horizon]
51
+
52
+ out[:-horizon] = (fut_close / cur_close - 1.0) * (fut_mask & cur_mask).float()
53
+ out_mask[:-horizon] = fut_mask & cur_mask
54
+ return out, out_mask
55
+
56
+
57
+ def target_01(panel: Panel) -> Tuple[Tensor, Tensor]:
58
+ """1-day forward simple return — the canonical training label."""
59
+ return forward_return(panel, horizon=1)
60
+
61
+
62
+ __all__ = ["target_01", "forward_return"]