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,168 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Optional, Sequence, Union
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import torch
9
+
10
+ from .panel import Panel
11
+
12
+
13
+ def load_akshare_panel(
14
+ tickers: Sequence[str],
15
+ start: str,
16
+ end: str,
17
+ device: Union[str, torch.device] = "cpu",
18
+ adjust: str = "qfq",
19
+ proxy_vwap: bool = True,
20
+ retries: int = 3,
21
+ retry_sleep: float = 0.75,
22
+ ) -> Panel:
23
+ """Download A-share daily data from akshare and return a Panel.
24
+
25
+ Parameters
26
+ ----------
27
+ tickers : Sequence[str]
28
+ A-share ticker codes without exchange prefixes (for example, "600519").
29
+ start : str
30
+ Start date in "YYYY-MM-DD" format.
31
+ end : str
32
+ End date in "YYYY-MM-DD" format.
33
+ device : str or torch.device
34
+ Where to allocate the resulting tensors.
35
+ adjust : str
36
+ Price adjustment mode: "qfq", "hfq", or an empty string.
37
+ proxy_vwap : bool
38
+ If True, estimate VWAP as (Open + Close + High + Low) / 4.
39
+ retries : int
40
+ Number of attempts for each ticker before reporting a fetch failure.
41
+ retry_sleep : float
42
+ Base seconds to wait between retry attempts.
43
+ """
44
+ import akshare
45
+
46
+ if not tickers:
47
+ raise ValueError("Tickers list cannot be empty")
48
+
49
+ # 按输入顺序去重,避免下载和透视时产生重复列
50
+ unique_tickers = list(dict.fromkeys(tickers))
51
+
52
+ if not proxy_vwap:
53
+ raise ValueError(
54
+ "akshare cannot provide an adjustment-consistent raw VWAP; use proxy_vwap=True"
55
+ )
56
+
57
+ column_map = {
58
+ "日期": "date",
59
+ "开盘": "open",
60
+ "收盘": "close",
61
+ "最高": "high",
62
+ "最低": "low",
63
+ "成交量": "volume",
64
+ "成交额": "amount",
65
+ }
66
+ data_columns = ["date", "open", "high", "low", "close", "volume", "amount"]
67
+
68
+ all_data = []
69
+ for idx, ticker in enumerate(unique_tickers, start=1):
70
+ if idx == 1 or idx % 25 == 0 or idx == len(unique_tickers):
71
+ print(f"AkShare download progress: {idx}/{len(unique_tickers)} ({ticker})", flush=True)
72
+ ticker_df = None
73
+ last_error: Optional[Exception] = None
74
+ for attempt in range(1, max(1, retries) + 1):
75
+ try:
76
+ ticker_df = akshare.stock_zh_a_hist(
77
+ symbol=ticker,
78
+ period="daily",
79
+ start_date=start.replace("-", ""),
80
+ end_date=end.replace("-", ""),
81
+ adjust=adjust,
82
+ )
83
+ break
84
+ except Exception as e:
85
+ last_error = e
86
+ if attempt < max(1, retries):
87
+ time.sleep(retry_sleep * attempt)
88
+
89
+ try:
90
+ if ticker_df is None and last_error is not None:
91
+ raise last_error
92
+ if ticker_df is None or ticker_df.empty:
93
+ continue
94
+
95
+ ticker_df = ticker_df.rename(columns=column_map)
96
+ missing_columns = [column for column in data_columns if column not in ticker_df.columns]
97
+ if missing_columns:
98
+ raise ValueError(f"missing columns: {missing_columns}")
99
+
100
+ ticker_df = ticker_df[data_columns].copy()
101
+ ticker_df["code"] = ticker
102
+ all_data.append(ticker_df)
103
+ except Exception as e:
104
+ print(f"Warning: Failed to fetch {ticker}: {e}")
105
+ continue
106
+
107
+ if not all_data:
108
+ raise ValueError(
109
+ f"No data returned for tickers {unique_tickers} from {start} to {end}"
110
+ )
111
+
112
+ df = pd.concat(all_data, ignore_index=True)
113
+
114
+ # 将行情字段统一转为数值类型
115
+ num_cols = ["open", "high", "low", "close", "volume", "amount"]
116
+ for col in num_cols:
117
+ df[col] = pd.to_numeric(df[col], errors="coerce")
118
+
119
+ df["date"] = pd.to_datetime(df["date"])
120
+
121
+ # 将长表转换为日期乘股票的宽表
122
+ fields_wide = {}
123
+
124
+ def get_wide_df(col_name):
125
+ wide = df.pivot(index="date", columns="code", values=col_name)
126
+ # 为所有请求的股票补齐列,并保持原始顺序
127
+ for ticker in unique_tickers:
128
+ if ticker not in wide.columns:
129
+ wide[ticker] = np.nan
130
+ return wide[list(unique_tickers)]
131
+
132
+ open_df = get_wide_df("open")
133
+ high_df = get_wide_df("high")
134
+ low_df = get_wide_df("low")
135
+ close_df = get_wide_df("close")
136
+ volume_df = get_wide_df("volume")
137
+ amount_df = get_wide_df("amount")
138
+
139
+ fields_wide["open"] = open_df
140
+ fields_wide["high"] = high_df
141
+ fields_wide["low"] = low_df
142
+ fields_wide["close"] = close_df
143
+ fields_wide["volume"] = volume_df
144
+ fields_wide["amount"] = amount_df
145
+ fields_wide["vwap"] = (open_df + close_df + high_df + low_df) / 4.0
146
+
147
+ dates = open_df.index.to_numpy()
148
+ stocks = list(unique_tickers)
149
+
150
+ mask = (
151
+ ~open_df.isna()
152
+ & ~high_df.isna()
153
+ & ~low_df.isna()
154
+ & ~close_df.isna()
155
+ & ~volume_df.isna()
156
+ ).to_numpy()
157
+
158
+ tensors = {
159
+ name: torch.from_numpy(df_.fillna(0.0).to_numpy(dtype=np.float32).copy()).to(device)
160
+ for name, df_ in fields_wide.items()
161
+ }
162
+
163
+ return Panel.from_tensors(
164
+ dates=dates,
165
+ stocks=stocks,
166
+ fields=tensors,
167
+ mask=torch.from_numpy(mask.copy()).to(device),
168
+ )
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ import torch
6
+ from typing import Sequence, Optional, Union
7
+
8
+ from .panel import Panel
9
+
10
+ def load_baostock_panel(
11
+ tickers: Sequence[str],
12
+ start: str,
13
+ end: str,
14
+ device: Union[str, torch.device] = "cpu",
15
+ proxy_vwap: bool = True
16
+ ) -> Panel:
17
+ """Download data from baostock and return a Panel.
18
+
19
+ Parameters
20
+ ----------
21
+ tickers : Sequence[str]
22
+ List of tickers to download. For A-shares, use baostock format (e.g., 'sh.600000', 'sz.000001').
23
+ start : str
24
+ Start date (e.g. "2020-01-01").
25
+ end : str
26
+ End date (e.g. "2023-12-31").
27
+ device : str or torch.device
28
+ Where to allocate the resulting tensors.
29
+ proxy_vwap : bool
30
+ If True, estimates VWAP as (Open + Close + High + Low) / 4.
31
+ Baostock does not expose an adjustment-consistent raw VWAP, so False
32
+ is rejected rather than mixing adjusted OHLC with unadjusted turnover.
33
+ """
34
+ if not proxy_vwap:
35
+ raise ValueError(
36
+ "Baostock cannot provide an adjustment-consistent raw VWAP; "
37
+ "use proxy_vwap=True"
38
+ )
39
+
40
+ import baostock as bs
41
+
42
+ if not tickers:
43
+ raise ValueError("Tickers list cannot be empty")
44
+
45
+ # Uniqueify tickers to avoid issues with duplicates during download or pivoting
46
+ unique_tickers = list(dict.fromkeys(tickers))
47
+
48
+ lg = bs.login()
49
+ if lg.error_code != '0':
50
+ raise RuntimeError(f"Baostock login failed: {lg.error_msg}")
51
+
52
+ try:
53
+ all_data = []
54
+ for ticker in unique_tickers:
55
+ rs = bs.query_history_k_data_plus(
56
+ ticker,
57
+ "date,code,open,high,low,close,preclose,volume,amount,tradestatus",
58
+ start_date=start,
59
+ end_date=end,
60
+ frequency="d",
61
+ adjustflag="2" # 2 for forward adjust
62
+ )
63
+ if rs.error_code != '0':
64
+ print(f"Warning: Failed to fetch {ticker}: {rs.error_msg}")
65
+ continue
66
+
67
+ while (rs.error_code == '0') & rs.next():
68
+ all_data.append(rs.get_row_data())
69
+ finally:
70
+ bs.logout()
71
+
72
+ if not all_data:
73
+ raise ValueError(f"No data returned for tickers {unique_tickers} from {start} to {end}")
74
+
75
+ df = pd.DataFrame(all_data, columns=["date", "code", "open", "high", "low", "close", "preclose", "volume", "amount", "tradestatus"])
76
+
77
+ # Convert numerical columns
78
+ num_cols = ["open", "high", "low", "close", "preclose", "volume", "amount"]
79
+ for col in num_cols:
80
+ df[col] = pd.to_numeric(df[col], errors='coerce')
81
+
82
+ df["date"] = pd.to_datetime(df["date"])
83
+
84
+ # Extract wide DataFrames
85
+ fields_wide = {}
86
+
87
+ def get_wide_df(col_name):
88
+ wide = df.pivot(index="date", columns="code", values=col_name)
89
+ # Ensure all requested tickers are present
90
+ for t in unique_tickers:
91
+ if t not in wide.columns:
92
+ wide[t] = np.nan
93
+ return wide[list(unique_tickers)]
94
+
95
+ open_df = get_wide_df("open")
96
+ high_df = get_wide_df("high")
97
+ low_df = get_wide_df("low")
98
+ close_df = get_wide_df("close")
99
+ volume_df = get_wide_df("volume")
100
+ amount_df = get_wide_df("amount")
101
+ preclose_df = get_wide_df("preclose")
102
+ status_df = get_wide_df("tradestatus") # 1 means tradable, 0 means halt
103
+
104
+ fields_wide["open"] = open_df
105
+ fields_wide["high"] = high_df
106
+ fields_wide["low"] = low_df
107
+ fields_wide["close"] = close_df
108
+ fields_wide["volume"] = volume_df
109
+ fields_wide["amount"] = amount_df
110
+ fields_wide["last_close"] = preclose_df
111
+
112
+ # OHLC are forward-adjusted (adjustflag="2"), while amount and volume are
113
+ # not. The typical-price proxy keeps every price field on the same basis.
114
+ fields_wide["vwap"] = (open_df + close_df + high_df + low_df) / 4.0
115
+
116
+ dates = open_df.index.to_numpy()
117
+ stocks = list(unique_tickers)
118
+
119
+ # status_df is string '1' or '0' natively before we pivoted, so convert it properly
120
+ # If a stock was missing on a day, it's NaN. Let's make tradable mask: status == '1'
121
+ status_df = status_df.fillna('0')
122
+ is_tradable = (status_df == '1') | (status_df == 1)
123
+
124
+ # Mask where close is not NaN and tradable
125
+ mask = (~open_df.isna() & ~high_df.isna() & ~low_df.isna() & ~close_df.isna() & ~volume_df.isna() & is_tradable).to_numpy()
126
+
127
+ tensors = {
128
+ name: torch.from_numpy(df_.fillna(0.0).to_numpy(dtype=np.float32).copy()).to(device)
129
+ for name, df_ in fields_wide.items()
130
+ }
131
+
132
+ return Panel.from_tensors(
133
+ dates=dates,
134
+ stocks=stocks,
135
+ fields=tensors,
136
+ mask=torch.from_numpy(mask.copy()).to(device),
137
+ )
@@ -0,0 +1,125 @@
1
+ """Loaders for real OCHLV CSV / parquet files.
2
+
3
+ The paper uses Wind exports; this module is intentionally generic so
4
+ you can plug in any tab-separated OCHLV dump that has at least these
5
+ columns:
6
+
7
+ TRADE_DT S_INFO_WINDCODE open close high low vwap volume
8
+
9
+ Any extra columns — ``S_DQ_AMOUNT`` (turnover), ``LIMIT_UP``,
10
+ ``LIMIT_DOWN``, ``LAST_CLOSE``, ``FHZS_FLAG`` (ex-dividend / split
11
+ flag) — are picked up automatically when present and surfaced on the
12
+ :class:`Panel` so the bias-correction and market-breadth pipelines can
13
+ use the *real* exchange figures rather than ±10 % proxies.
14
+
15
+ The loader is the single entry point for any external file path; the
16
+ synthetic generator in :mod:`mlquant.data.synthetic` produces the same
17
+ shape, so downstream code never branches on data source.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+ from typing import Mapping, Optional, Sequence
23
+
24
+ import numpy as np
25
+ import pandas as pd
26
+ import torch
27
+
28
+ from .panel import Panel
29
+
30
+
31
+ # Each entry maps the canonical Panel field name to a tuple of column
32
+ # aliases we accept on disk. The first match wins, so the most specific
33
+ # Wind name should come first.
34
+ _REQUIRED_ALIASES: Mapping[str, tuple[str, ...]] = {
35
+ "open": ("open", "S_FWDS_ADJOPEN", "OPEN"),
36
+ "high": ("high", "S_FWDS_ADJHIGH", "HIGH"),
37
+ "low": ("low", "S_FWDS_ADJLOW", "LOW"),
38
+ "close": ("close", "S_FWDS_ADJCLOSE", "CLOSE"),
39
+ "volume": ("volume", "S_DQ_VOLUME"),
40
+ "vwap": ("vwap", "S_DQ_AVGPRICE"),
41
+ }
42
+
43
+ _OPTIONAL_ALIASES: Mapping[str, tuple[str, ...]] = {
44
+ "amount": ("amount", "S_DQ_AMOUNT", "AMOUNT"),
45
+ "limit_up": ("limit_up", "LIMIT_UP", "S_DQ_LIMIT"),
46
+ "limit_down": ("limit_down", "LIMIT_DOWN", "S_DQ_STOPPING"),
47
+ "last_close": ("last_close", "LAST_CLOSE", "S_DQ_PRECLOSE"),
48
+ }
49
+
50
+
51
+ def _resolve(df: pd.DataFrame, name: str, aliases: tuple[str, ...]) -> Optional[pd.Series]:
52
+ for alias in aliases:
53
+ if alias in df.columns:
54
+ return df[alias].astype(np.float32)
55
+ return None
56
+
57
+
58
+ def load_ochlv_csv(
59
+ path: str | Path,
60
+ *,
61
+ sep: str = "\t",
62
+ universe: Optional[Sequence[str]] = None,
63
+ device: str | torch.device = "cpu",
64
+ ) -> Panel:
65
+ """Load a long-format OCHLV file into a :class:`Panel`.
66
+
67
+ Parameters
68
+ ----------
69
+ path : str | Path
70
+ Path to a tab-separated (or ``sep`` separated) file with at least
71
+ ``TRADE_DT`` and ``S_INFO_WINDCODE`` columns plus OCHLV fields.
72
+ universe : sequence of str, optional
73
+ If given, restrict to this set of tickers before pivoting.
74
+ device : torch device or str
75
+ Where to allocate the resulting tensors.
76
+ """
77
+ df = pd.read_csv(path, sep=sep, low_memory=False)
78
+ if "TRADE_DT" not in df.columns or "S_INFO_WINDCODE" not in df.columns:
79
+ raise KeyError("file must contain TRADE_DT and S_INFO_WINDCODE columns")
80
+
81
+ df["TRADE_DT"] = pd.to_datetime(df["TRADE_DT"].astype(str))
82
+ df["S_INFO_WINDCODE"] = df["S_INFO_WINDCODE"].astype(str).str[:6]
83
+ if universe is not None:
84
+ df = df[df["S_INFO_WINDCODE"].isin(set(universe))]
85
+ df = df.drop_duplicates(subset=["TRADE_DT", "S_INFO_WINDCODE"]).sort_values(
86
+ ["TRADE_DT", "S_INFO_WINDCODE"]
87
+ )
88
+
89
+ # Build a wide [date, stock] frame for every field we recognise.
90
+ fields_long: dict[str, pd.Series] = {}
91
+ for name, aliases in {**_REQUIRED_ALIASES, **_OPTIONAL_ALIASES}.items():
92
+ ser = _resolve(df, name, aliases)
93
+ if ser is not None:
94
+ fields_long[name] = ser
95
+ elif name in _REQUIRED_ALIASES:
96
+ raise KeyError(
97
+ f"None of {aliases} present in dataframe columns {list(df.columns)}"
98
+ )
99
+
100
+ fields_wide: dict[str, pd.DataFrame] = {}
101
+ for fld, ser in fields_long.items():
102
+ wide = (
103
+ df.assign(**{fld: ser})
104
+ .pivot(index="TRADE_DT", columns="S_INFO_WINDCODE", values=fld)
105
+ .sort_index()
106
+ )
107
+ fields_wide[fld] = wide
108
+
109
+ base = fields_wide["close"]
110
+ dates = base.index.to_numpy()
111
+ stocks = base.columns.to_numpy()
112
+
113
+ mask = (~base.isna()).to_numpy()
114
+ tensors = {
115
+ name: torch.from_numpy(df_.fillna(0.0).to_numpy(dtype=np.float32)).to(device)
116
+ for name, df_ in fields_wide.items()
117
+ }
118
+
119
+ panel = Panel.from_tensors(
120
+ dates=dates,
121
+ stocks=stocks,
122
+ fields=tensors,
123
+ mask=torch.from_numpy(mask).to(device),
124
+ )
125
+ return panel
mlquant/data/panel.py ADDED
@@ -0,0 +1,206 @@
1
+ """The :class:`Panel` — a thin, mask-aware container for OCHLV panels.
2
+
3
+ Design rules
4
+ ------------
5
+ * Every numeric field has shape ``[T, N]`` (date × stock) and dtype
6
+ ``float32``.
7
+ * ``mask[t, i] == True`` iff stock *i* was tradable on date *t*. Limit-up,
8
+ limit-down, halted, and pre-IPO cells are masked out — this is the
9
+ "bias correction" plumbing the paper relies on.
10
+ * Panels are *immutable*: factor pipelines return new tensors rather
11
+ than mutating in place. This keeps reasoning straightforward and is
12
+ a hard requirement for autograd-backed factors.
13
+
14
+ Optional A-share microstructure fields
15
+ --------------------------------------
16
+ The legacy ``cuda_features.py`` operates on Wind dumps that carry a few
17
+ extra columns beyond OCHLV: traded ``amount`` (turnover in CNY), the
18
+ exchange-published ``limit_up`` / ``limit_down`` prices and the previous
19
+ session's ``last_close``. They are *optional* on this dataclass — the
20
+ synthetic generator emits them, real-data loaders fill them when the
21
+ file has the columns, and downstream code falls back to derived
22
+ quantities when they are absent (e.g. ±10 % return proxy for limit
23
+ moves).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field, replace
28
+ from typing import Iterable, Mapping, Optional, Sequence
29
+
30
+ import numpy as np
31
+ import torch
32
+
33
+
34
+ _REQUIRED_FIELDS = ("open", "high", "low", "close", "volume", "vwap")
35
+ _OPTIONAL_FIELDS = ("amount", "limit_up", "limit_down", "last_close")
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Panel:
40
+ """OCHLV panel + tradability mask, all on the same device."""
41
+
42
+ dates: np.ndarray # shape [T], dtype object/datetime64
43
+ stocks: np.ndarray # shape [N], dtype object (ticker str)
44
+
45
+ open: torch.Tensor # [T, N]
46
+ high: torch.Tensor
47
+ low: torch.Tensor
48
+ close: torch.Tensor
49
+ volume: torch.Tensor
50
+ vwap: torch.Tensor
51
+ mask: torch.Tensor # [T, N] bool
52
+
53
+ # ------- optional A-share microstructure fields -----------------------
54
+ # All [T, N] float32 when present; ``None`` means "data source did not
55
+ # supply this column — fall back to derived defaults".
56
+ amount: Optional[torch.Tensor] = field(default=None)
57
+ limit_up: Optional[torch.Tensor] = field(default=None)
58
+ limit_down: Optional[torch.Tensor] = field(default=None)
59
+ last_close: Optional[torch.Tensor] = field(default=None)
60
+
61
+ # ------------------------------------------------------------------
62
+ # Construction helpers
63
+ # ------------------------------------------------------------------
64
+ @classmethod
65
+ def from_tensors(
66
+ cls,
67
+ dates: Sequence,
68
+ stocks: Sequence[str],
69
+ fields: Mapping[str, torch.Tensor],
70
+ mask: torch.Tensor,
71
+ ) -> "Panel":
72
+ missing = set(_REQUIRED_FIELDS) - set(fields)
73
+ if missing:
74
+ raise KeyError(f"Panel.from_tensors missing fields: {sorted(missing)}")
75
+ T, N = mask.shape
76
+ promoted = {}
77
+ for k, v in fields.items():
78
+ if k not in _REQUIRED_FIELDS and k not in _OPTIONAL_FIELDS:
79
+ # Allow forward-compat fields to round-trip silently.
80
+ continue
81
+ if tuple(v.shape) != (T, N):
82
+ raise ValueError(f"field {k!r} has shape {tuple(v.shape)}, expected {(T, N)}")
83
+ promoted[k] = v.float() if v.dtype != torch.float32 else v
84
+ kwargs = {k: promoted[k] for k in _REQUIRED_FIELDS}
85
+ for k in _OPTIONAL_FIELDS:
86
+ if k in promoted:
87
+ kwargs[k] = promoted[k]
88
+ return cls(
89
+ dates=np.asarray(dates),
90
+ stocks=np.asarray(stocks),
91
+ mask=mask.bool(),
92
+ **kwargs,
93
+ )
94
+
95
+ # ------------------------------------------------------------------
96
+ # Convenience
97
+ # ------------------------------------------------------------------
98
+ @property
99
+ def n_dates(self) -> int:
100
+ return int(self.mask.shape[0])
101
+
102
+ @property
103
+ def n_stocks(self) -> int:
104
+ return int(self.mask.shape[1])
105
+
106
+ @property
107
+ def device(self) -> torch.device:
108
+ return self.close.device
109
+
110
+ @property
111
+ def returns(self) -> torch.Tensor:
112
+ """Daily simple returns, masked at non-tradable cells.
113
+
114
+ When :attr:`last_close` is present we use it directly so the day-0
115
+ return is meaningful and adjusted-vs-unadjusted close mismatches
116
+ do not inject NaNs. Otherwise we fall back to the previous-row
117
+ close.
118
+ """
119
+ if self.last_close is not None:
120
+ prev = self.last_close.clamp_min(1e-9)
121
+ ret = self.close / prev - 1.0
122
+ valid = self.mask & (self.last_close > 0)
123
+ return ret.masked_fill(~valid, 0.0)
124
+ prev = torch.roll(self.close, shifts=1, dims=0)
125
+ prev[0] = self.close[0] # avoid div-by-zero on day 0
126
+ ret = self.close / prev - 1.0
127
+ ret[0] = 0.0
128
+ valid = self.mask & torch.roll(self.mask, shifts=1, dims=0)
129
+ valid[0] = False
130
+ return ret.masked_fill(~valid, 0.0)
131
+
132
+ @property
133
+ def has_real_limits(self) -> bool:
134
+ """True iff exchange-published limit-up / limit-down prices are present."""
135
+ return self.limit_up is not None and self.limit_down is not None
136
+
137
+ def field(self, name: str) -> Optional[torch.Tensor]:
138
+ """Generic field accessor — returns ``None`` if the field is absent."""
139
+ return getattr(self, name, None)
140
+
141
+ def to(self, device: torch.device | str) -> "Panel":
142
+ def _mv(t: Optional[torch.Tensor]) -> Optional[torch.Tensor]:
143
+ return None if t is None else t.to(device)
144
+ return replace(
145
+ self,
146
+ open=self.open.to(device),
147
+ high=self.high.to(device),
148
+ low=self.low.to(device),
149
+ close=self.close.to(device),
150
+ volume=self.volume.to(device),
151
+ vwap=self.vwap.to(device),
152
+ mask=self.mask.to(device),
153
+ amount=_mv(self.amount),
154
+ limit_up=_mv(self.limit_up),
155
+ limit_down=_mv(self.limit_down),
156
+ last_close=_mv(self.last_close),
157
+ )
158
+
159
+ def slice_dates(self, start: int, end: int) -> "Panel":
160
+ def _sl(t: Optional[torch.Tensor]) -> Optional[torch.Tensor]:
161
+ return None if t is None else t[start:end]
162
+ return replace(
163
+ self,
164
+ dates=self.dates[start:end],
165
+ open=self.open[start:end],
166
+ high=self.high[start:end],
167
+ low=self.low[start:end],
168
+ close=self.close[start:end],
169
+ volume=self.volume[start:end],
170
+ vwap=self.vwap[start:end],
171
+ mask=self.mask[start:end],
172
+ amount=_sl(self.amount),
173
+ limit_up=_sl(self.limit_up),
174
+ limit_down=_sl(self.limit_down),
175
+ last_close=_sl(self.last_close),
176
+ )
177
+
178
+ # ------------------------------------------------------------------
179
+ # Sanity checks (used by tests + CLI ``mlquant check``)
180
+ # ------------------------------------------------------------------
181
+ def assert_consistent(self) -> None:
182
+ T, N = self.mask.shape
183
+ assert self.dates.shape == (T,), f"dates {self.dates.shape}"
184
+ assert self.stocks.shape == (N,), f"stocks {self.stocks.shape}"
185
+ for name in _REQUIRED_FIELDS:
186
+ f_ = getattr(self, name)
187
+ assert f_.shape == (T, N), f"{name} {f_.shape}"
188
+ for name in _OPTIONAL_FIELDS:
189
+ f_ = getattr(self, name)
190
+ if f_ is not None:
191
+ assert f_.shape == (T, N), f"{name} {f_.shape}"
192
+ # H >= O,C,L and L <= O,C,H on tradable cells
193
+ m = self.mask
194
+ ohlc = torch.stack([self.open, self.high, self.low, self.close], dim=0)
195
+ assert ((self.high >= ohlc[[0, 2, 3]].max(dim=0).values) | ~m).all(), "high < o/l/c"
196
+ assert ((self.low <= ohlc[[0, 1, 3]].min(dim=0).values) | ~m).all(), "low > o/h/c"
197
+
198
+
199
+ def fields_iter(panel: Panel) -> Iterable[tuple[str, torch.Tensor]]:
200
+ """Yield ``(name, tensor)`` for every numeric panel field that is set."""
201
+ for name in _REQUIRED_FIELDS:
202
+ yield name, getattr(panel, name)
203
+ for name in _OPTIONAL_FIELDS:
204
+ v = getattr(panel, name)
205
+ if v is not None:
206
+ yield name, v