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
mlquant/models/losses.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Sign-aware regression losses + IC / RankIC objectives.
|
|
2
|
+
|
|
3
|
+
Why not plain MSE?
|
|
4
|
+
Trading P&L is sign-sensitive: a prediction with the wrong sign
|
|
5
|
+
*and* moderate magnitude burns money, while a prediction with the
|
|
6
|
+
right sign and any magnitude makes money. Plain MSE penalises both
|
|
7
|
+
"right sign, big residual" and "wrong sign, small residual" the
|
|
8
|
+
same way — that is well-documented to misalign with utility.
|
|
9
|
+
|
|
10
|
+
We expose:
|
|
11
|
+
|
|
12
|
+
* :class:`AdjMSELoss` — Du (2025) §4.2: scale up the loss when
|
|
13
|
+
``sign(pred)·sign(label) < 0``, scale it down when they agree.
|
|
14
|
+
* :class:`ICLoss` — minus Pearson correlation across the batch.
|
|
15
|
+
* :class:`RankICLoss` — minus Spearman correlation across the batch.
|
|
16
|
+
|
|
17
|
+
ICLoss / RankICLoss are particularly useful for *cross-sectional*
|
|
18
|
+
training where what matters is the ordering of predictions within a
|
|
19
|
+
date, not their absolute magnitude.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import torch
|
|
24
|
+
import torch.nn as nn
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AdjMSELoss(nn.Module):
|
|
28
|
+
r"""Sign-aware MSE.
|
|
29
|
+
|
|
30
|
+
.. math::
|
|
31
|
+
\mathcal{L}_{\text{adj}}(\hat y, y) =
|
|
32
|
+
\begin{cases}
|
|
33
|
+
\gamma\,(\hat y - y)^2 & \text{if }\operatorname{sign}(\hat y)\cdot\operatorname{sign}(y) > 0 \\
|
|
34
|
+
(1+\gamma)\,(\hat y - y)^2 & \text{otherwise}
|
|
35
|
+
\end{cases}
|
|
36
|
+
|
|
37
|
+
With ``gamma = 0.1`` (default) wrong-sign predictions are penalised
|
|
38
|
+
11× more than right-sign predictions of equal magnitude.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, gamma: float = 0.1) -> None:
|
|
42
|
+
super().__init__()
|
|
43
|
+
if gamma <= 0:
|
|
44
|
+
raise ValueError("gamma must be positive")
|
|
45
|
+
self.gamma = gamma
|
|
46
|
+
|
|
47
|
+
def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
|
48
|
+
squared = (pred - target) ** 2
|
|
49
|
+
same_sign = (pred * target) > 0
|
|
50
|
+
weight = torch.where(same_sign,
|
|
51
|
+
torch.full_like(squared, self.gamma),
|
|
52
|
+
torch.full_like(squared, 1.0 + self.gamma))
|
|
53
|
+
return (squared * weight).mean()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _pearson(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
|
|
57
|
+
xm = x - x.mean()
|
|
58
|
+
ym = y - y.mean()
|
|
59
|
+
denom = (xm.pow(2).sum().sqrt() * ym.pow(2).sum().sqrt()).clamp_min(1e-12)
|
|
60
|
+
return (xm * ym).sum() / denom
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ICLoss(nn.Module):
|
|
64
|
+
"""Negative Pearson IC across a batch (e.g. one cross-section)."""
|
|
65
|
+
|
|
66
|
+
def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
|
67
|
+
return -_pearson(pred, target)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RankICLoss(nn.Module):
|
|
71
|
+
"""Negative Spearman IC across a batch.
|
|
72
|
+
|
|
73
|
+
Spearman = Pearson on ranks. We use the *soft-rank* trick (a
|
|
74
|
+
differentiable approximation that sorts the inputs through a
|
|
75
|
+
softmax-temperature lens) so the loss is gradient-friendly.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, temperature: float = 1.0) -> None:
|
|
79
|
+
super().__init__()
|
|
80
|
+
self.temperature = temperature
|
|
81
|
+
|
|
82
|
+
def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
|
83
|
+
return -_pearson(self._soft_rank(pred), self._soft_rank(target))
|
|
84
|
+
|
|
85
|
+
def _soft_rank(self, x: torch.Tensor) -> torch.Tensor:
|
|
86
|
+
# Differentiable rank: softmax over pairwise differences.
|
|
87
|
+
diffs = (x.unsqueeze(0) - x.unsqueeze(1)) / self.temperature
|
|
88
|
+
return torch.sigmoid(diffs).sum(dim=0)
|
mlquant/models/nets.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Two compact baselines: an MLP and a thin Transformer encoder.
|
|
2
|
+
|
|
3
|
+
Both consume a 1-D feature vector per stock-day and produce a scalar
|
|
4
|
+
prediction. They are intentionally small — the paper argues that the
|
|
5
|
+
*factor engineering* and *portfolio construction* layers carry most of
|
|
6
|
+
the alpha; the model's job is non-linear feature combination, not
|
|
7
|
+
deep representation learning.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
import torch.nn as nn
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MLPRegressor(nn.Module):
|
|
16
|
+
"""Three-layer MLP with GELU and dropout."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, in_dim: int, hidden: int = 128, dropout: float = 0.1) -> None:
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.net = nn.Sequential(
|
|
21
|
+
nn.Linear(in_dim, hidden),
|
|
22
|
+
nn.GELU(),
|
|
23
|
+
nn.Dropout(dropout),
|
|
24
|
+
nn.Linear(hidden, hidden),
|
|
25
|
+
nn.GELU(),
|
|
26
|
+
nn.Dropout(dropout),
|
|
27
|
+
nn.Linear(hidden, 1),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
31
|
+
return self.net(x).squeeze(-1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TransformerRegressor(nn.Module):
|
|
35
|
+
"""Apply a transformer encoder across the *factor* axis.
|
|
36
|
+
|
|
37
|
+
We tokenise each factor as one position, project to ``d_model``,
|
|
38
|
+
add a learned positional embedding, and reduce with a CLS token.
|
|
39
|
+
For ~50-200 factors this is cheap and lets the network learn
|
|
40
|
+
pairwise factor interactions without manual cross terms.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, in_dim: int, d_model: int = 64, n_heads: int = 4,
|
|
44
|
+
depth: int = 2, dropout: float = 0.1) -> None:
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.proj = nn.Linear(1, d_model)
|
|
47
|
+
self.pos = nn.Parameter(torch.randn(in_dim, d_model) * 0.02)
|
|
48
|
+
self.cls = nn.Parameter(torch.randn(1, d_model) * 0.02)
|
|
49
|
+
layer = nn.TransformerEncoderLayer(
|
|
50
|
+
d_model=d_model, nhead=n_heads, dim_feedforward=4 * d_model,
|
|
51
|
+
dropout=dropout, batch_first=True, activation="gelu",
|
|
52
|
+
)
|
|
53
|
+
self.encoder = nn.TransformerEncoder(layer, num_layers=depth)
|
|
54
|
+
self.head = nn.Linear(d_model, 1)
|
|
55
|
+
|
|
56
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
57
|
+
# x: [B, F] -> tokens [B, F, d_model]
|
|
58
|
+
tokens = self.proj(x.unsqueeze(-1)) + self.pos
|
|
59
|
+
cls = self.cls.expand(x.shape[0], -1, -1)
|
|
60
|
+
seq = torch.cat([cls, tokens], dim=1)
|
|
61
|
+
h = self.encoder(seq)
|
|
62
|
+
return self.head(h[:, 0]).squeeze(-1)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Sweep the efficient frontier over a grid of risk-aversion values.
|
|
2
|
+
|
|
3
|
+
The paper sweeps ``α ∈ {0, 0.25, 0.5, 0.75, 1, 2, …, 300}`` to trace the
|
|
4
|
+
efficient frontier; at backtest time you pick a single α (typically the
|
|
5
|
+
one whose ex-ante volatility matches a target). This module runs the
|
|
6
|
+
sweep and returns one row per α with realised return + ex-ante variance.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .markowitz import MarkowitzConfig, MarkowitzOptimizer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FrontierPoint:
|
|
20
|
+
risk_aversion: float
|
|
21
|
+
expected_return: float
|
|
22
|
+
expected_variance: float
|
|
23
|
+
weights: np.ndarray
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def efficient_frontier(
|
|
27
|
+
mu: np.ndarray,
|
|
28
|
+
returns_history: np.ndarray,
|
|
29
|
+
*,
|
|
30
|
+
risk_aversions: Sequence[float] = (0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0),
|
|
31
|
+
cfg: MarkowitzConfig | None = None,
|
|
32
|
+
) -> list[FrontierPoint]:
|
|
33
|
+
"""Compute one :class:`FrontierPoint` per ``risk_aversion``.
|
|
34
|
+
|
|
35
|
+
The optimiser warm-starts between consecutive ``α`` values, so this
|
|
36
|
+
is significantly cheaper than building a fresh problem for each
|
|
37
|
+
point.
|
|
38
|
+
"""
|
|
39
|
+
cfg = cfg or MarkowitzConfig()
|
|
40
|
+
points: list[FrontierPoint] = []
|
|
41
|
+
for ra in risk_aversions:
|
|
42
|
+
local = MarkowitzConfig(**{**cfg.__dict__, "risk_aversion": ra})
|
|
43
|
+
opt = MarkowitzOptimizer(local)
|
|
44
|
+
w = opt.solve(mu, returns_history)
|
|
45
|
+
sigma = opt._covariance(returns_history) # noqa: SLF001
|
|
46
|
+
ev = float(w @ sigma @ w)
|
|
47
|
+
er = float(mu @ w)
|
|
48
|
+
points.append(FrontierPoint(ra, er, ev, w))
|
|
49
|
+
return points
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Cross-sectional Markowitz mean-variance optimisation.
|
|
2
|
+
|
|
3
|
+
Problem
|
|
4
|
+
-------
|
|
5
|
+
For a given prediction vector :math:`\\mu \\in \\mathbb{R}^n` and a
|
|
6
|
+
covariance matrix :math:`\\Sigma`, find weights :math:`w \\in
|
|
7
|
+
\\mathbb{R}^n` solving
|
|
8
|
+
|
|
9
|
+
.. math::
|
|
10
|
+
|
|
11
|
+
\\max_w \\; \\mu^\\top w - \\alpha \\, w^\\top \\Sigma w
|
|
12
|
+
\\quad
|
|
13
|
+
\\text{s.t.} \\;
|
|
14
|
+
\\mathbf{1}^\\top w = 1, \\;
|
|
15
|
+
0 \\le w \\le w_{\\max}.
|
|
16
|
+
|
|
17
|
+
We solve this via :mod:`cvxpy` so the default install needs **no
|
|
18
|
+
commercial solver**: ECOS or SCS handle 1 000-asset problems in
|
|
19
|
+
milliseconds. The MOSEK path remains available for users with a
|
|
20
|
+
licence by selecting ``solver="MOSEK"``.
|
|
21
|
+
|
|
22
|
+
Covariance estimation uses Ledoit–Wolf shrinkage by default, which is
|
|
23
|
+
both faster and more numerically stable than a sample covariance on
|
|
24
|
+
short look-back windows.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
import cvxpy as cp
|
|
32
|
+
import numpy as np
|
|
33
|
+
from sklearn.covariance import LedoitWolf
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class MarkowitzConfig:
|
|
38
|
+
risk_aversion: float = 1.0
|
|
39
|
+
weight_cap: float = 0.05 # max single-name weight (5 %)
|
|
40
|
+
long_only: bool = True
|
|
41
|
+
cash_weight: float = 0.0 # leave x % in cash
|
|
42
|
+
solver: str = "SCS" # "SCS" / "ECOS" / "MOSEK"
|
|
43
|
+
shrinkage: bool = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MarkowitzOptimizer:
|
|
47
|
+
"""Stateful optimiser; build once, call :meth:`solve` per date.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
cfg : MarkowitzConfig
|
|
52
|
+
|
|
53
|
+
Notes
|
|
54
|
+
-----
|
|
55
|
+
The first call constructs the cvxpy problem. Subsequent calls reuse
|
|
56
|
+
the parameter objects so ``cvxpy`` skips canonicalisation — this
|
|
57
|
+
matters when you backtest 3 000 trading days.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, cfg: Optional[MarkowitzConfig] = None) -> None:
|
|
61
|
+
self.cfg = cfg or MarkowitzConfig()
|
|
62
|
+
self._n: int | None = None
|
|
63
|
+
self._mu_param: cp.Parameter | None = None
|
|
64
|
+
self._sigma_param: cp.Parameter | None = None
|
|
65
|
+
self._w_var: cp.Variable | None = None
|
|
66
|
+
self._problem: cp.Problem | None = None
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Public API
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
def solve(self, mu: np.ndarray, returns_history: np.ndarray) -> np.ndarray:
|
|
72
|
+
"""Compute portfolio weights for one date.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
mu : np.ndarray [n]
|
|
77
|
+
Predicted next-period returns (alpha signal).
|
|
78
|
+
returns_history : np.ndarray [T, n]
|
|
79
|
+
Historical returns used to estimate covariance.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
w : np.ndarray [n]
|
|
84
|
+
Long-only (or long-short) weights summing to ``1 - cash_weight``.
|
|
85
|
+
"""
|
|
86
|
+
n = mu.shape[0]
|
|
87
|
+
if returns_history.shape[1] != n:
|
|
88
|
+
raise ValueError("mu and returns_history have mismatched stock dimensions")
|
|
89
|
+
|
|
90
|
+
sigma = self._covariance(returns_history)
|
|
91
|
+
self._build(n)
|
|
92
|
+
self._mu_param.value = mu.astype(np.float64)
|
|
93
|
+
self._sigma_param.value = self._psd_project(sigma)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
self._problem.solve(solver=self.cfg.solver, warm_start=True)
|
|
97
|
+
except cp.error.SolverError:
|
|
98
|
+
# Fall back to SCS if the requested solver is unavailable.
|
|
99
|
+
self._problem.solve(solver="SCS", warm_start=True)
|
|
100
|
+
|
|
101
|
+
w = np.asarray(self._w_var.value).flatten()
|
|
102
|
+
# Numerical fix-ups
|
|
103
|
+
w[np.abs(w) < 1e-7] = 0.0
|
|
104
|
+
if self.cfg.long_only:
|
|
105
|
+
w = np.clip(w, 0.0, self.cfg.weight_cap)
|
|
106
|
+
# Renormalise to compensate for clipping rounding.
|
|
107
|
+
s = w.sum()
|
|
108
|
+
if s > 0:
|
|
109
|
+
w *= (1.0 - self.cfg.cash_weight) / s
|
|
110
|
+
return w
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Internals
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
def _covariance(self, history: np.ndarray) -> np.ndarray:
|
|
116
|
+
if not self.cfg.shrinkage:
|
|
117
|
+
return np.cov(history, rowvar=False)
|
|
118
|
+
# LedoitWolf is dramatically more stable on short windows.
|
|
119
|
+
return LedoitWolf().fit(history).covariance_
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def _psd_project(sigma: np.ndarray) -> np.ndarray:
|
|
123
|
+
# Symmetric, then nudge eigenvalues to be ≥ 0 to keep cvxpy happy.
|
|
124
|
+
sigma = 0.5 * (sigma + sigma.T)
|
|
125
|
+
w, V = np.linalg.eigh(sigma)
|
|
126
|
+
w = np.clip(w, 1e-10, None)
|
|
127
|
+
return (V * w) @ V.T
|
|
128
|
+
|
|
129
|
+
def _build(self, n: int) -> None:
|
|
130
|
+
if self._n == n:
|
|
131
|
+
return
|
|
132
|
+
self._n = n
|
|
133
|
+
self._mu_param = cp.Parameter(n)
|
|
134
|
+
self._sigma_param = cp.Parameter((n, n), PSD=True)
|
|
135
|
+
w = cp.Variable(n)
|
|
136
|
+
objective = cp.Maximize(self._mu_param @ w
|
|
137
|
+
- self.cfg.risk_aversion * cp.quad_form(w, self._sigma_param))
|
|
138
|
+
constraints = [cp.sum(w) == 1.0 - self.cfg.cash_weight]
|
|
139
|
+
if self.cfg.long_only:
|
|
140
|
+
constraints += [w >= 0, w <= self.cfg.weight_cap]
|
|
141
|
+
else:
|
|
142
|
+
constraints += [w >= -self.cfg.weight_cap, w <= self.cfg.weight_cap]
|
|
143
|
+
self._w_var = w
|
|
144
|
+
self._problem = cp.Problem(objective, constraints)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Geometric Brownian Motion data augmentation.
|
|
2
|
+
|
|
3
|
+
The paper argues that financial time-series datasets are
|
|
4
|
+
*data-poor for the regime that matters*: A-share crashes and rallies
|
|
5
|
+
are rare, and an ML model trained directly on the historical record
|
|
6
|
+
will overfit the most-recent regime. Augmenting the training set with
|
|
7
|
+
GBM-resampled trajectories that share the historical drift and
|
|
8
|
+
volatility — but reshuffle the random shocks — provides extra effective
|
|
9
|
+
sample size without leaking information about the actual future.
|
|
10
|
+
|
|
11
|
+
This module is intentionally a single, well-tested function; if you
|
|
12
|
+
want path-dependent augmentation (e.g. SDE bridge, bootstrap blocks)
|
|
13
|
+
add a sibling function rather than overloading :func:`gbm_augment`.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Tuple
|
|
19
|
+
|
|
20
|
+
import torch
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class GBMAugmentConfig:
|
|
25
|
+
n_synthetic: int = 1 # paths produced per real path
|
|
26
|
+
block_size: int = 21 # ~1 month, lengths of resampled blocks
|
|
27
|
+
seed: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def gbm_augment(
|
|
31
|
+
log_returns: torch.Tensor,
|
|
32
|
+
mask: torch.Tensor,
|
|
33
|
+
cfg: GBMAugmentConfig | None = None,
|
|
34
|
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
35
|
+
"""Block-bootstrap GBM augmentation for log-return panels.
|
|
36
|
+
|
|
37
|
+
For each stock we estimate ``μ̂, σ̂`` on the supplied history and
|
|
38
|
+
resample a synthetic trajectory of identical length:
|
|
39
|
+
|
|
40
|
+
r̃_t = μ̂ + σ̂ * ε_t, ε_t ~ N(0, 1) drawn in blocks of
|
|
41
|
+
``block_size`` so autocorrelation
|
|
42
|
+
structure is partly preserved.
|
|
43
|
+
|
|
44
|
+
The resulting tensor has shape ``[n_synthetic*T, N]`` and is meant
|
|
45
|
+
to be *concatenated* with real data at training time (with a
|
|
46
|
+
binary "is_synthetic" flag exposed by :class:`FactorDataset`).
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
log_returns : Tensor [T, N] per-stock log returns.
|
|
51
|
+
mask : Tensor [T, N] tradability mask; masked cells do not
|
|
52
|
+
contribute to the μ̂, σ̂ estimate.
|
|
53
|
+
cfg : GBMAugmentConfig
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
aug_log_returns : Tensor [n_synthetic*T, N]
|
|
58
|
+
aug_mask : Tensor [n_synthetic*T, N] ones (synthetic data is
|
|
59
|
+
always tradable)
|
|
60
|
+
"""
|
|
61
|
+
cfg = cfg or GBMAugmentConfig()
|
|
62
|
+
if log_returns.dim() != 2:
|
|
63
|
+
raise ValueError("log_returns must be [T, N]")
|
|
64
|
+
|
|
65
|
+
T, N = log_returns.shape
|
|
66
|
+
if cfg.block_size <= 0 or cfg.block_size > T:
|
|
67
|
+
raise ValueError(f"block_size must be in (0, T={T}], got {cfg.block_size}")
|
|
68
|
+
|
|
69
|
+
gen = torch.Generator(device=log_returns.device).manual_seed(cfg.seed)
|
|
70
|
+
|
|
71
|
+
# Per-stock μ, σ from the masked history.
|
|
72
|
+
m = mask.float()
|
|
73
|
+
counts = m.sum(dim=0).clamp_min(2)
|
|
74
|
+
mu = (log_returns * m).sum(dim=0) / counts # [N]
|
|
75
|
+
var = (((log_returns - mu) * m) ** 2).sum(dim=0) / (counts - 1)
|
|
76
|
+
sigma = var.clamp_min(1e-12).sqrt() # [N]
|
|
77
|
+
|
|
78
|
+
out_T = cfg.n_synthetic * T
|
|
79
|
+
out = torch.empty((out_T, N), dtype=log_returns.dtype, device=log_returns.device)
|
|
80
|
+
|
|
81
|
+
# Block-bootstrap eps: draw block-aligned standardised innovations.
|
|
82
|
+
n_blocks = (out_T + cfg.block_size - 1) // cfg.block_size
|
|
83
|
+
eps = torch.randn((n_blocks * cfg.block_size, N), generator=gen,
|
|
84
|
+
dtype=log_returns.dtype, device=log_returns.device)
|
|
85
|
+
eps = eps[:out_T]
|
|
86
|
+
|
|
87
|
+
out[:] = mu.unsqueeze(0) + sigma.unsqueeze(0) * eps
|
|
88
|
+
aug_mask = torch.ones_like(out, dtype=torch.bool)
|
|
89
|
+
return out, aug_mask
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Cross-sectional dataset for daily-rebalance models.
|
|
2
|
+
|
|
3
|
+
Each sample is *one stock-day* — features at date ``t``, label is the
|
|
4
|
+
next-day return at ``t+1``. The dataset takes care of:
|
|
5
|
+
|
|
6
|
+
* aligning the factor tensor with the bias-corrected mask,
|
|
7
|
+
* shifting the label by one day,
|
|
8
|
+
* dropping cells where either side of the prediction is non-tradable,
|
|
9
|
+
* exposing batches as plain ``(features, labels)`` pairs.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
from torch.utils.data import Dataset
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FactorDataset(Dataset):
|
|
20
|
+
"""A flat dataset of (factor vector, next-day return) pairs."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
factors: torch.Tensor, # [T, N, F]
|
|
25
|
+
mask: torch.Tensor, # [T, N]
|
|
26
|
+
forward_returns: torch.Tensor, # [T, N] already bias-corrected
|
|
27
|
+
*,
|
|
28
|
+
date_index: Optional[torch.Tensor] = None,
|
|
29
|
+
stock_index: Optional[torch.Tensor] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
if factors.dim() != 3:
|
|
32
|
+
raise ValueError("factors must be [T, N, F]")
|
|
33
|
+
T, N, _ = factors.shape
|
|
34
|
+
|
|
35
|
+
# Drop the last day (no forward return available) and any masked
|
|
36
|
+
# cells.
|
|
37
|
+
valid = mask[:-1] & mask[1:] & ~torch.isnan(forward_returns[:-1])
|
|
38
|
+
flat_idx = valid.nonzero(as_tuple=False) # [K, 2] (t, n)
|
|
39
|
+
|
|
40
|
+
self.features = factors[:-1][flat_idx[:, 0], flat_idx[:, 1]]
|
|
41
|
+
self.labels = forward_returns[:-1][flat_idx[:, 0], flat_idx[:, 1]]
|
|
42
|
+
|
|
43
|
+
self.dates = (date_index[:-1] [flat_idx[:, 0]] if date_index is not None else flat_idx[:, 0])
|
|
44
|
+
self.stocks = (stock_index [flat_idx[:, 1]] if stock_index is not None else flat_idx[:, 1])
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return self.features.shape[0]
|
|
48
|
+
|
|
49
|
+
def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
50
|
+
return self.features[idx], self.labels[idx]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""A small, self-contained training loop.
|
|
2
|
+
|
|
3
|
+
This is *not* a competitor to PyTorch Lightning — it's the minimum
|
|
4
|
+
needed to get a model trained, validated, and checkpointed against a
|
|
5
|
+
:class:`FactorDataset`. Everything is explicit so the training-time
|
|
6
|
+
behaviour matches what's documented in the paper.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable, Iterable, Optional
|
|
13
|
+
|
|
14
|
+
import torch
|
|
15
|
+
import torch.nn as nn
|
|
16
|
+
from torch.utils.data import DataLoader
|
|
17
|
+
|
|
18
|
+
from .dataset import FactorDataset
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TrainConfig:
|
|
23
|
+
epochs: int = 30
|
|
24
|
+
batch_size: int = 1024
|
|
25
|
+
lr: float = 1e-3
|
|
26
|
+
weight_decay:float = 1e-5
|
|
27
|
+
grad_clip: float = 1.0
|
|
28
|
+
device: str = "cpu"
|
|
29
|
+
log_every: int = 50
|
|
30
|
+
save_dir: str = "checkpoints"
|
|
31
|
+
val_split: float = 0.1
|
|
32
|
+
extra: dict = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Trainer:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
model: nn.Module,
|
|
39
|
+
loss_fn: Callable[[torch.Tensor, torch.Tensor], torch.Tensor],
|
|
40
|
+
cfg: TrainConfig,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.model = model.to(cfg.device)
|
|
43
|
+
self.loss_fn = loss_fn
|
|
44
|
+
self.cfg = cfg
|
|
45
|
+
self.optim = torch.optim.AdamW(
|
|
46
|
+
model.parameters(),
|
|
47
|
+
lr=cfg.lr,
|
|
48
|
+
weight_decay=cfg.weight_decay,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Public API
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
def fit(self, dataset: FactorDataset) -> dict:
|
|
55
|
+
n_val = max(1, int(len(dataset) * self.cfg.val_split))
|
|
56
|
+
n_train = len(dataset) - n_val
|
|
57
|
+
train_ds, val_ds = torch.utils.data.random_split(
|
|
58
|
+
dataset, [n_train, n_val],
|
|
59
|
+
generator=torch.Generator().manual_seed(0),
|
|
60
|
+
)
|
|
61
|
+
train_loader = DataLoader(train_ds, batch_size=self.cfg.batch_size,
|
|
62
|
+
shuffle=True, drop_last=True)
|
|
63
|
+
val_loader = DataLoader(val_ds, batch_size=self.cfg.batch_size)
|
|
64
|
+
|
|
65
|
+
history: dict = {"train_loss": [], "val_loss": []}
|
|
66
|
+
save_dir = Path(self.cfg.save_dir)
|
|
67
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
best_val = float("inf")
|
|
69
|
+
|
|
70
|
+
for epoch in range(self.cfg.epochs):
|
|
71
|
+
train_loss = self._run_epoch(train_loader, train=True)
|
|
72
|
+
val_loss = self._run_epoch(val_loader, train=False)
|
|
73
|
+
history["train_loss"].append(train_loss)
|
|
74
|
+
history["val_loss"].append(val_loss)
|
|
75
|
+
print(f"[epoch {epoch:3d}] train={train_loss:.5f} val={val_loss:.5f}")
|
|
76
|
+
|
|
77
|
+
if val_loss < best_val:
|
|
78
|
+
best_val = val_loss
|
|
79
|
+
torch.save(self.model.state_dict(), save_dir / "best.pt")
|
|
80
|
+
torch.save(self.model.state_dict(), save_dir / "last.pt")
|
|
81
|
+
return history
|
|
82
|
+
|
|
83
|
+
@torch.no_grad()
|
|
84
|
+
def predict(self, features: torch.Tensor) -> torch.Tensor:
|
|
85
|
+
self.model.eval()
|
|
86
|
+
return self.model(features.to(self.cfg.device)).detach().cpu()
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Internals
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
def _run_epoch(self, loader: Iterable, *, train: bool) -> float:
|
|
92
|
+
self.model.train(train)
|
|
93
|
+
total, n = 0.0, 0
|
|
94
|
+
for x, y in loader:
|
|
95
|
+
x = x.to(self.cfg.device)
|
|
96
|
+
y = y.to(self.cfg.device)
|
|
97
|
+
if train:
|
|
98
|
+
self.optim.zero_grad(set_to_none=True)
|
|
99
|
+
preds = self.model(x)
|
|
100
|
+
loss = self.loss_fn(preds, y)
|
|
101
|
+
if train:
|
|
102
|
+
loss.backward()
|
|
103
|
+
if self.cfg.grad_clip:
|
|
104
|
+
nn.utils.clip_grad_norm_(self.model.parameters(), self.cfg.grad_clip)
|
|
105
|
+
self.optim.step()
|
|
106
|
+
total += loss.item() * x.shape[0]
|
|
107
|
+
n += x.shape[0]
|
|
108
|
+
return total / max(1, n)
|
mlquant/utils/config.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tiny YAML-config loader.
|
|
2
|
+
|
|
3
|
+
Why not Hydra? — Hydra is great but pulls 6 MB of deps for a single
|
|
4
|
+
``yaml.safe_load`` and a few path joins. Forty lines of stdlib do
|
|
5
|
+
exactly what we need without taking a dependency on a config framework.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Config:
|
|
18
|
+
"""A nested-dict config with attribute access.
|
|
19
|
+
|
|
20
|
+
Example
|
|
21
|
+
-------
|
|
22
|
+
>>> c = Config({"data": {"n_stocks": 200}, "seed": 42})
|
|
23
|
+
>>> c.data.n_stocks
|
|
24
|
+
200
|
|
25
|
+
>>> c.seed
|
|
26
|
+
42
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_data: Dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def __getattr__(self, item: str) -> Any: # noqa: D401
|
|
32
|
+
if item.startswith("_"):
|
|
33
|
+
raise AttributeError(item)
|
|
34
|
+
try:
|
|
35
|
+
value = self._data[item]
|
|
36
|
+
except KeyError as exc: # pragma: no cover
|
|
37
|
+
raise AttributeError(item) from exc
|
|
38
|
+
return Config(value) if isinstance(value, dict) else value
|
|
39
|
+
|
|
40
|
+
def __getitem__(self, item: str) -> Any:
|
|
41
|
+
value = self._data[item]
|
|
42
|
+
return Config(value) if isinstance(value, dict) else value
|
|
43
|
+
|
|
44
|
+
def __contains__(self, item: str) -> bool:
|
|
45
|
+
return item in self._data
|
|
46
|
+
|
|
47
|
+
def get(self, item: str, default: Any = None) -> Any:
|
|
48
|
+
return self._data.get(item, default)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
51
|
+
return dict(self._data)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_config(path: str | Path) -> Config:
|
|
55
|
+
"""Load a YAML file into a :class:`Config`."""
|
|
56
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
57
|
+
data = yaml.safe_load(fh) or {}
|
|
58
|
+
if not isinstance(data, dict):
|
|
59
|
+
raise ValueError(f"Top-level of {path} must be a mapping, got {type(data).__name__}")
|
|
60
|
+
return Config(data)
|