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
|
@@ -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
|
+
)
|
mlquant/data/loaders.py
ADDED
|
@@ -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
|