tidequant 0.1.0__tar.gz

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.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: tidequant
3
+ Version: 0.1.0
4
+ Summary: TideQuant因子层: 算子库、表达式因子求值与历史因子生成引擎
5
+ Classifier: Intended Audience :: Financial and Insurance Industry
6
+ Classifier: Programming Language :: Python :: 3.11
7
+ Classifier: Programming Language :: Python :: 3.12
8
+ Classifier: Topic :: Office/Business :: Financial :: Investment
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: jsonargparse
12
+ Requires-Dist: loguru
13
+ Requires-Dist: numba
14
+ Requires-Dist: numpy
15
+ Requires-Dist: pandas
16
+ Requires-Dist: pyarrow
17
+ Requires-Dist: setproctitle
18
+ Requires-Dist: tqdm
19
+
20
+ # TideQuant 潮汐量化
21
+ 顺势而生,逆势有度
22
+
23
+ 本包发布的是 TideQuant 的**因子层**: 算子库、表达式因子求值,
24
+ 以及从历史逐笔数据生成因子库的引擎。交易执行、交易所对接与看板
25
+ 不在这个包里。
26
+
27
+ ## 安装
28
+
29
+ ```bash
30
+ pip install tidequant
31
+ ```
32
+
33
+ ## 因子计算
34
+
35
+ ```python
36
+ from tidequant.factor import OPS, evaluate, parse
37
+
38
+ node = parse("cs_zscore(ts_mean(amount / volume, 20))")
39
+ value = evaluate(node, lambda leaf: load(leaf))
40
+ ```
41
+
42
+ `OPS` 是算子表,`register` 可以往里加自定义算子。算子分三族:
43
+ `ts_` 时序、`cs_` 截面、`ew_` 指数加权。
44
+
45
+ ## 生成历史因子库
46
+
47
+ 读取逐笔 trade 数据,按 interval 聚合字段因子,再对 `factors.json`
48
+ 里的表达式因子沿整个时间轴求值:
49
+
50
+ ```bash
51
+ python -m tidequant.engine.history_factor_generator \
52
+ --tbt_folder data/binance/trades --out_folder data/factors_15m \
53
+ --start_dt 2022-07-01 --end_dt 2026-07-01 --interval 15m
54
+ ```
55
+
56
+ 已生成的因子自动跳过,中断后重跑自动续写。用 `--factors_path`
57
+ 指向自己的因子名单文件即可替换内置的 `factors.json`。
58
+
59
+ 因子库读写接口在 `tidequant.data.FactorBase`。
@@ -0,0 +1,40 @@
1
+ # TideQuant 潮汐量化
2
+ 顺势而生,逆势有度
3
+
4
+ 本包发布的是 TideQuant 的**因子层**: 算子库、表达式因子求值,
5
+ 以及从历史逐笔数据生成因子库的引擎。交易执行、交易所对接与看板
6
+ 不在这个包里。
7
+
8
+ ## 安装
9
+
10
+ ```bash
11
+ pip install tidequant
12
+ ```
13
+
14
+ ## 因子计算
15
+
16
+ ```python
17
+ from tidequant.factor import OPS, evaluate, parse
18
+
19
+ node = parse("cs_zscore(ts_mean(amount / volume, 20))")
20
+ value = evaluate(node, lambda leaf: load(leaf))
21
+ ```
22
+
23
+ `OPS` 是算子表,`register` 可以往里加自定义算子。算子分三族:
24
+ `ts_` 时序、`cs_` 截面、`ew_` 指数加权。
25
+
26
+ ## 生成历史因子库
27
+
28
+ 读取逐笔 trade 数据,按 interval 聚合字段因子,再对 `factors.json`
29
+ 里的表达式因子沿整个时间轴求值:
30
+
31
+ ```bash
32
+ python -m tidequant.engine.history_factor_generator \
33
+ --tbt_folder data/binance/trades --out_folder data/factors_15m \
34
+ --start_dt 2022-07-01 --end_dt 2026-07-01 --interval 15m
35
+ ```
36
+
37
+ 已生成的因子自动跳过,中断后重跑自动续写。用 `--factors_path`
38
+ 指向自己的因子名单文件即可替换内置的 `factors.json`。
39
+
40
+ 因子库读写接口在 `tidequant.data.FactorBase`。
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tidequant"
7
+ dynamic = ["version"]
8
+ description = "TideQuant因子层: 算子库、表达式因子求值与历史因子生成引擎"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ # 只列因子层真用得上的; 主仓pyproject里那些(ccxt/aiohttp/xarray/dash等)
12
+ # 是交易与看板侧的, 装因子层的人不该被它们连坐
13
+ dependencies = [
14
+ "jsonargparse",
15
+ "loguru",
16
+ "numba",
17
+ "numpy",
18
+ "pandas",
19
+ "pyarrow",
20
+ "setproctitle",
21
+ "tqdm",
22
+ ]
23
+
24
+ classifiers = [
25
+ "Intended Audience :: Financial and Insurance Industry",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Topic :: Office/Business :: Financial :: Investment",
29
+ ]
30
+
31
+ # 构建根是publish/build.py搭出来的暂存树, 里头只有瘦包那几个文件,
32
+ # 所以find直接全收即可 —— 该收哪些的决定在build.py的PATHS
33
+ [tool.setuptools.packages.find]
34
+ include = ["tidequant*"]
35
+
36
+ [tool.setuptools.package-data]
37
+ # factors.json是包内数据文件, 不显式列出的话wheel里不会有它, 装上就
38
+ # 读不到因子配方
39
+ "tidequant.factor" = ["*.json"]
40
+
41
+ [tool.setuptools.dynamic]
42
+ version = { attr = "tidequant.__version__" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .enums import *
4
+
5
+ from .types import *
6
+
7
+
8
+ def __getattr__(name: str):
9
+ """
10
+ Executor惰性导入(PEP 562)
11
+
12
+ 顶层直接import它会连带拖进exchange与ccxt, 而PyPI上发布的是只含
13
+ 因子层的瘦包(见publish/), 那份wheel里没有executor, 照旧写法
14
+ import tidequant.factor就会在这一行直接炸
15
+
16
+ from tidequant import Executor与tidequant.Executor照旧可用;
17
+ 只有from tidequant import *不再带上它
18
+ """
19
+ if name == "Executor":
20
+ from .executor import Executor
21
+
22
+ return Executor
23
+
24
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,326 @@
1
+ """
2
+ 数据库相关类
3
+ """
4
+
5
+ import os
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from typing import List, Literal
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+
13
+ class TradeBase:
14
+ """
15
+ 历史逐笔数据库
16
+
17
+ 布局(folder/): <symbol>/<YYYY-MM-DD>.feather
18
+ 每文件一个symbol一个UTC自然日的逐笔交易
19
+ """
20
+
21
+ def __init__(self, folder: str) -> None:
22
+ self.folder: str = folder
23
+
24
+ def path(self, symbol: str, dt: np.datetime64) -> str:
25
+ return os.path.join(self.folder, symbol, f"{dt}.feather")
26
+
27
+ def list_symbols(self, start_dt: str, end_dt: str) -> List[str]:
28
+ """
29
+ 扫描[start_dt, end_dt)内有数据文件的symbol, 排序返回
30
+ """
31
+ symbols: List[str] = []
32
+ for name in sorted(os.listdir(self.folder)):
33
+ folder: str = os.path.join(self.folder, name)
34
+ if not os.path.isdir(folder):
35
+ continue
36
+
37
+ if any(
38
+ start_dt <= file[: 10] < end_dt
39
+ for file in os.listdir(folder)
40
+ if file.endswith(".feather")
41
+ ):
42
+ symbols.append(name)
43
+
44
+ return symbols
45
+
46
+ def list_dts(self, symbol: str) -> List[np.datetime64]:
47
+ """
48
+ 列出某symbol已有数据文件的日期, 排序返回
49
+ """
50
+ folder: str = os.path.join(self.folder, symbol)
51
+ if not os.path.isdir(folder):
52
+ return []
53
+
54
+ return sorted(
55
+ np.datetime64(file[: 10], "D")
56
+ for file in os.listdir(folder)
57
+ if file.endswith(".feather")
58
+ )
59
+
60
+ def read(self, symbol: str, dt: np.datetime64) -> pd.DataFrame | None:
61
+ """
62
+ 读取一个symbol一天的trades, 无文件(未上市/停牌)返回None
63
+ """
64
+ path: str = self.path(symbol, dt)
65
+ if not os.path.exists(path):
66
+ return None
67
+
68
+ return pd.read_feather(path)
69
+
70
+ def read_tails(self, symbol: str, dt: np.datetime64) -> pd.DataFrame | None:
71
+ """
72
+ 读取一天各方向最后一笔trade, 供次日计算交易时间间隔等指标
73
+ """
74
+ df: pd.DataFrame | None = self.read(symbol, dt)
75
+ if df is None:
76
+ return None
77
+
78
+ return self.tails(df)
79
+
80
+ @staticmethod
81
+ def tails(df: pd.DataFrame) -> pd.DataFrame:
82
+ """
83
+ 取trades中各方向最后一笔, "尾部"的唯一定义
84
+
85
+ 读盘路径(read_tails)与内存中刚聚合完的df取尾共用
86
+ """
87
+ return df.groupby("is_buyer_maker", sort=False).tail(1)
88
+
89
+ def write(
90
+ self, symbol: str, dt: np.datetime64, df: pd.DataFrame
91
+ ) -> None:
92
+ """
93
+ 写入一个symbol一天的trades
94
+
95
+ 先写临时文件再原子改名, 保证读侧看到的文件必是完整的
96
+ """
97
+ path: str = self.path(symbol, dt)
98
+ os.makedirs(os.path.dirname(path), exist_ok=True)
99
+ tmp_path: str = f"{path}.tmp.{os.getpid()}"
100
+ df.to_feather(tmp_path)
101
+ os.replace(tmp_path, path)
102
+
103
+
104
+ class FactorBase:
105
+ """
106
+ 因子库, 建库写入与切片读取的全部接口
107
+
108
+ 布局(folder/):
109
+ symbols.bin 换行分隔的symbol字符串, N轴, 建库时一次固定
110
+ datetimes.bin int64毫秒close时间戳, 每完成一天追加一批,
111
+ 是库进度的唯一真源
112
+ x/<因子名> float32行主序(T, N)矩阵, 字段因子按日追加,
113
+ 表达式因子每次整文件覆盖(见overwrite)
114
+ y/<标签名> 布局同x, 放前瞻的预测目标
115
+
116
+ 崩溃恢复: 每日提交顺序为先写全部因子块最后追加datetimes,
117
+ 重新打开库时把行数超过轴长的文件截断回轴长即可幂等续跑
118
+ """
119
+
120
+ ITEM_SIZE: int = 4 # float32字节数
121
+
122
+ def __init__(self, folder: str) -> None:
123
+ self.folder: str = folder
124
+ self.symbols_path: str = os.path.join(folder, "symbols.bin")
125
+ self.datetimes_path: str = os.path.join(folder, "datetimes.bin")
126
+
127
+ self.symbols: List[str] = []
128
+ self.datetimes: np.ndarray = np.empty(0, dtype=np.int64)
129
+ if self.exists():
130
+ with open(self.symbols_path, encoding="utf-8") as f:
131
+ self.symbols = [s for s in f.read().splitlines() if s]
132
+
133
+ if os.path.exists(self.datetimes_path):
134
+ self.datetimes = np.fromfile(
135
+ self.datetimes_path, dtype=np.int64
136
+ )
137
+
138
+ self._truncate_overrun()
139
+
140
+ def exists(self) -> bool:
141
+ """
142
+ 库是否已建过, 即N轴是否已写定
143
+ """
144
+ return os.path.exists(self.symbols_path)
145
+
146
+ def _truncate_overrun(self) -> None:
147
+ """
148
+ 把行数超过轴长的因子文件截断回轴长, 以防上次崩溃的半日写入
149
+ """
150
+ n_bytes: int = len(self.datetimes) * len(self.symbols) * self.ITEM_SIZE
151
+ for mode in ("x", "y"):
152
+ for name in self.list_names(mode):
153
+ path: str = os.path.join(self.folder, mode, name)
154
+ if os.path.getsize(path) > n_bytes:
155
+ with open(path, "r+b") as f:
156
+ f.truncate(n_bytes)
157
+
158
+ def create(self, symbols: List[str]) -> None:
159
+ """
160
+ 建库: 固定N轴并建好目录结构, 一个库只能建一次
161
+
162
+ N轴一旦写定, 后续所有因子文件的列顺序都以它为准
163
+ """
164
+ if self.exists():
165
+ raise RuntimeError(f"因子库{self.folder}已建过, 不可重建N轴")
166
+
167
+ for mode in ("x", "y"):
168
+ os.makedirs(os.path.join(self.folder, mode), exist_ok=True)
169
+
170
+ with open(self.symbols_path, "w", encoding="utf-8") as f:
171
+ f.write("".join(symbol + "\n" for symbol in symbols))
172
+
173
+ self.symbols = list(symbols)
174
+
175
+ def list_names(self, mode: Literal["x", "y"] = "x") -> List[str]:
176
+ """
177
+ 返回某侧全部名字, mode="x"为因子、"y"为标签
178
+ """
179
+ folder: str = os.path.join(self.folder, mode)
180
+ if not os.path.isdir(folder):
181
+ return []
182
+
183
+ return sorted(os.listdir(folder))
184
+
185
+ def written_rows(self, name: str, mode: Literal["x", "y"] = "x") -> int:
186
+ """
187
+ 某个名字已写入的行数, 无文件为0
188
+
189
+ 与len(datetimes)的区别: 后者是全库已提交的行数, 每天
190
+ 写完所有因子块才追加一批; 单个文件可以超前于它,
191
+ 也可以落后于它
192
+ """
193
+ path: str = os.path.join(self.folder, mode, name)
194
+ row_bytes: int = len(self.symbols) * self.ITEM_SIZE
195
+ try:
196
+ return os.path.getsize(path) // row_bytes
197
+ except FileNotFoundError:
198
+ return 0
199
+
200
+ def append(
201
+ self, name: str, block: np.ndarray, mode: Literal["x", "y"] = "x"
202
+ ) -> None:
203
+ """
204
+ 向因子文件尾部追加一个(bars, N)块
205
+ """
206
+ assert block.ndim == 2 and block.shape[1] == len(self.symbols)
207
+ with open(os.path.join(self.folder, mode, name), "ab") as f:
208
+ np.ascontiguousarray(block, dtype=np.float32).tofile(f)
209
+
210
+ def append_datetimes(self, stamps: np.ndarray) -> None:
211
+ """
212
+ 追加一批close时间戳, 作为一天数据的提交点
213
+ """
214
+ stamps = np.asarray(stamps, dtype=np.int64)
215
+ with open(self.datetimes_path, "ab") as f:
216
+ stamps.tofile(f)
217
+
218
+ self.datetimes = np.concatenate([self.datetimes, stamps])
219
+
220
+ def overwrite(
221
+ self, name: str, block: np.ndarray, mode: Literal["x", "y"] = "x"
222
+ ) -> None:
223
+ """
224
+ 用一个(T, N)块整文件替换某个因子
225
+
226
+ 表达式因子每次都从第0行重算(见HistoryFactorGenerator的二趟),
227
+ 所以是覆盖而不是追加。先写临时文件再原子改名, 与TradeBase.write
228
+ 同一套办法: 中途崩溃留下的是旧的完整文件, 不会是半个新文件
229
+ """
230
+ assert block.ndim == 2 and block.shape[1] == len(self.symbols)
231
+ path: str = os.path.join(self.folder, mode, name)
232
+ tmp_path: str = f"{path}.tmp.{os.getpid()}"
233
+ np.ascontiguousarray(block, dtype=np.float32).tofile(tmp_path)
234
+ os.replace(tmp_path, path)
235
+
236
+ def memmap(
237
+ self, name: str, mode: Literal["x", "y"] = "x"
238
+ ) -> np.memmap:
239
+ """
240
+ 某个因子的(已写行数, N)只读memmap视图
241
+
242
+ 供按品种列切片读: 文件是行主序, 列切片省不了I/O(每行的页
243
+ 都要碰), 但能把物化数组从T×N降到T×chunk, 这才是按列分块
244
+ 求值的实际收益
245
+ """
246
+ n_rows: int = self.written_rows(name, mode)
247
+ return np.memmap(
248
+ os.path.join(self.folder, mode, name),
249
+ dtype=np.float32,
250
+ mode="r",
251
+ shape=(n_rows, len(self.symbols)),
252
+ )
253
+
254
+ def read(
255
+ self,
256
+ names: str | List[str],
257
+ start_dt: str | None = None,
258
+ end_dt: str | None = None,
259
+ mode: Literal["x", "y"] = "x",
260
+ n_worker: int = 1,
261
+ ) -> np.ndarray:
262
+ """
263
+ 读取因子, 恒返回float32的(T, N, F)矩阵
264
+
265
+ [start_dt, end_dt)按UTC自然日左闭右开取整天,
266
+ 注意行对应bar的close时间, 每天末根bar的close为次日零点,
267
+ 故read(name, "2026-06-01", "2026-06-03")返回06-01、06-02
268
+ 两整天, 06-03T00:00对应的bar会包含在内。时间轴取
269
+ self.datetimes, N轴取self.symbols
270
+
271
+ n_worker>1时并发读各因子文件, 只对多因子有意义
272
+ """
273
+ if isinstance(names, str):
274
+ names = [names]
275
+
276
+ # close是bar的右端点, 故两端都取searchsorted右侧, 选中
277
+ # close∈(start_dt, end_dt]的行, 即窗口起点在[start_dt, end_dt)内
278
+ stamps: np.ndarray = self.datetimes
279
+ start: int = 0 if start_dt is None else int(np.searchsorted(
280
+ stamps, np.datetime64(start_dt, "ms").astype(np.int64),
281
+ side="right",
282
+ ))
283
+ end: int = len(stamps) if end_dt is None else int(np.searchsorted(
284
+ stamps, np.datetime64(end_dt, "ms").astype(np.int64),
285
+ side="right",
286
+ ))
287
+
288
+ cube: np.ndarray = np.empty(
289
+ (end - start, len(self.symbols), len(names)),
290
+ dtype=np.float32,
291
+ )
292
+
293
+ def load(i: int) -> None:
294
+ cube[:, :, i] = self._read_rows(names[i], mode, start, end)
295
+
296
+ if n_worker > 1:
297
+ with ThreadPoolExecutor(n_worker) as pool:
298
+ list(pool.map(load, range(len(names))))
299
+ else:
300
+ for i in range(len(names)):
301
+ load(i)
302
+
303
+ return cube
304
+
305
+ def _read_rows(
306
+ self, name: str, mode: Literal["x", "y"], start: int, end: int
307
+ ) -> np.ndarray:
308
+ """
309
+ 按字节偏移读取因子文件的[start, end)行
310
+ """
311
+ n_symbols: int = len(self.symbols)
312
+ n_want: int = (end - start) * n_symbols
313
+ path: str = os.path.join(self.folder, mode, name)
314
+ data: np.ndarray = np.fromfile(
315
+ path,
316
+ dtype=np.float32,
317
+ count=n_want,
318
+ offset=start * n_symbols * self.ITEM_SIZE,
319
+ )
320
+ if data.size != n_want:
321
+ raise ValueError(
322
+ f"{path}短于T轴: 期望读到行[{start}, {end}), "
323
+ f"实际不足, 文件可能未写完或损坏"
324
+ )
325
+
326
+ return data.reshape(-1, n_symbols)
@@ -0,0 +1 @@
1
+ from .base import Engine
@@ -0,0 +1,78 @@
1
+ """
2
+ 负责执行任务的引擎基类
3
+ """
4
+
5
+ import os
6
+ import sys
7
+ from abc import ABC, abstractmethod
8
+
9
+ import setproctitle
10
+ from loguru import logger
11
+
12
+
13
+ class Engine(ABC):
14
+ """
15
+ 引擎基类
16
+
17
+ 引擎运行的结果会被写入一个新的文件夹
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ folder: str,
23
+ log_folder: str | None = None,
24
+ log_label: str = "main",
25
+ ) -> None:
26
+ self.folder: str = folder
27
+ # 默认与数据目录相同; 多进程时可指到共享目录
28
+ self.log_folder: str = folder if log_folder is None else log_folder
29
+ self.log_label: str = log_label
30
+
31
+ # 进程名和log文件名均使用类名
32
+ setproctitle.setproctitle(self.__class__.__name__)
33
+ self._init_logger()
34
+
35
+ def _init_logger(self) -> None:
36
+ """
37
+ 配置logger, 同时输出到文件和控制台
38
+ """
39
+ self.configure_logger(
40
+ os.path.join(
41
+ self.log_folder, f"{self.__class__.__name__}.log"
42
+ ),
43
+ log_label=self.log_label,
44
+ )
45
+
46
+ @staticmethod
47
+ def configure_logger(
48
+ log_path: str,
49
+ log_label: str = "main",
50
+ ) -> None:
51
+ """
52
+ 配置进程内logger; enqueue保证多进程写同一文件安全
53
+ """
54
+ log_format: str = (
55
+ "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | "
56
+ f"{log_label} | {{message}}"
57
+ )
58
+ logger.remove()
59
+ logger.add(sys.stderr, format=log_format)
60
+ logger.add(
61
+ log_path,
62
+ encoding="utf-8",
63
+ format=log_format,
64
+ enqueue=True,
65
+ )
66
+
67
+ @abstractmethod
68
+ async def run(self) -> None:
69
+ """
70
+ 运行任务
71
+ """
72
+ pass
73
+
74
+ async def close(self) -> None:
75
+ """
76
+ 释放资源并关闭引擎
77
+ """
78
+ logger.remove()