alpha101v 0.1.2__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.
- alpha101v/__init__.py +128 -0
- alpha101v/adapters/__init__.py +18 -0
- alpha101v/adapters/adjust.py +224 -0
- alpha101v/adapters/industry.py +130 -0
- alpha101v/adapters/mongo.py +234 -0
- alpha101v/elemwise.py +193 -0
- alpha101v/extreme.py +105 -0
- alpha101v/factors.py +1348 -0
- alpha101v/neutralize.py +204 -0
- alpha101v/operators.py +502 -0
- alpha101v/panel.py +219 -0
- alpha101v/registry.py +158 -0
- alpha101v-0.1.2.dist-info/METADATA +271 -0
- alpha101v-0.1.2.dist-info/RECORD +17 -0
- alpha101v-0.1.2.dist-info/WHEEL +5 -0
- alpha101v-0.1.2.dist-info/licenses/LICENSE +21 -0
- alpha101v-0.1.2.dist-info/top_level.txt +1 -0
alpha101v/__init__.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""alpha101v — 一份「可验证的 Alpha101」实现(M0/M1:算子层)。
|
|
2
|
+
|
|
3
|
+
设计依据:``docs/Alpha101_修复发布决策与验证协议_v0.1.md``(内容版本 v0.2)。
|
|
4
|
+
口径唯一权威:``docs/operators.md``。
|
|
5
|
+
|
|
6
|
+
公开算子面恰好 20 个(16 个时序/截面算子 + ``rolling_rank`` 构件 + 3 个中性化算子),
|
|
7
|
+
见 :data:`OPERATORS`;另有**不属于算子面**的逐元素函数层见 :data:`ELEMWISE`
|
|
8
|
+
(论文把它们列为 Functions,见 ``docs/operators.md`` §3.9)。
|
|
9
|
+
|
|
10
|
+
因子层(M3)交付 **全部 101 个因子**(:data:`ALL_FACTOR_NAMES`),导入本包即自动登记进
|
|
11
|
+
``registry.REGISTRY``;状态只有 ``partial``(结构对照等价)与 ``ambiguous``
|
|
12
|
+
(规范未定,见 ``factors.AMBIGUOUS_FACTORS``)——``verified`` 需六条门槛,见设计文档 §6.8。因子层(``factors.py``)已含全部 101 个因子(论文逐字转写)。
|
|
13
|
+
本包**不含任何来自 GPL 实现的代码**(clean-room,见 ``docs/paper_formulas.md`` 与
|
|
14
|
+
``tests/test_provenance.py``)。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from . import elemwise, factors, neutralize, operators, panel, registry
|
|
20
|
+
from .elemwise import ELEMWISE, abs_, log, maximum, minimum, sign, signedpower
|
|
21
|
+
from .factors import ALL_FACTOR_NAMES, PILOT_NAMES, alpha4, alpha41, alpha5, alpha56, alpha101
|
|
22
|
+
from .neutralize import ind_neutralize, neu_industry, neutral
|
|
23
|
+
from .operators import (
|
|
24
|
+
correlation,
|
|
25
|
+
covariance,
|
|
26
|
+
decay_linear,
|
|
27
|
+
delta,
|
|
28
|
+
delay,
|
|
29
|
+
product,
|
|
30
|
+
rank,
|
|
31
|
+
rolling_rank,
|
|
32
|
+
scale,
|
|
33
|
+
sma,
|
|
34
|
+
stddev,
|
|
35
|
+
ts_argmax,
|
|
36
|
+
ts_argmin,
|
|
37
|
+
ts_max,
|
|
38
|
+
ts_min,
|
|
39
|
+
ts_rank,
|
|
40
|
+
ts_sum,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def _resolve_version() -> str:
|
|
44
|
+
"""版本号**单一来源**:优先读安装元数据(`pyproject.toml` 里的 version),
|
|
45
|
+
源码树直接 import(未安装)时回退。硬编码版本号曾与 pyproject 漂移过
|
|
46
|
+
(`__init__` 写 0.1.0、dist 是 0.1.1),因此有 tests/test_version.py 专门守这条。
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
from importlib.metadata import version as _v
|
|
50
|
+
|
|
51
|
+
return _v("alpha101v")
|
|
52
|
+
except Exception: # noqa: BLE001 - 未安装/元数据缺失时回退
|
|
53
|
+
return "0.1.2"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__version__ = _resolve_version()
|
|
57
|
+
|
|
58
|
+
#: 20 个算子的名字(设计文档 §7.3 的 17 个 API + §8.1 的 rolling_rank + §8.3 的两个中性化变体)。
|
|
59
|
+
#: ``tests/test_operators.py::test_api_surface_is_exactly_twenty`` 会守住这个清单。
|
|
60
|
+
OPERATORS: tuple[str, ...] = (
|
|
61
|
+
# A 组 / 时序与截面(§7.3)
|
|
62
|
+
"ts_sum",
|
|
63
|
+
"sma",
|
|
64
|
+
"stddev",
|
|
65
|
+
"correlation",
|
|
66
|
+
"covariance",
|
|
67
|
+
"ts_rank",
|
|
68
|
+
"product",
|
|
69
|
+
"ts_min",
|
|
70
|
+
"ts_max",
|
|
71
|
+
"delta",
|
|
72
|
+
"delay",
|
|
73
|
+
"rank",
|
|
74
|
+
"scale",
|
|
75
|
+
"ts_argmax",
|
|
76
|
+
"ts_argmin",
|
|
77
|
+
"decay_linear",
|
|
78
|
+
# §8.1 明确要求对拍的构件
|
|
79
|
+
"rolling_rank",
|
|
80
|
+
# §8.3 中性化组
|
|
81
|
+
"ind_neutralize",
|
|
82
|
+
"neu_industry",
|
|
83
|
+
"neutral",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"ALL_FACTOR_NAMES",
|
|
88
|
+
"ELEMWISE",
|
|
89
|
+
"OPERATORS",
|
|
90
|
+
"PILOT_NAMES",
|
|
91
|
+
"__version__",
|
|
92
|
+
"alpha4",
|
|
93
|
+
"alpha41",
|
|
94
|
+
"alpha56",
|
|
95
|
+
"alpha101",
|
|
96
|
+
"factors",
|
|
97
|
+
"panel",
|
|
98
|
+
"registry",
|
|
99
|
+
"abs_",
|
|
100
|
+
"elemwise",
|
|
101
|
+
"log",
|
|
102
|
+
"maximum",
|
|
103
|
+
"minimum",
|
|
104
|
+
"sign",
|
|
105
|
+
"signedpower",
|
|
106
|
+
"correlation",
|
|
107
|
+
"covariance",
|
|
108
|
+
"decay_linear",
|
|
109
|
+
"delta",
|
|
110
|
+
"delay",
|
|
111
|
+
"ind_neutralize",
|
|
112
|
+
"neu_industry",
|
|
113
|
+
"neutral",
|
|
114
|
+
"neutralize",
|
|
115
|
+
"operators",
|
|
116
|
+
"product",
|
|
117
|
+
"rank",
|
|
118
|
+
"rolling_rank",
|
|
119
|
+
"scale",
|
|
120
|
+
"sma",
|
|
121
|
+
"stddev",
|
|
122
|
+
"ts_argmax",
|
|
123
|
+
"ts_argmin",
|
|
124
|
+
"ts_max",
|
|
125
|
+
"ts_min",
|
|
126
|
+
"ts_rank",
|
|
127
|
+
"ts_sum",
|
|
128
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""alpha101v.adapters — 数据适配层(可选,**不属于运行时依赖**)。
|
|
2
|
+
|
|
3
|
+
设计文档 §7.1 硬约束:本包**运行时**只依赖 ``numpy / pandas / scipy``。
|
|
4
|
+
适配器因此遵循两条纪律:
|
|
5
|
+
|
|
6
|
+
1. **延迟导入**:第三方驱动(如 ``pymongo``)只在函数体内 import,且本模块**不被
|
|
7
|
+
``alpha101v/__init__.py`` 导入** ⇒ ``import alpha101v`` 永远不会拉进它们
|
|
8
|
+
(由 ``tests/test_provenance.py::test_runtime_import_is_minimal`` 机械守门);
|
|
9
|
+
2. **不做隐式插值**:缺数据就是 NaN,需要前向填充必须由调用方显式开关并写清理由。
|
|
10
|
+
|
|
11
|
+
子模块
|
|
12
|
+
------
|
|
13
|
+
* :mod:`alpha101v.adapters.mongo` —— 本地 MongoDB(``quantaxis``)→ §7.4 面板契约;
|
|
14
|
+
* :mod:`alpha101v.adapters.industry` —— 申万 as-of 成员 → **PIT 行业标签面板**
|
|
15
|
+
(DataFrame 形态,正是 :func:`alpha101v.neutralize.ind_neutralize` 支持的逐日口径)。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""价格复权(除权除息)——**PIT 安全**版本。
|
|
2
|
+
|
|
3
|
+
为什么必须有这一层
|
|
4
|
+
------------------
|
|
5
|
+
``stock_day`` 是**原始价**:除权除息当天价格会「跳空」(送转股/派现),
|
|
6
|
+
这不是亏损却会被算成收益率暴跌。实测样例(000153,2024-06-07,每 10 股送 4 股 + 派 1.5 元)::
|
|
7
|
+
|
|
8
|
+
前收 7.74 → 当日收 5.61 ⇒ 原始收益率 −27.5%(假信号)
|
|
9
|
+
|
|
10
|
+
本模块按「除权除息参考价」口径复权,把该跳空抹平(同一例复权收益率 +3.5%)。
|
|
11
|
+
|
|
12
|
+
口径(实证确定,2026-10-09)
|
|
13
|
+
--------------------------
|
|
14
|
+
``stock_xdxr`` 的 ``fenhong`` / ``songzhuangu`` / ``peigu`` 是 **每 10 股**口径
|
|
15
|
+
(用 6 个真实案例反推参考价,与当日实际收盘的相对误差 0.6%~3.9%;按「每股」解释则偏 44%~85%)::
|
|
16
|
+
|
|
17
|
+
R = (C_prev − fenhong/10 + (peigu/10)·peigujia) / (1 + songzhuangu/10 + peigu/10)
|
|
18
|
+
|
|
19
|
+
累计因子 ``F_t = Π_{e ≤ t} (C_prev,e / R_e)``(≥1,随时间增长)。
|
|
20
|
+
|
|
21
|
+
**两种模式与它们的关键差别**
|
|
22
|
+
|
|
23
|
+
* ``hfq``(**本库默认**):把 ``F`` 归一化到面板**第一行 = 1**(早期价不动、后期价上调)。
|
|
24
|
+
因子只依赖**过去**的除权事件 ⇒ **因果/PIT 安全**,能通过时间截断一致性检验;
|
|
25
|
+
* ``qfq``:把 ``F`` 归一化到**最后一行 = 1**(最新价与原始价一致,便于人工核对)。
|
|
26
|
+
它让**全部历史价格**都依赖**未来**的除权事件 ⇒ 全样本 qfq 存在前视
|
|
27
|
+
(``docs/verification.md`` §1.3 的已知限制之一),**仅供展示/回测取价,不用于因子计算**。
|
|
28
|
+
|
|
29
|
+
两者只差每只股票一个**常数**缩放,因此对 `rank` 型因子无影响,但对跨股**水平型**比较
|
|
30
|
+
(如 `alpha41 = sqrt(high·low) − vwap`)会有差异——这正是必须显式选一种口径的原因。
|
|
31
|
+
|
|
32
|
+
不做什么
|
|
33
|
+
--------
|
|
34
|
+
* 不改 ``volume`` / ``amount``(成交量额是交易数量,不是价格);
|
|
35
|
+
* 不改 ``cap``(市值口径由调用方声明,见歧义 A19);
|
|
36
|
+
* 不做隐式填充(缺前收的事件跳过并计数,由调用方决定)。
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from collections.abc import Iterable, Sequence
|
|
42
|
+
|
|
43
|
+
import numpy as np
|
|
44
|
+
import pandas as pd
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"ADJUST_MODES",
|
|
48
|
+
"PRICE_FIELDS",
|
|
49
|
+
"adjust_price_panels",
|
|
50
|
+
"cumulative_factors",
|
|
51
|
+
"load_xdxr_events",
|
|
52
|
+
"reference_price",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
#: 允许的复权模式:``none`` 不复权;``hfq`` 后复权(因果,默认);``qfq`` 前复权(非因果,仅供展示)。
|
|
56
|
+
ADJUST_MODES: tuple[str, ...] = ("none", "hfq", "qfq")
|
|
57
|
+
|
|
58
|
+
#: 需要按同一因子缩放的价格类字段(``vwap`` 是价格,必须一起缩放,否则 `close − vwap` 失去意义)。
|
|
59
|
+
PRICE_FIELDS: tuple[str, ...] = ("close", "Open", "high", "low", "vwap")
|
|
60
|
+
|
|
61
|
+
#: 影响价格连续性的 xdxr 类别(1 = 除权除息);其余类别(股本变化/送配股上市…)不影响价格。
|
|
62
|
+
XDXR_PRICE_CATEGORY = 1
|
|
63
|
+
|
|
64
|
+
_XDXR_FIELDS = ("fenhong", "songzhuangu", "peigu", "peigujia")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def reference_price(
|
|
68
|
+
prev_close: float, fenhong: float, songzhuangu: float, peigu: float, peigujia: float
|
|
69
|
+
) -> float:
|
|
70
|
+
"""除权除息参考价 ``R``(字段按**每 10 股**解释)。
|
|
71
|
+
|
|
72
|
+
``R = (C_prev − fenhong/10 + (peigu/10)·peigujia) / (1 + songzhuangu/10 + peigu/10)``
|
|
73
|
+
|
|
74
|
+
返回 ``nan`` 表示该事件无法定价(前收非正、R 非正),调用方应跳过。
|
|
75
|
+
"""
|
|
76
|
+
if not np.isfinite(prev_close) or prev_close <= 0:
|
|
77
|
+
return float("nan")
|
|
78
|
+
dividend = (0.0 if fenhong is None else float(fenhong)) / 10.0
|
|
79
|
+
bonus = (0.0 if songzhuangu is None else float(songzhuangu)) / 10.0
|
|
80
|
+
rights = (0.0 if peigu is None else float(peigu)) / 10.0
|
|
81
|
+
rights_price = 0.0 if peigujia is None else float(peigujia)
|
|
82
|
+
denominator = 1.0 + bonus + rights
|
|
83
|
+
if denominator <= 0:
|
|
84
|
+
return float("nan")
|
|
85
|
+
ref = (prev_close - dividend + rights * rights_price) / denominator
|
|
86
|
+
return float(ref) if ref > 0 else float("nan")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_xdxr_events(
|
|
90
|
+
codes: Iterable[str],
|
|
91
|
+
start: str,
|
|
92
|
+
end: str,
|
|
93
|
+
*,
|
|
94
|
+
uri: str = "mongodb://127.0.0.1:27017",
|
|
95
|
+
database: str = "quantaxis",
|
|
96
|
+
collection: str = "stock_xdxr",
|
|
97
|
+
category: int = XDXR_PRICE_CATEGORY,
|
|
98
|
+
chunk_size: int = 200,
|
|
99
|
+
) -> pd.DataFrame:
|
|
100
|
+
"""读取区间内的除权除息事件(只读;``pymongo`` 延迟导入)。
|
|
101
|
+
|
|
102
|
+
返回列:``code, date, fenhong, songzhuangu, peigu, peigujia``(缺失字段按 0 处理)。
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
import pymongo # 延迟导入:不进运行时依赖
|
|
106
|
+
except ImportError as exc: # pragma: no cover
|
|
107
|
+
raise ImportError("读取 xdxr 需要 pymongo:pip install 'alpha101v[mongo]'") from exc
|
|
108
|
+
|
|
109
|
+
codes = sorted({str(code) for code in codes})
|
|
110
|
+
if not codes:
|
|
111
|
+
raise ValueError("codes 不能为空")
|
|
112
|
+
|
|
113
|
+
client = pymongo.MongoClient(uri, serverSelectionTimeoutMS=5000)
|
|
114
|
+
try:
|
|
115
|
+
coll = client[database][collection]
|
|
116
|
+
frames: list[pd.DataFrame] = []
|
|
117
|
+
for offset in range(0, len(codes), chunk_size):
|
|
118
|
+
batch = codes[offset : offset + chunk_size]
|
|
119
|
+
cursor = coll.find(
|
|
120
|
+
{"code": {"$in": batch}, "category": category, "date": {"$gte": start, "$lte": end}},
|
|
121
|
+
{"_id": 0, "code": 1, "date": 1, **{f: 1 for f in _XDXR_FIELDS}},
|
|
122
|
+
)
|
|
123
|
+
frames.append(pd.DataFrame(list(cursor)))
|
|
124
|
+
finally:
|
|
125
|
+
client.close()
|
|
126
|
+
|
|
127
|
+
if not frames or all(frame.empty for frame in frames):
|
|
128
|
+
return pd.DataFrame(columns=["code", "date", *_XDXR_FIELDS])
|
|
129
|
+
|
|
130
|
+
events = pd.concat(frames, ignore_index=True)
|
|
131
|
+
events["date"] = pd.to_datetime(events["date"])
|
|
132
|
+
for field in _XDXR_FIELDS:
|
|
133
|
+
if field not in events.columns:
|
|
134
|
+
events[field] = np.nan
|
|
135
|
+
events[field] = pd.to_numeric(events[field], errors="coerce")
|
|
136
|
+
return events.sort_values(["date", "code"], ignore_index=True)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cumulative_factors(
|
|
140
|
+
close: pd.DataFrame,
|
|
141
|
+
events: pd.DataFrame,
|
|
142
|
+
*,
|
|
143
|
+
mode: str = "hfq",
|
|
144
|
+
anchor_date=None,
|
|
145
|
+
) -> pd.DataFrame:
|
|
146
|
+
"""累计复权因子面板(与 ``close`` 同形,逐股票取值)。
|
|
147
|
+
|
|
148
|
+
* 事件日若不是交易日,映射到**该日或之后第一个交易日**(用 ``searchsorted``);
|
|
149
|
+
* **前收取「该事件日之前最后一个非 NaN 的收盘」**(跳过停牌)——修 bug A:
|
|
150
|
+
原实现用 ``series.iloc[position-1]``,若中间有停牌(NaN)会把除权事件**静默当成 1.0**,
|
|
151
|
+
导致「同一股票单独请求 vs 与别人一起请求」结果不同;
|
|
152
|
+
* ``anchor_date`` 给定时,**该日期的因子被归一为 1**,且结果**与请求窗口/批次无关**(修 bug B);
|
|
153
|
+
调用方应传入固定的 ``ref_date``(锚从哪来是调用方的事:库只保证「给定锚,算得对」);
|
|
154
|
+
* 未给 ``anchor_date`` 时保留历史语义:``hfq`` 归一化到面板**首行** = 1(因果,但**依赖窗口**)、
|
|
155
|
+
``qfq`` 归一化到**末行** = 1(非因果,仅供展示)。跨窗口复现请显式传 ``anchor_date``。
|
|
156
|
+
"""
|
|
157
|
+
if mode not in ADJUST_MODES:
|
|
158
|
+
raise ValueError(f"mode 只接受 {ADJUST_MODES},收到 {mode!r}")
|
|
159
|
+
if mode == "none":
|
|
160
|
+
return pd.DataFrame(1.0, index=close.index, columns=close.columns)
|
|
161
|
+
|
|
162
|
+
factors = pd.DataFrame(1.0, index=close.index, columns=close.columns)
|
|
163
|
+
if events is None or events.empty:
|
|
164
|
+
return factors
|
|
165
|
+
|
|
166
|
+
for code, group in events.groupby("code", sort=False):
|
|
167
|
+
if code not in close.columns:
|
|
168
|
+
continue
|
|
169
|
+
series = close[code]
|
|
170
|
+
if series.notna().sum() == 0:
|
|
171
|
+
continue
|
|
172
|
+
multipliers: list[tuple[int, float]] = []
|
|
173
|
+
for row in group.itertuples(index=False):
|
|
174
|
+
position = int(series.index.searchsorted(row.date))
|
|
175
|
+
if position <= 0 or position >= len(series):
|
|
176
|
+
continue # 面板首行或窗口之外:无前收,跳过
|
|
177
|
+
# 修 bug A:向前找「事件日之前最后一个非 NaN 的收盘」(跳过停牌/缺数据)
|
|
178
|
+
prior = series.iloc[:position].dropna()
|
|
179
|
+
if prior.empty:
|
|
180
|
+
continue
|
|
181
|
+
prev_close = prior.iloc[-1]
|
|
182
|
+
ref = reference_price(
|
|
183
|
+
prev_close, row.fenhong, row.songzhuangu, row.peigu, row.peigujia
|
|
184
|
+
)
|
|
185
|
+
if not np.isfinite(ref):
|
|
186
|
+
continue
|
|
187
|
+
multipliers.append((position, float(prev_close) / ref))
|
|
188
|
+
|
|
189
|
+
if not multipliers:
|
|
190
|
+
continue
|
|
191
|
+
column = np.ones(len(series), dtype=float)
|
|
192
|
+
for position, ratio in multipliers:
|
|
193
|
+
column[position:] *= ratio
|
|
194
|
+
factors[code] = column
|
|
195
|
+
|
|
196
|
+
if anchor_date is not None:
|
|
197
|
+
# 显式锚:该日期因子归一为 1 ⇒ 结果与窗口/批次无关(修 bug B)
|
|
198
|
+
anchor = pd.Timestamp(anchor_date)
|
|
199
|
+
if anchor not in factors.index:
|
|
200
|
+
position = int(factors.index.searchsorted(anchor))
|
|
201
|
+
position = min(max(position, 0), len(factors.index) - 1)
|
|
202
|
+
anchor = factors.index[position]
|
|
203
|
+
row = factors.loc[anchor].replace(0.0, np.nan)
|
|
204
|
+
factors = factors.div(row, axis=1).fillna(1.0)
|
|
205
|
+
elif mode == "qfq":
|
|
206
|
+
tail = factors.iloc[-1].replace(0.0, np.nan)
|
|
207
|
+
factors = factors.div(tail, axis=1).fillna(1.0)
|
|
208
|
+
return factors
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def adjust_price_panels(
|
|
212
|
+
panels: dict[str, pd.DataFrame],
|
|
213
|
+
factors: pd.DataFrame,
|
|
214
|
+
*,
|
|
215
|
+
price_fields: Sequence[str] = PRICE_FIELDS,
|
|
216
|
+
) -> dict[str, pd.DataFrame]:
|
|
217
|
+
"""按累计因子缩放价格类字段,返回**新字典**(不修改入参)。"""
|
|
218
|
+
adjusted = dict(panels)
|
|
219
|
+
for field in price_fields:
|
|
220
|
+
if field in adjusted:
|
|
221
|
+
adjusted[field] = adjusted[field].reindex(
|
|
222
|
+
index=factors.index, columns=factors.columns
|
|
223
|
+
) * factors
|
|
224
|
+
return adjusted
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""申万行业 as-of 成员 → **PIT 行业标签面板**(可选适配层)。
|
|
2
|
+
|
|
3
|
+
数据源(设计文档 §10.2 指定)
|
|
4
|
+
----------------------------
|
|
5
|
+
``/home/yinxiuqu/quantming/data/sw_industry/members.parquet``(覆盖约 98.4%)::
|
|
6
|
+
|
|
7
|
+
index_code level src con_code in_date out_date is_new iface
|
|
8
|
+
801010.SI L1 SW2021 000019 2021-12-13 NaT Y index_member
|
|
9
|
+
|
|
10
|
+
``out_date`` 为 ``NaT`` 表示**仍在成分内**。另有 ``classify.parquet`` 提供
|
|
11
|
+
``index_code → industry_name``(中文名),本模块可选地用它把标签换成人可读的名称。
|
|
12
|
+
|
|
13
|
+
**为什么必须是 as-of**:原始 GPL 仓库用的是「固定 2017-01 快照」(设计文档 §3.3 缺陷 5),
|
|
14
|
+
那等于把未来信息灌进历史因子。本模块按 ``(交易日, 股票)`` 取 **``in_date <= t`` 且
|
|
15
|
+
(``out_date`` 为空或 ``> t``)** 的最近一次成分记录,构造 ``ind`` 面板——这正是
|
|
16
|
+
:func:`alpha101v.neutralize.ind_neutralize` 支持的逐日 DataFrame 形态。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterable
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import pandas as pd
|
|
26
|
+
|
|
27
|
+
__all__ = ["coverage_of", "load_sw_industry", "load_sw_industry_names"]
|
|
28
|
+
|
|
29
|
+
DEFAULT_MEMBERS = "/home/yinxiuqu/quantming/data/sw_industry/members.parquet"
|
|
30
|
+
DEFAULT_CLASSIFY = "/home/yinxiuqu/quantming/data/sw_industry/classify.parquet"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_sw_industry_names(classify_path: str | Path = DEFAULT_CLASSIFY) -> dict[str, str]:
|
|
34
|
+
"""``index_code → industry_name``(中文名,便于人工核对与文档展示)。"""
|
|
35
|
+
classify = pd.read_parquet(classify_path)
|
|
36
|
+
required = {"index_code", "industry_name"}
|
|
37
|
+
missing = required - set(classify.columns)
|
|
38
|
+
if missing:
|
|
39
|
+
raise KeyError(f"classify.parquet 缺少列 {sorted(missing)};实际 {sorted(classify.columns)}")
|
|
40
|
+
return dict(zip(classify["index_code"], classify["industry_name"], strict=False))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_sw_industry(
|
|
44
|
+
dates: Iterable[pd.Timestamp],
|
|
45
|
+
codes: Iterable[str],
|
|
46
|
+
*,
|
|
47
|
+
members_path: str | Path = DEFAULT_MEMBERS,
|
|
48
|
+
classify_path: str | Path | None = DEFAULT_CLASSIFY,
|
|
49
|
+
level: str = "L1",
|
|
50
|
+
src: str = "SW2021",
|
|
51
|
+
use_names: bool = True,
|
|
52
|
+
) -> pd.DataFrame:
|
|
53
|
+
"""构造 PIT 行业标签面板(``index``=交易日, ``columns``=股票代码)。
|
|
54
|
+
|
|
55
|
+
规则(显式,全部可测):
|
|
56
|
+
|
|
57
|
+
1. 只取 ``level`` / ``src`` 匹配的成分记录;
|
|
58
|
+
2. 对每个 (交易日, 股票),取 **``in_date <= 交易日``** 的最近一条(``merge_asof`` backward);
|
|
59
|
+
3. 若该条的 ``out_date`` 非空且 ``<= 交易日`` ⇒ 已调出,标签置 ``NaN``;
|
|
60
|
+
4. 同一 (交易日, 股票) 命中多条(数据里同一 ``in_date`` 多行业)时,按
|
|
61
|
+
``index_code`` 升序取第一条 —— **确定性的显式策略**,不随机;
|
|
62
|
+
5. 没有任何成分记录的格子为 ``NaN``(覆盖率缺口,不下沉填充——行业标签无法插值)。
|
|
63
|
+
"""
|
|
64
|
+
dates = pd.DatetimeIndex(sorted(pd.DatetimeIndex(dates).unique()))
|
|
65
|
+
codes = [str(code) for code in codes]
|
|
66
|
+
if len(dates) == 0 or not codes:
|
|
67
|
+
raise ValueError("dates 与 codes 都不能为空")
|
|
68
|
+
|
|
69
|
+
members = pd.read_parquet(members_path)
|
|
70
|
+
required = {"index_code", "level", "src", "con_code", "in_date", "out_date"}
|
|
71
|
+
missing = required - set(members.columns)
|
|
72
|
+
if missing:
|
|
73
|
+
raise KeyError(f"members.parquet 缺少列 {sorted(missing)};实际 {sorted(members.columns)}")
|
|
74
|
+
|
|
75
|
+
members = members[(members["level"] == level) & (members["src"] == src)].copy()
|
|
76
|
+
members = members[members["con_code"].astype(str).isin(codes)]
|
|
77
|
+
members["con_code"] = members["con_code"].astype(str)
|
|
78
|
+
members["in_date"] = pd.to_datetime(members["in_date"])
|
|
79
|
+
members["out_date"] = pd.to_datetime(members["out_date"])
|
|
80
|
+
|
|
81
|
+
label_column = "index_code"
|
|
82
|
+
if use_names and classify_path is not None and Path(classify_path).exists():
|
|
83
|
+
names = load_sw_industry_names(classify_path)
|
|
84
|
+
members["label"] = members["index_code"].map(names).fillna(members["index_code"])
|
|
85
|
+
label_column = "label"
|
|
86
|
+
else:
|
|
87
|
+
members["label"] = members["index_code"]
|
|
88
|
+
|
|
89
|
+
# merge_asof 要求**按 on-key 全局有序**(by 分组内自然随之有序):
|
|
90
|
+
# 左表按 (date, code) 排、右表按 (in_date, con_code) 排。
|
|
91
|
+
members = members.sort_values(["in_date", "con_code", "index_code"], ignore_index=True)
|
|
92
|
+
|
|
93
|
+
grid = pd.MultiIndex.from_product([dates, codes], names=["date", "code"]).to_frame(index=False)
|
|
94
|
+
grid = grid.sort_values(["date", "code"], ignore_index=True)
|
|
95
|
+
|
|
96
|
+
merged = pd.merge_asof(
|
|
97
|
+
grid,
|
|
98
|
+
members[["con_code", "in_date", "out_date", label_column]].rename(
|
|
99
|
+
columns={"con_code": "code"}
|
|
100
|
+
),
|
|
101
|
+
left_on="date",
|
|
102
|
+
right_on="in_date",
|
|
103
|
+
by="code",
|
|
104
|
+
direction="backward",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
active = merged["out_date"].isna() | (merged["out_date"] > merged["date"])
|
|
108
|
+
merged = merged[active]
|
|
109
|
+
merged = merged.drop_duplicates(["date", "code"], keep="first")
|
|
110
|
+
|
|
111
|
+
labels = merged.pivot(index="date", columns="code", values=label_column)
|
|
112
|
+
labels = labels.reindex(index=dates, columns=codes)
|
|
113
|
+
labels.index.name = None
|
|
114
|
+
labels.columns.name = None
|
|
115
|
+
coverage = float(labels.notna().to_numpy().mean())
|
|
116
|
+
if coverage == 0.0:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"行业覆盖率 0:level={level} src={src} 下没有任何 (date, code) 命中;"
|
|
119
|
+
"请检查成分表与股票代码格式(如 000001 vs 000001.SZ)"
|
|
120
|
+
)
|
|
121
|
+
labels.attrs["coverage"] = coverage
|
|
122
|
+
labels.attrs["level"] = level
|
|
123
|
+
labels.attrs["src"] = src
|
|
124
|
+
return labels
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def coverage_of(labels: pd.DataFrame) -> float:
|
|
128
|
+
"""辅助:行业标签覆盖率(``0~1``)。"""
|
|
129
|
+
values = labels.to_numpy()
|
|
130
|
+
return float(np.mean(~pd.isna(values)))
|