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,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,5 @@
1
+ """Cross-sectional Markowitz with α-sweep + shrunk covariance."""
2
+ from .markowitz import MarkowitzOptimizer, MarkowitzConfig
3
+ from .frontier import efficient_frontier
4
+
5
+ __all__ = ["MarkowitzOptimizer", "MarkowitzConfig", "efficient_frontier"]
@@ -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,6 @@
1
+ """Datasets, GBM augmentation, and the training loop."""
2
+ from .dataset import FactorDataset
3
+ from .augment import gbm_augment
4
+ from .trainer import Trainer, TrainConfig
5
+
6
+ __all__ = ["FactorDataset", "gbm_augment", "Trainer", "TrainConfig"]
@@ -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)
@@ -0,0 +1,5 @@
1
+ """Cross-cutting utilities: config loading, seeding, logging."""
2
+ from .config import Config, load_config
3
+ from .seed import seed_everything
4
+
5
+ __all__ = ["Config", "load_config", "seed_everything"]
@@ -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)