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/__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())
|
mlquant/cli/__init__.py
ADDED
|
@@ -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
|
mlquant/data/__init__.py
ADDED
|
@@ -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}")
|