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
mlquant/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """mlquant — reference implementation of the ML-quant-trading paper.
2
+
3
+ Public sub-packages:
4
+ data — panel loaders and synthetic data generators
5
+ features — tensor-accelerated factor engine and alpha library
6
+ training — datasets, GBM augmentation, training loop
7
+ models — neural networks and sign-aware losses
8
+ portfolio — cross-sectional Markowitz with α-sweep
9
+ backtest — vectorised backtest engine and metrics
10
+ cli — Click entry points used by `mlquant ...`
11
+
12
+ The package is import-light: `import mlquant` does **not** transitively
13
+ import torch or cvxpy. Sub-packages are loaded lazily on demand.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ __version__ = "0.2.5"
18
+ __all__ = ["__version__"]
@@ -0,0 +1,14 @@
1
+ """Vectorised backtest engine + reporting metrics."""
2
+ from .engine import run_backtest, BacktestResult
3
+ from .metrics import (
4
+ sharpe_ratio, sortino_ratio, calmar_ratio,
5
+ annualised_return, max_drawdown,
6
+ information_coefficient, rank_information_coefficient,
7
+ )
8
+
9
+ __all__ = [
10
+ "run_backtest", "BacktestResult",
11
+ "sharpe_ratio", "sortino_ratio", "calmar_ratio",
12
+ "annualised_return", "max_drawdown",
13
+ "information_coefficient", "rank_information_coefficient",
14
+ ]
@@ -0,0 +1,118 @@
1
+ """Vectorised backtest engine.
2
+
3
+ Inputs
4
+ ------
5
+ weights : np.ndarray [T, N]
6
+ Position weights. Row ``t`` is the weight vector chosen *with
7
+ information up to date t* and applied for the period ``t -> t+1``.
8
+ returns : np.ndarray [T, N]
9
+ Realised simple returns from ``t-1 -> t`` (so ``returns[t]`` is
10
+ earned by ``weights[t-1]``).
11
+ costs_bps : float
12
+ Round-trip transaction cost in basis points charged on absolute
13
+ weight changes. The default 5 bps roughly matches A-share retail
14
+ commissions + half-spread on liquid names.
15
+
16
+ The engine is intentionally tiny — every realistic refinement (slippage
17
+ models, borrow fees, T+1 settlement) goes into a sibling module rather
18
+ than here. Doing so keeps the core path easy to audit.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from typing import Optional
24
+
25
+ import numpy as np
26
+
27
+ from . import metrics
28
+
29
+
30
+ @dataclass
31
+ class BacktestResult:
32
+ portfolio_returns: np.ndarray # [T] net of costs
33
+ gross_returns: np.ndarray # [T] gross
34
+ cumulative_equity: np.ndarray # [T]
35
+ weights: np.ndarray # [T, N]
36
+ cost_drag_cumulative: float # total cost paid over the whole run
37
+ metrics: dict # summary table
38
+
39
+ @property
40
+ def cost_drag(self) -> float:
41
+ """Deprecated alias for :attr:`cost_drag_cumulative`.
42
+
43
+ The name was ambiguous: this figure is cumulative over the backtest,
44
+ while ``ann_return``, ``gross_ann_return`` and ``ann_vol`` alongside it
45
+ are annualised. Kept for one release so existing scripts and archived
46
+ JSON reports keep working. Prefer ``cost_drag_cumulative``.
47
+ """
48
+ return self.cost_drag_cumulative
49
+
50
+ def summary(self) -> dict:
51
+ return self.metrics
52
+
53
+ def __repr__(self) -> str: # pragma: no cover
54
+ m = self.metrics
55
+ return (
56
+ "BacktestResult("
57
+ f"ann_return={m.get('ann_return', 0):.2%}, "
58
+ f"sharpe={m.get('sharpe', 0):.2f}, "
59
+ f"max_dd={m.get('max_dd', 0):.2%}, "
60
+ f"turnover={m.get('turnover', 0):.2%})"
61
+ )
62
+
63
+
64
+ def run_backtest(
65
+ weights: np.ndarray,
66
+ returns: np.ndarray,
67
+ *,
68
+ costs_bps: float = 5.0,
69
+ benchmark: Optional[np.ndarray] = None,
70
+ ) -> BacktestResult:
71
+ """Run a long-only / long-short vector backtest."""
72
+ if weights.shape != returns.shape:
73
+ raise ValueError(f"shape mismatch: weights {weights.shape} returns {returns.shape}")
74
+
75
+ T, _ = weights.shape
76
+
77
+ # Lagged weights: weights[t-1] is what's earned over t-1 -> t.
78
+ w_lag = np.zeros_like(weights)
79
+ w_lag[1:] = weights[:-1]
80
+
81
+ gross = (w_lag * returns).sum(axis=1)
82
+ # Cost drag from rebalancing day t.
83
+ delta = np.zeros_like(weights)
84
+ delta[1:] = np.abs(weights[1:] - weights[:-1])
85
+ cost = delta.sum(axis=1) * (costs_bps * 1e-4)
86
+ net = gross - cost
87
+
88
+ equity = np.cumprod(1.0 + net)
89
+
90
+ summary = {
91
+ "ann_return": metrics.annualised_return(net),
92
+ "ann_vol": metrics.annualised_vol(net),
93
+ "sharpe": metrics.sharpe_ratio(net),
94
+ "sortino": metrics.sortino_ratio(net),
95
+ "calmar": metrics.calmar_ratio(net),
96
+ "max_dd": metrics.max_drawdown(net),
97
+ "turnover": metrics.turnover(weights),
98
+ # Cumulative over the run, unlike its annualised neighbours above.
99
+ # Both keys carry the same value for one release; `cost_drag` is
100
+ # deprecated. See docs/public_data_validation.md for the glossary.
101
+ "cost_drag_cumulative": float(cost.sum()),
102
+ "cost_drag": float(cost.sum()),
103
+ "n_periods": int(T),
104
+ }
105
+
106
+ if benchmark is not None and benchmark.shape == net.shape:
107
+ active = net - benchmark
108
+ summary["info_ratio"] = metrics.sharpe_ratio(active)
109
+ summary["alpha_ann"] = metrics.annualised_return(active)
110
+
111
+ return BacktestResult(
112
+ portfolio_returns=net,
113
+ gross_returns=gross,
114
+ cumulative_equity=equity,
115
+ weights=weights,
116
+ cost_drag_cumulative=float(cost.sum()),
117
+ metrics=summary,
118
+ )
@@ -0,0 +1,95 @@
1
+ """Performance and information-content metrics.
2
+
3
+ All functions take 1-D numpy arrays so they can be unit-tested without
4
+ torch. The conventions follow Bailey & López de Prado (2014):
5
+
6
+ annualised return := (1 + mean(r))**P - 1
7
+ annualised vol := std(r) * sqrt(P)
8
+ Sharpe := annualised return / annualised vol
9
+ Sortino := annualised return / (downside std * sqrt(P))
10
+ Calmar := annualised return / max drawdown
11
+
12
+ with ``P = 252`` for daily series.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+
18
+ DAYS_PER_YEAR = 252
19
+
20
+
21
+ def annualised_return(returns: np.ndarray, *, periods: int = DAYS_PER_YEAR) -> float:
22
+ if returns.size == 0:
23
+ return 0.0
24
+ return float((1.0 + returns).prod() ** (periods / returns.size) - 1.0)
25
+
26
+
27
+ def annualised_vol(returns: np.ndarray, *, periods: int = DAYS_PER_YEAR) -> float:
28
+ return float(returns.std(ddof=1) * np.sqrt(periods)) if returns.size > 1 else 0.0
29
+
30
+
31
+ def sharpe_ratio(returns: np.ndarray, *, rf: float = 0.0, periods: int = DAYS_PER_YEAR) -> float:
32
+ excess = returns - rf / periods
33
+ vol = excess.std(ddof=1)
34
+ if vol < 1e-12:
35
+ return 0.0
36
+ return float(excess.mean() / vol * np.sqrt(periods))
37
+
38
+
39
+ def sortino_ratio(returns: np.ndarray, *, rf: float = 0.0, periods: int = DAYS_PER_YEAR) -> float:
40
+ excess = returns - rf / periods
41
+ downside = excess[excess < 0]
42
+ if downside.size == 0:
43
+ return float("inf")
44
+ if downside.size < 2:
45
+ return 0.0
46
+ dd = downside.std(ddof=1)
47
+ if not np.isfinite(dd) or dd < 1e-12:
48
+ return 0.0
49
+ return float(excess.mean() / dd * np.sqrt(periods))
50
+
51
+
52
+ def max_drawdown(returns: np.ndarray) -> float:
53
+ if returns.size == 0:
54
+ return 0.0
55
+ equity = (1.0 + returns).cumprod()
56
+ peak = np.maximum.accumulate(equity)
57
+ return float(((peak - equity) / peak).max())
58
+
59
+
60
+ def calmar_ratio(returns: np.ndarray, *, periods: int = DAYS_PER_YEAR) -> float:
61
+ mdd = max_drawdown(returns)
62
+ if mdd < 1e-12:
63
+ return float("inf")
64
+ return annualised_return(returns, periods=periods) / mdd
65
+
66
+
67
+ def information_coefficient(pred: np.ndarray, target: np.ndarray) -> float:
68
+ """Pearson IC. NaNs are dropped pairwise."""
69
+ valid = np.isfinite(pred) & np.isfinite(target)
70
+ if valid.sum() < 2:
71
+ return 0.0
72
+ p = pred[valid]
73
+ t = target[valid]
74
+ pm = p - p.mean()
75
+ tm = t - t.mean()
76
+ denom = np.sqrt((pm ** 2).sum() * (tm ** 2).sum())
77
+ if denom < 1e-12:
78
+ return 0.0
79
+ return float((pm * tm).sum() / denom)
80
+
81
+
82
+ def rank_information_coefficient(pred: np.ndarray, target: np.ndarray) -> float:
83
+ """Spearman IC."""
84
+ from scipy.stats import rankdata
85
+ valid = np.isfinite(pred) & np.isfinite(target)
86
+ if valid.sum() < 2:
87
+ return 0.0
88
+ return information_coefficient(rankdata(pred[valid]), rankdata(target[valid]))
89
+
90
+
91
+ def turnover(weights: np.ndarray) -> float:
92
+ """Average L1 turnover between consecutive weight vectors."""
93
+ if weights.shape[0] < 2:
94
+ return 0.0
95
+ return float(0.5 * np.abs(weights[1:] - weights[:-1]).sum(axis=1).mean())
@@ -0,0 +1 @@
1
+ """``mlquant`` Click entry points (``mlquant gen-data``, ``mlquant features`` ...)."""
mlquant/cli/main.py ADDED
@@ -0,0 +1,292 @@
1
+ """Command-line entry point: ``mlquant <subcommand> --config configs/foo.yaml``.
2
+
3
+ Subcommands form a deterministic pipeline; each writes its outputs to a
4
+ sub-directory of ``artifacts/`` so downstream stages can be re-run
5
+ without recomputing upstream work.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import pickle
12
+ from importlib.resources import as_file, files
13
+ from pathlib import Path
14
+
15
+ import click
16
+ import numpy as np
17
+ import torch
18
+
19
+ from ..backtest.engine import run_backtest
20
+ from ..data.synthetic import SyntheticConfig, make_synthetic_panel
21
+ from ..features.legacy_factors import compute_legacy_set
22
+ from ..features.alpha101 import compute_alpha_set # backward compat
23
+ from ..features.bias import limit_move_mask
24
+ from ..models.losses import AdjMSELoss
25
+ from ..models.nets import MLPRegressor
26
+ from ..portfolio.markowitz import MarkowitzConfig, MarkowitzOptimizer
27
+ from ..training.dataset import FactorDataset
28
+ from ..training.trainer import TrainConfig, Trainer
29
+ from ..utils.config import load_config
30
+ from ..utils.seed import seed_everything
31
+
32
+
33
+ def _artifacts_dir(cfg) -> Path:
34
+ p = Path(cfg.get("artifacts_dir", "artifacts"))
35
+ p.mkdir(parents=True, exist_ok=True)
36
+ return p
37
+
38
+
39
+ def _write_backtest_summary(
40
+ output_dir: Path,
41
+ metrics: dict,
42
+ *,
43
+ config_path: str,
44
+ costs_bps: float,
45
+ ) -> tuple[Path, Path]:
46
+ """Write portable JSON and Markdown summaries for sharing and review."""
47
+ clean_metrics = {
48
+ key: value if not isinstance(value, float) or math.isfinite(value) else None
49
+ for key, value in metrics.items()
50
+ }
51
+ payload = {
52
+ "project": "ml-quant-trading",
53
+ "workflow": "synthetic factor-to-backtest demo",
54
+ "config": config_path,
55
+ "costs_bps": float(costs_bps),
56
+ "metrics": clean_metrics,
57
+ "disclaimer": (
58
+ "Synthetic research output only; not investment advice or evidence of live performance."
59
+ ),
60
+ }
61
+ json_path = output_dir / "summary.json"
62
+ json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
63
+
64
+ rows = []
65
+ for key, value in clean_metrics.items():
66
+ if value is None:
67
+ display = "n/a"
68
+ elif isinstance(value, float):
69
+ display = f"{value:.6f}"
70
+ else:
71
+ display = str(value)
72
+ rows.append(f"| `{key}` | {display} |")
73
+
74
+ markdown = "\n".join(
75
+ [
76
+ "# mlquant demo result",
77
+ "",
78
+ f"- Config: `{config_path}`",
79
+ f"- Transaction cost assumption: `{costs_bps:g} bps`",
80
+ "- Workflow: synthetic data → 213 factors → MLP → Markowitz → backtest",
81
+ "",
82
+ "| Metric | Value |",
83
+ "|---|---:|",
84
+ *rows,
85
+ "",
86
+ "> Synthetic research output only. This is not investment advice or evidence of",
87
+ "> live or out-of-sample trading performance.",
88
+ "",
89
+ ]
90
+ )
91
+ markdown_path = output_dir / "summary.md"
92
+ markdown_path.write_text(markdown)
93
+ return markdown_path, json_path
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Click app
98
+ # ---------------------------------------------------------------------------
99
+ @click.group()
100
+ def cli() -> None:
101
+ """ml-quant-trading command-line interface."""
102
+
103
+
104
+ @cli.command("demo")
105
+ @click.option(
106
+ "--config",
107
+ "config_path",
108
+ default=None,
109
+ show_default="packaged small config",
110
+ type=click.Path(exists=True),
111
+ )
112
+ @click.pass_context
113
+ def cmd_demo(ctx: click.Context, config_path: str | None) -> None:
114
+ """Run the complete synthetic factor-to-backtest pipeline."""
115
+ if config_path is not None:
116
+ _run_demo(ctx, config_path)
117
+ return
118
+
119
+ default_config = files("mlquant.configs").joinpath("small.yaml")
120
+ with as_file(default_config) as default_path:
121
+ click.echo("Using packaged default config (override with --config PATH).\n")
122
+ _run_demo(ctx, str(default_path))
123
+
124
+
125
+ def _run_demo(ctx: click.Context, config_path: str) -> None:
126
+ """Run all demo stages with an explicit, existing config path."""
127
+ stages = (
128
+ ("1/5 Generate deterministic synthetic data", cmd_gen_data),
129
+ ("2/5 Compute the 213-factor tensor", cmd_features),
130
+ ("3/5 Train the baseline model", cmd_train),
131
+ ("4/5 Build portfolio weights", cmd_portfolio),
132
+ ("5/5 Run the cost-aware backtest", cmd_backtest),
133
+ )
134
+ click.echo("mlquant demo — data → factors → model → portfolio → backtest\n")
135
+ for label, command in stages:
136
+ click.echo(f"[{label}]")
137
+ ctx.invoke(command, config_path=config_path)
138
+ click.echo()
139
+ click.echo("Demo complete. Inspect the backtest summary above and artifacts/ for outputs.")
140
+
141
+
142
+ @cli.command("gen-data")
143
+ @click.option("--config", "config_path", required=True, type=click.Path(exists=True))
144
+ def cmd_gen_data(config_path: str) -> None:
145
+ cfg = load_config(config_path)
146
+ seed_everything(cfg.get("seed", 42))
147
+ syn = SyntheticConfig(**cfg.synthetic.to_dict()) if "synthetic" in cfg else SyntheticConfig()
148
+ panel = make_synthetic_panel(syn)
149
+ out = _artifacts_dir(cfg) / "panel.pt"
150
+ torch.save({
151
+ "dates": panel.dates, "stocks": panel.stocks,
152
+ "open": panel.open, "high": panel.high, "low": panel.low,
153
+ "close": panel.close, "volume": panel.volume, "vwap": panel.vwap,
154
+ "mask": panel.mask,
155
+ }, out)
156
+ click.echo(f"wrote {out} ({panel.n_dates} dates × {panel.n_stocks} stocks)")
157
+
158
+
159
+ @cli.command("features")
160
+ @click.option("--config", "config_path", required=True, type=click.Path(exists=True))
161
+ def cmd_features(config_path: str) -> None:
162
+ cfg = load_config(config_path)
163
+ seed_everything(cfg.get("seed", 42))
164
+ art = _artifacts_dir(cfg)
165
+ panel_blob = torch.load(art / "panel.pt", weights_only=False)
166
+ from ..data.panel import Panel
167
+ panel = Panel(
168
+ dates=panel_blob["dates"], stocks=panel_blob["stocks"],
169
+ open=panel_blob["open"], high=panel_blob["high"], low=panel_blob["low"],
170
+ close=panel_blob["close"], volume=panel_blob["volume"], vwap=panel_blob["vwap"],
171
+ mask=panel_blob["mask"],
172
+ )
173
+ bias_mask = limit_move_mask(panel, limit_pct=cfg.get("limit_pct", 0.098))
174
+ factors, mask, names = compute_legacy_set(panel)
175
+ mask = mask & bias_mask
176
+ torch.save({"factors": factors, "mask": mask, "names": names}, art / "features.pt")
177
+ click.echo(f"wrote features: {factors.shape} alphas={names}")
178
+
179
+
180
+ @cli.command("train")
181
+ @click.option("--config", "config_path", required=True, type=click.Path(exists=True))
182
+ def cmd_train(config_path: str) -> None:
183
+ cfg = load_config(config_path)
184
+ seed_everything(cfg.get("seed", 42))
185
+ art = _artifacts_dir(cfg)
186
+ panel_blob = torch.load(art / "panel.pt", weights_only=False)
187
+ feat_blob = torch.load(art / "features.pt", weights_only=False)
188
+
189
+ close = panel_blob["close"]
190
+ fwd_ret = torch.zeros_like(close)
191
+ fwd_ret[:-1] = close[1:] / close[:-1].clamp_min(1e-9) - 1.0
192
+
193
+ ds = FactorDataset(
194
+ factors=feat_blob["factors"],
195
+ mask=feat_blob["mask"],
196
+ forward_returns=fwd_ret,
197
+ )
198
+ n_features = ds.features.shape[1]
199
+ model = MLPRegressor(in_dim=n_features, hidden=cfg.get("hidden", 128))
200
+ trainer = Trainer(model, AdjMSELoss(gamma=cfg.get("loss_gamma", 0.1)),
201
+ TrainConfig(**(cfg.train.to_dict() if "train" in cfg else {})))
202
+ trainer.fit(ds)
203
+
204
+ # Write predictions for every (t, n) cell that's tradable.
205
+ with torch.no_grad():
206
+ T, N, F = feat_blob["factors"].shape
207
+ flat = feat_blob["factors"].reshape(-1, F)
208
+ pred = model(flat.to(trainer.cfg.device)).cpu().reshape(T, N)
209
+ pred = pred * feat_blob["mask"].float()
210
+ torch.save({"pred": pred}, art / "predictions.pt")
211
+ click.echo(f"wrote predictions: {pred.shape}")
212
+
213
+
214
+ @cli.command("portfolio")
215
+ @click.option("--config", "config_path", required=True, type=click.Path(exists=True))
216
+ def cmd_portfolio(config_path: str) -> None:
217
+ cfg = load_config(config_path)
218
+ seed_everything(cfg.get("seed", 42))
219
+ art = _artifacts_dir(cfg)
220
+ panel_blob = torch.load(art / "panel.pt", weights_only=False)
221
+ pred_blob = torch.load(art / "predictions.pt", weights_only=False)
222
+
223
+ close = panel_blob["close"].numpy()
224
+ mask = panel_blob["mask"].numpy()
225
+ pred = pred_blob["pred"].numpy()
226
+ rets = np.zeros_like(close)
227
+ rets[1:] = close[1:] / np.clip(close[:-1], 1e-9, None) - 1.0
228
+
229
+ look_back = cfg.get("cov_lookback", 60)
230
+ mk_cfg = MarkowitzConfig(**(cfg.portfolio.to_dict() if "portfolio" in cfg else {}))
231
+ opt = MarkowitzOptimizer(mk_cfg)
232
+
233
+ T, N = pred.shape
234
+ weights = np.zeros_like(pred)
235
+ for t in range(look_back, T - 1):
236
+ tradable = mask[t]
237
+ idx = np.where(tradable)[0]
238
+ if idx.size < 5:
239
+ continue
240
+ mu = pred[t, idx].astype(np.float64)
241
+ history = rets[t - look_back:t, idx]
242
+ try:
243
+ w = opt.solve(mu, history)
244
+ except Exception as exc: # pragma: no cover
245
+ click.echo(f"[warn] solver failed at t={t}: {exc}")
246
+ continue
247
+ weights[t, idx] = w
248
+ torch.save({"weights": torch.from_numpy(weights)}, art / "weights.pt")
249
+ click.echo(f"wrote weights: {weights.shape}")
250
+
251
+
252
+ @cli.command("backtest")
253
+ @click.option("--config", "config_path", required=True, type=click.Path(exists=True))
254
+ def cmd_backtest(config_path: str) -> None:
255
+ cfg = load_config(config_path)
256
+ art = _artifacts_dir(cfg)
257
+ panel_blob = torch.load(art / "panel.pt", weights_only=False)
258
+ weight_blob = torch.load(art / "weights.pt", weights_only=False)
259
+
260
+ close = panel_blob["close"].numpy()
261
+ rets = np.zeros_like(close)
262
+ rets[1:] = close[1:] / np.clip(close[:-1], 1e-9, None) - 1.0
263
+
264
+ weights = weight_blob["weights"].numpy()
265
+ costs_bps = cfg.get("costs_bps", 5.0)
266
+ res = run_backtest(weights, rets, costs_bps=costs_bps)
267
+ out = art / "backtest.pkl"
268
+ with open(out, "wb") as fh:
269
+ pickle.dump(res, fh)
270
+ markdown_path, json_path = _write_backtest_summary(
271
+ art,
272
+ res.metrics,
273
+ config_path=config_path,
274
+ costs_bps=costs_bps,
275
+ )
276
+
277
+ click.echo("\nBacktest summary")
278
+ click.echo("-" * 40)
279
+ for k, v in res.metrics.items():
280
+ if isinstance(v, float):
281
+ click.echo(f" {k:<12s} {v:>10.4f}")
282
+ else:
283
+ click.echo(f" {k:<12s} {v:>10}")
284
+ click.echo(f"\nShareable reports: {markdown_path} {json_path}")
285
+
286
+
287
+ def main() -> None:
288
+ cli()
289
+
290
+
291
+ if __name__ == "__main__": # pragma: no cover
292
+ main()
@@ -0,0 +1 @@
1
+ """Packaged configuration files for zero-setup CLI entry points."""
@@ -0,0 +1,41 @@
1
+ # Small synthetic config — runs end-to-end in <1 minute on a laptop CPU.
2
+ # This is the configuration that `make paper` and CI exercise.
3
+
4
+ seed: 42
5
+ artifacts_dir: artifacts/small
6
+ limit_pct: 0.098
7
+ costs_bps: 5.0
8
+ cov_lookback: 60
9
+ hidden: 64
10
+ loss_gamma: 0.1
11
+
12
+ synthetic:
13
+ n_stocks: 200
14
+ n_dates: 500
15
+ start_date: "2022-01-04"
16
+ annual_drift: 0.05
17
+ annual_vol: 0.30
18
+ market_beta: 0.6
19
+ halt_prob: 0.005
20
+ limit_pct: 0.10
21
+ seed: 42
22
+ device: cpu
23
+
24
+ train:
25
+ epochs: 8
26
+ batch_size: 1024
27
+ lr: 1.0e-3
28
+ weight_decay: 1.0e-5
29
+ grad_clip: 1.0
30
+ device: cpu
31
+ log_every: 50
32
+ save_dir: artifacts/small/checkpoints
33
+ val_split: 0.1
34
+
35
+ portfolio:
36
+ risk_aversion: 5.0
37
+ weight_cap: 0.05
38
+ long_only: true
39
+ cash_weight: 0.0
40
+ solver: SCS
41
+ shrinkage: true
@@ -0,0 +1,88 @@
1
+ """Panel-data loaders + synthetic generator.
2
+
3
+ The package treats market data as a pair of ``[date, stock]`` torch
4
+ tensors plus a boolean mask telling us which cells are tradable. All
5
+ downstream code (factors, training, portfolio) operates on this layout.
6
+
7
+ Five data sources are wired up out of the box:
8
+
9
+ * ``"synthetic"`` — :func:`make_synthetic_panel`, deterministic GBM panel
10
+ for tests, demos and CI.
11
+ * ``"csv"`` — :func:`load_ochlv_csv`, generic tab-separated Wind / Tushare
12
+ dump with optional ``LIMIT_UP`` / ``LIMIT_DOWN`` / ``LAST_CLOSE`` /
13
+ ``S_DQ_AMOUNT`` columns.
14
+ * ``"yfinance"`` — :func:`load_yfinance_panel`, public Yahoo Finance
15
+ market data.
16
+ * ``"baostock"`` — :func:`load_baostock_panel`, authenticated A-share
17
+ daily data.
18
+ * ``"akshare"`` — :func:`load_akshare_panel`, zero-auth A-share daily
19
+ data from public upstream interfaces.
20
+
21
+ All return the same :class:`Panel`, so consumers should never branch
22
+ on the source.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+ from typing import Any, Optional
28
+
29
+ from .panel import Panel
30
+ from .synthetic import SyntheticConfig, make_synthetic_panel
31
+ from .loaders import load_ochlv_csv
32
+ from .yfinance_loader import load_yfinance_panel
33
+ from .baostock_loader import load_baostock_panel
34
+ from .akshare_loader import load_akshare_panel
35
+
36
+ __all__ = [
37
+ "Panel",
38
+ "SyntheticConfig",
39
+ "make_synthetic_panel",
40
+ "load_ochlv_csv",
41
+ "load_yfinance_panel",
42
+ "load_baostock_panel",
43
+ "load_akshare_panel",
44
+ "make_panel",
45
+ ]
46
+
47
+
48
+ def make_panel(source: str = "synthetic", **kwargs: Any) -> Panel:
49
+ """Build a panel from synthetic, CSV, yfinance, Baostock, or AkShare data.
50
+
51
+ Parameters
52
+ ----------
53
+ source : str
54
+ One of ``"synthetic"``, ``"csv"``, ``"yfinance"``,
55
+ ``"baostock"``, or ``"akshare"``.
56
+ **kwargs
57
+ Source-specific keyword arguments.
58
+ """
59
+ if source == "synthetic":
60
+ cfg = SyntheticConfig(**kwargs) if kwargs else None
61
+ return make_synthetic_panel(cfg)
62
+ if source == "csv":
63
+ path: Optional[str | Path] = kwargs.pop("path", None)
64
+ if path is None:
65
+ raise TypeError("make_panel(source='csv', ...) requires a `path=` kwarg")
66
+ return load_ochlv_csv(path, **kwargs)
67
+ if source == "yfinance":
68
+ tickers = kwargs.pop("tickers", None)
69
+ start = kwargs.pop("start", None)
70
+ end = kwargs.pop("end", None)
71
+ if tickers is None or start is None or end is None:
72
+ raise TypeError("make_panel(source='yfinance', ...) requires `tickers`, `start`, and `end` kwargs")
73
+ return load_yfinance_panel(tickers, start, end, **kwargs)
74
+ if source == "baostock":
75
+ tickers = kwargs.pop("tickers", None)
76
+ start = kwargs.pop("start", None)
77
+ end = kwargs.pop("end", None)
78
+ if tickers is None or start is None or end is None:
79
+ raise TypeError("make_panel(source='baostock', ...) requires `tickers`, `start`, and `end` kwargs")
80
+ return load_baostock_panel(tickers, start, end, **kwargs)
81
+ if source == "akshare":
82
+ tickers = kwargs.pop("tickers", None)
83
+ start = kwargs.pop("start", None)
84
+ end = kwargs.pop("end", None)
85
+ if tickers is None or start is None or end is None:
86
+ raise TypeError("make_panel(source='akshare', ...) requires `tickers`, `start`, and `end` kwargs")
87
+ return load_akshare_panel(tickers, start, end, **kwargs)
88
+ raise ValueError(f"unknown panel source: {source!r}")