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,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"]
|