qka 2.3.3.dev2__tar.gz → 2.4.1__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.
Files changed (32) hide show
  1. {qka-2.3.3.dev2 → qka-2.4.1}/PKG-INFO +5 -3
  2. {qka-2.3.3.dev2 → qka-2.4.1}/README.md +4 -2
  3. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/data.py +475 -72
  4. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/indicator.py +2 -2
  5. {qka-2.3.3.dev2 → qka-2.4.1}/skills/qka/references/backtest.md +1 -1
  6. {qka-2.3.3.dev2 → qka-2.4.1}/skills/qka/references/data.md +62 -22
  7. {qka-2.3.3.dev2 → qka-2.4.1}/skills/qka/references/strategy.md +2 -2
  8. {qka-2.3.3.dev2 → qka-2.4.1}/.github/workflows/release.yml +0 -0
  9. {qka-2.3.3.dev2 → qka-2.4.1}/.gitignore +0 -0
  10. {qka-2.3.3.dev2 → qka-2.4.1}/.vscode/settings.json +0 -0
  11. {qka-2.3.3.dev2 → qka-2.4.1}/CHANGELOG.md +0 -0
  12. {qka-2.3.3.dev2 → qka-2.4.1}/LICENSE +0 -0
  13. {qka-2.3.3.dev2 → qka-2.4.1}/pyproject.toml +0 -0
  14. {qka-2.3.3.dev2 → qka-2.4.1}/qka/__init__.py +0 -0
  15. {qka-2.3.3.dev2 → qka-2.4.1}/qka/cli.py +0 -0
  16. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/__init__.py +0 -0
  17. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/accessor.py +0 -0
  18. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/analysis.py +0 -0
  19. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/backtest.py +0 -0
  20. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/broker.py +0 -0
  21. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/report.py +0 -0
  22. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/simulate.py +0 -0
  23. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/sizing.py +0 -0
  24. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/strategy.py +0 -0
  25. {qka-2.3.3.dev2 → qka-2.4.1}/qka/core/tdx.py +0 -0
  26. {qka-2.3.3.dev2 → qka-2.4.1}/qka/utils/__init__.py +0 -0
  27. {qka-2.3.3.dev2 → qka-2.4.1}/qka/utils/logger.py +0 -0
  28. {qka-2.3.3.dev2 → qka-2.4.1}/skills/qka/SKILL.md +0 -0
  29. {qka-2.3.3.dev2 → qka-2.4.1}/skills/qka/references/analysis.md +0 -0
  30. {qka-2.3.3.dev2 → qka-2.4.1}/skills/qka/references/broker.md +0 -0
  31. {qka-2.3.3.dev2 → qka-2.4.1}/skills/qka/references/sizing.md +0 -0
  32. {qka-2.3.3.dev2 → qka-2.4.1}/tools/generate_api_ref.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qka
3
- Version: 2.3.3.dev2
3
+ Version: 2.4.1
4
4
  Summary: QKA(快量化 / Quant Kit for A-shares)- 简洁易用的 A 股量化回测框架
5
5
  Project-URL: Home, https://github.com/zsrl/qka
6
6
  Project-URL: Repository, https://github.com/zsrl/qka
@@ -97,7 +97,7 @@ from qka import Data
97
97
 
98
98
  data = Data(
99
99
  symbols=['000001.SZ', '600000.SH'],
100
- indicators={
100
+ factors={
101
101
  'sma_5': ('ta.trend.sma_indicator', 'close', 5),
102
102
  'rsi_14': ('ta.momentum.rsi', 'close', 14),
103
103
  },
@@ -105,6 +105,8 @@ data = Data(
105
105
  df = data.get() # 返回宽表 DataFrame,列名 {symbol}|{factor}
106
106
  ```
107
107
 
108
+ > `factors=` 也可写作 `indicators=`(旧名,完全等价、保留兼容);两者只能传一个。
109
+
108
110
  ### 策略
109
111
 
110
112
  ```python
@@ -159,7 +161,7 @@ from qka import Data, Strategy, Backtest
159
161
 
160
162
  data = Data(
161
163
  symbols=['000001.SZ'],
162
- indicators={
164
+ factors={
163
165
  'sma_5': ('ta.trend.sma_indicator', 'close', 5),
164
166
  'sma_20': ('ta.trend.sma_indicator', 'close', 20),
165
167
  },
@@ -46,7 +46,7 @@ from qka import Data
46
46
 
47
47
  data = Data(
48
48
  symbols=['000001.SZ', '600000.SH'],
49
- indicators={
49
+ factors={
50
50
  'sma_5': ('ta.trend.sma_indicator', 'close', 5),
51
51
  'rsi_14': ('ta.momentum.rsi', 'close', 14),
52
52
  },
@@ -54,6 +54,8 @@ data = Data(
54
54
  df = data.get() # 返回宽表 DataFrame,列名 {symbol}|{factor}
55
55
  ```
56
56
 
57
+ > `factors=` 也可写作 `indicators=`(旧名,完全等价、保留兼容);两者只能传一个。
58
+
57
59
  ### 策略
58
60
 
59
61
  ```python
@@ -108,7 +110,7 @@ from qka import Data, Strategy, Backtest
108
110
 
109
111
  data = Data(
110
112
  symbols=['000001.SZ'],
111
- indicators={
113
+ factors={
112
114
  'sma_5': ('ta.trend.sma_indicator', 'close', 5),
113
115
  'sma_20': ('ta.trend.sma_indicator', 'close', 20),
114
116
  },
@@ -5,8 +5,12 @@ QKA数据模块
5
5
  """
6
6
 
7
7
  import contextlib
8
+ import hashlib
9
+ import inspect
8
10
  import io
11
+ import json
9
12
  import os
13
+ import textwrap
10
14
  import threading
11
15
  from pathlib import Path
12
16
  from tqdm import tqdm
@@ -30,12 +34,20 @@ _QKA_BUILTIN_NAMES = frozenset({
30
34
  })
31
35
 
32
36
 
33
- def _is_qka_indicator(ind_type):
37
+ def _is_qka_factor(ind_type):
34
38
  """判断指标类型是否为 qka 内置指标(支持 qka. 前缀或裸名)。"""
35
39
  return (isinstance(ind_type, str) and
36
40
  (ind_type.startswith('qka.') or ind_type in _QKA_BUILTIN_NAMES))
37
41
 
38
42
 
43
+ def _callable_source(fn):
44
+ """取可调用对象的源码文本,用于算配方指纹;取不到返回 None(表示不缓存)。"""
45
+ try:
46
+ return textwrap.dedent(inspect.getsource(fn))
47
+ except (OSError, TypeError):
48
+ return None
49
+
50
+
39
51
  # 当日日线数据「可能已经生成」的时刻(本地时间)。一天以它为界切成前后两个时段:
40
52
  # 17:00 之前,数据源手里最新的一根还是上一交易日,当天这根根本还没生成;
41
53
  # 17:00 之后,当天的已经出完了,要补也就这一次。
@@ -57,14 +69,15 @@ class Data():
57
69
  数据管理类
58
70
 
59
71
  负责股票数据的获取、缓存和管理,支持多数据源、并发下载和自定义因子计算。
60
- 通过 `indicators` 参数统一处理技术指标和自定义因子,在数据加载时一次性预计算。
61
-
72
+ 通过 `factors` 参数统一处理技术指标和自定义因子,在数据加载时一次性预计算。
73
+ (`indicators` 是同义旧名,两者完全等价、可互换,保留向后兼容。)
74
+
62
75
  Attributes:
63
76
  symbols (List[str]): 股票代码列表,格式「代码.市场」,如 ['600519.SH', '000001.SZ']
64
77
  (也兼容 sh.600519 旧写法,两种写法等价)
65
78
  period (str): 数据周期,如 '1d'、'1m' 等
66
79
  adjust (str): 复权方式,如 'qfq'、'hfq'、'bfq'
67
- indicators (dict | Callable): 预计算指标/因子
80
+ factors (dict | Callable): 预计算指标/因子(旧名 `indicators` 等价)
68
81
  source (str): 数据源,默认 'tickflow'(可选 'baostock'、'tdx')
69
82
  pool_size (int): 并发下载线程数
70
83
  datadir (Path): 数据缓存目录
@@ -77,6 +90,14 @@ class Data():
77
90
  # 支持的行情源
78
91
  SOURCES = ('tickflow', 'baostock', 'tdx')
79
92
 
93
+ # 允许「增量缓存」的复权方式。前复权(qfq)**不在其中**。
94
+ # 原因:前复权是「以最新价为锚」,今后的分红送股会往回改写整段历史 ——
95
+ # 缓存若只往后补新棒,文件里就会拼出「前段旧口径 + 后段新口径」的缝,
96
+ # 缝上凭空多出一根假 K 线(实测 000001.SZ 一次分红让整段等比缩 5.57%,
97
+ # 拼接处算出 -4.84%,真实是 +0.91%)。故前复权一律整段重取覆盖,
98
+ # 保证文件里永远只有一种口径。不复权 / 后复权的历史不会被改写,可以放心增量。
99
+ INCREMENTAL_ADJUSTS = ('bfq', 'hfq')
100
+
80
101
  # baostock query_history_k_data_plus 完整支持的基础字段(除 date 索引外)
81
102
  BAOSTOCK_BASE_FIELDS = ["open", "high", "low", "close", "volume", "amount"]
82
103
  # 可通过 extra_fields 追加的扩展字段白名单(行情/估值/选股类)
@@ -127,9 +148,11 @@ class Data():
127
148
  source: str = 'tickflow',
128
149
  pool_size: int = 10,
129
150
  datadir: Optional[Path] = None,
151
+ factors: Optional[dict] = None,
130
152
  indicators: Optional[dict] = None,
131
153
  extra_fields: Optional[List[str]] = None,
132
154
  warmup: int = 0,
155
+ factor_cache: bool = False,
133
156
  ):
134
157
  """
135
158
  初始化数据对象
@@ -148,31 +171,24 @@ class Data():
148
171
  ])
149
172
  ```
150
173
 
151
- 模拟标的同样支持 `indicators` / `extra_fields` / `warmup`,行为与真实源
174
+ 模拟标的同样支持 `factors` / `extra_fields` / `warmup`,行为与真实源
152
175
  一致(列名同样是 {symbol}|{field},扩展列同样是「不请求就不生成」)。
153
176
  模拟标的的行情区间由 get(start_date, end_date) 决定 —— 用 baostock 的
154
177
  交易日历把区间内的真实交易日取出来,一 日一根,与真实源同构。
155
178
  benchmark: 基准代码,如 '000300.SH'。基准数据以 benchmark| 前缀加入最终 DataFrame,
156
179
  仅供辅助计算(β/α 等),不参与指标计算
157
180
  period: 数据周期,如 '1d'(日线)、'1m'(分钟)
158
- adjust: 复权方式,'qfq'(前复权)、'hfq'(后复权)、'bfq'(不复权)
181
+ adjust: 复权方式,'qfq'(前复权)、'hfq'(后复权)、'bfq'(不复权)。
182
+ 注意缓存口径:前复权的历史会被今后的分红送股改写,故它**不做增量缓存**
183
+ —— 需要问数据源时整段重取覆盖,保证缓存文件里只有一种口径;
184
+ 不复权 / 后复权的历史不会被改写,走增量缓存(只补缺口)。详见 INCREMENTAL_ADJUSTS
159
185
  source: 数据来源,默认 'tickflow'(可选 'baostock'、'tdx')。各源返回结构一致,
160
186
  切换源不影响上层调用;差异见各源的扩展字段能力。'tdx' 走本机安装的
161
187
  「支持 TQ 策略」通达信客户端(仅 Windows,需客户端在运行),安装目录的定位
162
188
  方式见 `qka.set_tdx_root` / `qka.get_tdx_root_info`
163
189
  pool_size: 并发下载线程数
164
190
  datadir: 缓存目录路径
165
- indicators: 预计算指标/因子,支持三种格式:
166
- extra_fields: 扩展字段列表(选股/估值用,如 ['peTTM', 'pbMRQ', 'turn']),
167
- 可选值见 BAOSTOCK_EXTRA_FIELDS —— 仅 'baostock' 源提供;
168
- 'tickflow' 与 'tdx' 源无扩展列,传入的扩展字段会被静默忽略
169
- (不报错、不生成该列)。
170
- 追加的列同样遵循 {symbol}|{field} 命名,如 '600519.SH|peTTM'。注意:
171
- 首次下载后缓存字段固定,变更 extra_fields 会自动检测列缺失并重新下载对应股票。
172
-
173
- warmup: 指标预热天数(默认 0)。回测/取数时自动多读取 warmup 个交易日的历史数据
174
- 用于计算指标,使第 1 个交易日即可拿到有效指标值,无需在策略里手写
175
- `if len(hist) < N: continue` 跳过前 N 根 bar。仅用于计算,不会增加 on_bar 调用次数。
191
+ factors: 预计算指标/因子(**推荐写法**)。支持两种写法(dict / callable;不传即不算):
176
192
 
177
193
  **1. 字典(混搭 ta 函数和自定义因子):**
178
194
  ```python
@@ -187,13 +203,32 @@ class Data():
187
203
  每个条目独立产一列,直接透传 ta 库全部指标。
188
204
  列名参数('close'/'high' 等)放在 ta 路径之后、数值参数之前;
189
205
  多列函数(如 ATR)连续列出所有列名即可。
190
-
206
+
191
207
  **2. 函数(自定义因子):**
192
208
  ```python
193
- indicators=lambda df: df.assign(ma5=df['close'].rolling(5).mean())
209
+ factors=lambda df: df.assign(ma5=df['close'].rolling(5).mean())
194
210
  ```
195
211
  函数接收单只股票的 DataFrame,返回添加了额外列的 DataFrame。
196
212
 
213
+ indicators: `factors` 的同义旧名,行为完全一致、保留向后兼容;两者同时传会报错。
214
+
215
+ extra_fields: 扩展字段列表(选股/估值用,如 ['peTTM', 'pbMRQ', 'turn']),
216
+ 可选值见 BAOSTOCK_EXTRA_FIELDS —— 仅 'baostock' 源提供;
217
+ 'tickflow' 与 'tdx' 源无扩展列,传入的扩展字段会被静默忽略
218
+ (不报错、不生成该列)。
219
+ 追加的列同样遵循 {symbol}|{field} 命名,如 '600519.SH|peTTM'。注意:
220
+ 首次下载后缓存字段固定,变更 extra_fields 会自动检测列缺失并重新下载对应股票。
221
+
222
+ warmup: 因子预热天数(默认 0)。回测/取数时自动多读取 warmup 个交易日的历史数据
223
+ 用于计算因子,使第 1 个交易日即可拿到有效值,无需在策略里手写
224
+ `if len(hist) < N: continue` 跳过前 N 根 bar。仅用于计算,不会增加 on_bar 调用次数。
225
+
226
+ factor_cache: 是否把算出来的因子**纵向落盘**(默认 False,即不缓存)。
227
+ 开启后,因子按 `datadir/factors/{因子名}/{symbol}.parquet` 存,
228
+ 命中就直接读、只缺尾巴就只补尾巴;配方或数据口径变了则整条重算。
229
+ 详见 `skills/qka/references/data.md` 的「因子缓存」。
230
+ 只作用于默认(`lazy=False`)路径。
231
+
197
232
  """
198
233
  # ── 归一化 symbols:str = 真实标的,Simulate 对象 = 模拟标的,可混排 ──
199
234
  names, sim_specs = [], {}
@@ -217,11 +252,15 @@ class Data():
217
252
  self.benchmark = benchmark
218
253
  self.period = period
219
254
  self.adjust = adjust
255
+ # 该复权方式是否允许增量缓存(前复权不允许,见 INCREMENTAL_ADJUSTS)
256
+ self.incremental = adjust in self.INCREMENTAL_ADJUSTS
220
257
  if source not in self.SOURCES:
221
258
  raise ValueError(f"不支持的数据源: {source!r}。可选: {self.SOURCES}")
222
259
  self.source = source
223
260
  self.pool_size = pool_size
224
261
  self.warmup = warmup
262
+ # 因子缓存:默认关。要用得显式开(见 _factors_with_cache)
263
+ self.factor_cache = bool(factor_cache)
225
264
 
226
265
  # 当前数据源实际支持的扩展字段(源能力不同,按源过滤)
227
266
  whitelist = self._extra_fields_whitelist(source)
@@ -244,18 +283,24 @@ class Data():
244
283
  if f not in self.extra_fields:
245
284
  self.extra_fields.append(f)
246
285
 
247
- # 统一处理 indicators 参数
248
- if callable(indicators):
286
+ # 统一处理 factors / indicators 参数(同一个东西的两个名字,等价)
287
+ if factors is not None and indicators is not None:
288
+ raise TypeError(
289
+ "factors 与 indicators 是同一个参数的新旧两个名字,"
290
+ "等价且只能传一个,请只传 factors"
291
+ )
292
+ specs = factors if factors is not None else indicators
293
+ if callable(specs):
249
294
  # 函数形式 → 保存为 callable
250
- self._indicators = indicators
251
- elif isinstance(indicators, dict):
295
+ self._factors = specs
296
+ elif isinstance(specs, dict):
252
297
  # 字典形式
253
- self._indicators = indicators
254
- elif indicators is None:
255
- self._indicators = {}
298
+ self._factors = specs
299
+ elif specs is None:
300
+ self._factors = {}
256
301
  else:
257
302
  raise TypeError(
258
- f"indicators 必须是 dict、callable 或 None,got {type(indicators)}"
303
+ f"factors 必须是 dict、callable 或 None,got {type(specs)}"
259
304
  )
260
305
 
261
306
  # 初始化缓存目录
@@ -494,6 +539,9 @@ class Data():
494
539
  只补下载缺失的部分(前面缺失补前面,后面缺失补后面),合并去重写回。
495
540
  若缓存缺少 extra_fields 指定的列(如从无扩展字段升级到有),则全量重新下载。
496
541
 
542
+ 例外:不允许增量缓存的复权方式(前复权,见 INCREMENTAL_ADJUSTS)不走增量,
543
+ 一旦需要问数据源就整段重取覆盖,详见 _rewrite_full。
544
+
497
545
  每次问完数据源都会留下痕迹:补到新数据就写文件(修改时间自然刷新),
498
546
  没补到就刷新修改时间 + 记下「已确认起点」。这两样让重复的请求不必再问网络,
499
547
  详见 _needs_download。
@@ -516,6 +564,14 @@ class Data():
516
564
  # 实际请求的扩展字段 = 当前配置 ∪ 缓存已有扩展列(列只增不减,不互相覆盖)
517
565
  merged_extra = self._merged_extra_fields(path)
518
566
 
567
+ # ── 不通增量的复权方式(前复权):整段重取、整段覆盖 ──
568
+ # 前复权的历史会被今后的分红送股改写,只补新棒就会拼出「前段旧口径 + 后段新口径」
569
+ # 的缝(见 INCREMENTAL_ADJUSTS),故一旦需要问数据源就整段重新要一遍、整体替换。
570
+ if path.exists() and not self.incremental:
571
+ return self._rewrite_full(
572
+ path, symbol, download_start, download_end, merged_extra
573
+ )
574
+
519
575
  # ── 首次下载:只拉请求范围(缓存缺失 extra_fields 列时也全量重下)──
520
576
  if not path.exists() or self._cache_missing_extra_fields(path):
521
577
  df = self._fetch(
@@ -597,6 +653,54 @@ class Data():
597
653
  pq.write_table(table, path)
598
654
  return path
599
655
 
656
+ def _rewrite_full(
657
+ self, path: Path, symbol: str,
658
+ download_start: str = None, download_end: str = None,
659
+ extra_fields: Optional[List[str]] = None,
660
+ ) -> Path:
661
+ """整段重取并覆盖缓存(供不允许增量缓存的复权方式使用)。
662
+
663
+ 为什么要盖住缓存已存的整段:只下「请求范围」会把长历史截短;只补缺口又会
664
+ 留下范围外的老口径数据 —— 那也是缝。所以范围取「缓存已有范围 ∪ 请求范围」,
665
+ 重取回来的整段是同一个口径,整体替换。
666
+
667
+ Args:
668
+ path: 缓存文件路径
669
+ symbol: 标的代码
670
+ download_start / download_end: 本次请求范围(None 表示不设边界)
671
+ extra_fields: 本次要请求的扩展字段
672
+
673
+ Returns:
674
+ Path: 数据文件路径
675
+
676
+ Raises:
677
+ RuntimeError: 数据源返回空数据(此时保留原缓存,绝不拿空数据覆盖)
678
+ """
679
+ existing = pd.read_parquet(path)
680
+ cache_min = existing.index.min()
681
+ cache_max = existing.index.max()
682
+
683
+ req_start = pd.Timestamp(download_start) if download_start else None
684
+ req_end = (pd.Timestamp(download_end) if download_end
685
+ else pd.Timestamp.now().floor('D'))
686
+
687
+ range_start = min(x for x in (cache_min, req_start) if x is not None)
688
+ range_end = max(cache_max, req_end)
689
+
690
+ df = self._fetch(
691
+ symbol,
692
+ start_date=range_start.strftime('%Y-%m-%d'),
693
+ end_date=range_end.strftime('%Y-%m-%d'),
694
+ extra_fields=extra_fields,
695
+ )
696
+ if len(df) == 0:
697
+ raise RuntimeError(
698
+ f"{symbol}: 数据源({self.source})返回空数据,已保留原缓存"
699
+ )
700
+ # 这次就是从这个起点问的,一并记下「已确认起点」
701
+ self._write_verified_from(path, pa.Table.from_pandas(df), range_start)
702
+ return path
703
+
600
704
  def _needs_download(
601
705
  self, symbol: str,
602
706
  download_start: str = None,
@@ -659,18 +763,18 @@ class Data():
659
763
  lazy=True: dd.DataFrame,列名格式 {symbol}|{factor}
660
764
  没有数据时抛出 RuntimeError
661
765
 
662
- 注意:有指标时,start_date 会自动向后扩展 max_window 个交易日读取缓存,
663
- 确保指标有足够的预热数据。最终返回的 DataFrame 仍严格限定在 [start_date, end_date]。
766
+ 注意:有因子时,start_date 会自动向后扩展 max_window 个交易日读取缓存,
767
+ 确保因子有足够的预热数据。最终返回的 DataFrame 仍严格限定在 [start_date, end_date]。
664
768
 
665
769
  模拟标的的日期轴取自 baostock 交易日历(区间内一日一根),因此需要能登录 baostock。
666
770
  """
667
771
  if not self.symbols:
668
772
  return pd.DataFrame()
669
773
 
670
- # 计算指标预热窗口
671
- max_window = self._min_rows_for_indicators()
774
+ # 计算因子预热窗口
775
+ max_window = self._min_rows_for_factors()
672
776
 
673
- # 构建 parquet predicate pushdown 过滤(日期 + 指标预热扩展)
777
+ # 构建 parquet predicate pushdown 过滤(日期 + 因子预热扩展)
674
778
  pq_filters = None
675
779
  read_start = None
676
780
  if start_date is not None or end_date is not None or max_window > 0:
@@ -679,9 +783,9 @@ class Data():
679
783
  read_start = pd.Timestamp(start_date)
680
784
  if max_window > 0:
681
785
  if read_start is not None:
682
- # 向后扩展,确保指标有足够的预热数据
786
+ # 向后扩展,确保因子有足够的预热数据
683
787
  read_start = read_start - pd.offsets.BDay(max_window * 2)
684
- # 无 start_date 但有指标时,不需要扩展(从头读即可)
788
+ # 无 start_date 但有因子时,不需要扩展(从头读即可)
685
789
  if read_start is not None:
686
790
  pq_filters.append(('date', '>=', read_start))
687
791
  if end_date is not None:
@@ -779,7 +883,7 @@ class Data():
779
883
  continue
780
884
  ddf = dd.read_parquet(str(parquet_path), filters=pq_filters)
781
885
  ddf['returns'] = ddf['close'].diff() / ddf['close'].shift(1)
782
- ddf = self._apply_indicators(ddf)
886
+ ddf = self._apply_factors(ddf)
783
887
  column_mapping = {col: f'{symbol}|{col}' for col in ddf.columns}
784
888
  dfs.append(ddf.rename(columns=column_mapping))
785
889
 
@@ -800,7 +904,7 @@ class Data():
800
904
  ddf = self._add_benchmark(ddf, self.target_dir, pq_filters)
801
905
 
802
906
  # qka 内置指标(需要 benchmark 和 symbol 前缀)
803
- ddf = self._apply_qka_indicators(ddf)
907
+ ddf = self._apply_qka_factors(ddf)
804
908
 
805
909
  return ddf
806
910
 
@@ -819,7 +923,6 @@ class Data():
819
923
  continue
820
924
  df = dd.read_parquet(str(parquet_path), filters=pq_filters)
821
925
  df['returns'] = df['close'].diff() / df['close'].shift(1)
822
- df = self._apply_indicators(df)
823
926
  column_mapping = {col: f'{symbol}|{col}' for col in df.columns}
824
927
  dfs.append(df.rename(columns=column_mapping))
825
928
 
@@ -831,6 +934,11 @@ class Data():
831
934
 
832
935
  ddf = dd.concat(dfs, axis=1, join='outer')
833
936
  result = ddf.compute()
937
+
938
+ # 因子:逐票逐因子贴上(带缓存 —— 全盖住就不算,只缺尾巴就只补尾巴)
939
+ # 注意在切片之前算:此时还带着预热数据,滚动窗口才够长。
940
+ result = self._factors_with_cache(result)
941
+
834
942
  # 切片回用户请求的日期范围
835
943
  if start_date is not None and max_window > 0:
836
944
  result = result[result.index >= pd.Timestamp(start_date)]
@@ -839,10 +947,11 @@ class Data():
839
947
  if self.benchmark:
840
948
  result = self._add_benchmark(result, self.target_dir, pq_filters)
841
949
 
842
- # qka 内置指标(需要 benchmark 和 symbol 前缀)
843
- result = self._apply_qka_indicators(result)
950
+ # qka 内置因子(需要 benchmark 和 symbol 前缀)
951
+ result = self._qka_factors_with_cache(result)
844
952
 
845
- return result
953
+ # 列顺序复原,与重构前一致
954
+ return self._order_columns(result, self._qka_factor_names())
846
955
 
847
956
  def _add_benchmark(self, df, target_dir, pq_filters):
848
957
  """将基准数据的 returns 以 benchmark| 前缀追加到 DataFrame。"""
@@ -861,7 +970,7 @@ class Data():
861
970
  df['benchmark|returns'] = bench_returns.reindex(df.index)
862
971
  return df
863
972
 
864
- def _apply_indicators(self, df):
973
+ def _apply_factors(self, df):
865
974
  """
866
975
  对单只股票的数据应用预定义的指标/因子。
867
976
 
@@ -876,7 +985,7 @@ class Data():
876
985
  Returns:
877
986
  DataFrame: 包含原始列和指标列
878
987
  """
879
- inds = self._indicators
988
+ inds = self._factors
880
989
 
881
990
  # 空 → 跳过
882
991
  if not inds:
@@ -892,23 +1001,30 @@ class Data():
892
1001
  if isinstance(df, dd.DataFrame):
893
1002
  # 样本行数完全由 warmup 决定(用户显式声明前置窗口),不写死兜底值。
894
1003
  # warmup=0 表示用户不预留前置数据,lambda 指标可能因此无法物化——属用户责任。
895
- sample = df.head(self._min_rows_for_indicators())
896
- meta = self._compute_indicator_cols(sample.copy())
1004
+ sample = df.head(self._min_rows_for_factors())
1005
+ meta = self._compute_factor_cols(sample.copy())
897
1006
  return df.map_partitions(
898
- lambda partition: self._compute_indicator_cols(partition.copy()),
1007
+ lambda partition: self._compute_factor_cols(partition.copy()),
899
1008
  meta=meta,
900
1009
  )
901
- return self._compute_indicator_cols(df.copy())
1010
+ return self._compute_factor_cols(df.copy())
1011
+
1012
+ def _compute_factor_cols(self, df, only=None):
1013
+ """在 pandas DataFrame 上计算指标/因子列(单只股票)。
902
1014
 
903
- def _compute_indicator_cols(self, df):
904
- """在 pandas DataFrame 上计算指标/因子列(单只股票)。"""
1015
+ Args:
1016
+ df: 单票 DataFrame
1017
+ only: 只算这几个列名(None = 全算)。因子缓存按因子粒度补算时用。
1018
+ """
905
1019
 
906
1020
  # 分区过小时跳过,避免 ta-lib 在 dask meta 推断时崩溃
907
- min_rows = self._min_rows_for_indicators()
1021
+ min_rows = self._min_rows_for_factors()
908
1022
  if len(df) < min_rows:
909
1023
  return df
910
1024
 
911
- for col_name, spec in self._indicators.items():
1025
+ for col_name, spec in self._factors.items():
1026
+ if only is not None and col_name not in only:
1027
+ continue
912
1028
  # 自定义因子(callable 值)
913
1029
  if callable(spec):
914
1030
  result = spec(df)
@@ -928,22 +1044,22 @@ class Data():
928
1044
 
929
1045
  ind_type = spec[0]
930
1046
 
931
- # qka 内置指标:跳过,由 _apply_qka_indicators 集中处理
932
- if _is_qka_indicator(ind_type):
1047
+ # qka 内置指标:跳过,由 _apply_qka_factors 集中处理
1048
+ if _is_qka_factor(ind_type):
933
1049
  continue
934
1050
 
935
1051
  args = list(spec[1:])
936
1052
 
937
1053
  # 收集前面连续的字符串参数作为列名(遇到第一个非字符串即停)
938
- factors = []
1054
+ cols = []
939
1055
  rest_start = 0
940
1056
  for i, arg in enumerate(args):
941
1057
  if isinstance(arg, str):
942
- factors.append(arg)
1058
+ cols.append(arg)
943
1059
  rest_start = i + 1
944
1060
  else:
945
1061
  break
946
- if not factors:
1062
+ if not cols:
947
1063
  logger.warning(f"指标 {col_name} 缺少计算列名,跳过")
948
1064
  continue
949
1065
  rest = args[rest_start:]
@@ -966,14 +1082,14 @@ class Data():
966
1082
  logger.warning(f"无法加载指标函数 {ind_type}: {e}")
967
1083
  continue
968
1084
 
969
- if len(factors) == 1:
970
- df[col_name] = fn(df[factors[0]], *rest)
1085
+ if len(cols) == 1:
1086
+ df[col_name] = fn(df[cols[0]], *rest)
971
1087
  else:
972
- df[col_name] = fn(*[df[f] for f in factors], *rest)
1088
+ df[col_name] = fn(*[df[f] for f in cols], *rest)
973
1089
 
974
1090
  return df
975
1091
 
976
- def _apply_qka_indicators(self, df):
1092
+ def _apply_qka_factors(self, df):
977
1093
  """应用 qka 内置指标(带 symbol 前缀,含 benchmark 数据)。
978
1094
 
979
1095
  必须在 concat + 加 benchmark 之后调用,此时列名格式为
@@ -985,28 +1101,28 @@ class Data():
985
1101
  Returns:
986
1102
  DataFrame: 含 qka 指标列
987
1103
  """
988
- inds = self._indicators
1104
+ inds = self._factors
989
1105
  if not inds or not isinstance(inds, dict):
990
1106
  return df
991
1107
 
992
1108
  qka_specs = [
993
1109
  (col_name, spec) for col_name, spec in inds.items()
994
1110
  if isinstance(spec, (list, tuple))
995
- and _is_qka_indicator(spec[0])
1111
+ and _is_qka_factor(spec[0])
996
1112
  ]
997
1113
  if not qka_specs:
998
1114
  return df
999
1115
 
1000
1116
  if isinstance(df, dd.DataFrame):
1001
- sample = df.head(self._min_rows_for_indicators())
1002
- meta = self._compute_qka_indicator_cols(sample, qka_specs)
1117
+ sample = df.head(self._min_rows_for_factors())
1118
+ meta = self._compute_qka_factor_cols(sample, qka_specs)
1003
1119
  return df.map_partitions(
1004
- lambda p: self._compute_qka_indicator_cols(p, qka_specs),
1120
+ lambda p: self._compute_qka_factor_cols(p, qka_specs),
1005
1121
  meta=meta,
1006
1122
  )
1007
- return self._compute_qka_indicator_cols(df, qka_specs)
1123
+ return self._compute_qka_factor_cols(df, qka_specs)
1008
1124
 
1009
- def _compute_qka_indicator_cols(self, df, qka_specs):
1125
+ def _compute_qka_factor_cols(self, df, qka_specs, symbols=None):
1010
1126
  """在已合并的 DataFrame 上计算 qka 内置指标。"""
1011
1127
  for col_key, spec in qka_specs:
1012
1128
  fn_name = spec[0].split('.', 1)[1] if spec[0].startswith('qka.') else spec[0]
@@ -1019,7 +1135,7 @@ class Data():
1019
1135
  continue
1020
1136
 
1021
1137
  # 为每只股票计算
1022
- for symbol in self.symbols:
1138
+ for symbol in (symbols if symbols is not None else self.symbols):
1023
1139
  col = f'{symbol}|{col_key}'
1024
1140
  try:
1025
1141
  df[col] = fn(df, symbol, *args)
@@ -1029,16 +1145,16 @@ class Data():
1029
1145
 
1030
1146
  return df
1031
1147
 
1032
- def _min_rows_for_indicators(self):
1033
- """计算所有指标所需的最小行数(含显式预热天数)。
1148
+ def _min_rows_for_factors(self):
1149
+ """计算所有因子所需的最小行数(含显式预热天数)。
1034
1150
 
1035
- 遍历所有指标条目的整数参数,取最大值作为窗口上限的保守估计,
1151
+ 遍历所有因子条目的整数参数,取最大值作为窗口上限的保守估计,
1036
1152
  再与显式 warmup 取较大者。
1037
1153
  """
1038
- if not self._indicators or not isinstance(self._indicators, dict):
1154
+ if not self._factors or not isinstance(self._factors, dict):
1039
1155
  return self.warmup
1040
1156
  max_window = 0
1041
- for spec in self._indicators.values():
1157
+ for spec in self._factors.values():
1042
1158
  if callable(spec) or not isinstance(spec, (list, tuple)):
1043
1159
  continue
1044
1160
  # spec[1:] 中跳过所有前导字符串(列名),收集剩余整数
@@ -1051,6 +1167,293 @@ class Data():
1051
1167
  max_window = max(max_window, v)
1052
1168
  return max(max_window, self.warmup)
1053
1169
 
1170
+ # ── 因子缓存 ────────────────────────────────────────────────
1171
+ # 因子就是从行情算出来的「下一列」,所以跟行情一样纵向落盘。
1172
+ # 但目录**不按数据源/周期/复权分**(那三样是「行情」的口径),而是按**因子名**分:
1173
+ # 一个因子 = 一份独立的资产,可以单独查、单独发布、单独排名。
1174
+ #
1175
+ # datadir/factors/{因子名}/{symbol}.parquet (单列,索引同行情)
1176
+ #
1177
+ # 数据源 / 周期 / 复权 + 因子定义本身,一起压进文件的「配方指纹」——
1178
+ # 只要其中任一样变了,指纹就对不上,该票该因子整条重算并覆盖。
1179
+
1180
+ FACTOR_DIR = 'factors'
1181
+ FACTOR_META_KEY = b'qka_factor_meta'
1182
+ FACTOR_CACHE_VERSION = 1
1183
+ _UNSET = object()
1184
+
1185
+ def _factor_plan(self):
1186
+ """{因子名: (定义, 指纹)};只要有因子认不出配方就整体返回 None(这次不缓存)。
1187
+
1188
+ 认不出的典型是「取不到源码的可调用对象」(REPL 里现写的 lambda、
1189
+ 动态拼出来的函数)——它的指纹没法跨进程稳定,宁可每次现算,
1190
+ 也不能拿旧配方算出来的值冒充新值。
1191
+ """
1192
+ plan = getattr(self, '_factor_plan_cache', self._UNSET)
1193
+ if plan is not self._UNSET:
1194
+ return plan
1195
+
1196
+ plan = None
1197
+ if isinstance(self._factors, dict) and self._factors:
1198
+ built = {}
1199
+ for name, spec in self._factors.items():
1200
+ fp = self._factor_signature(name, spec)
1201
+ if fp is None:
1202
+ built = None
1203
+ break
1204
+ built[name] = (spec, fp)
1205
+ plan = built
1206
+ self._factor_plan_cache = plan
1207
+ return plan
1208
+
1209
+ def _factor_signature(self, name, spec):
1210
+ """单个因子的配方指纹(定义 + 数据口径);认不出返回 None。"""
1211
+ payload = self._spec_payload(spec)
1212
+ if payload is None:
1213
+ return None
1214
+ blob = json.dumps({
1215
+ 'v': self.FACTOR_CACHE_VERSION,
1216
+ 'name': name,
1217
+ 'spec': payload,
1218
+ 'source': self.source,
1219
+ 'period': self.period,
1220
+ 'adjust': self.adjust,
1221
+ }, sort_keys=True, ensure_ascii=False)
1222
+ return hashlib.sha1(blob.encode('utf-8')).hexdigest()
1223
+
1224
+ @staticmethod
1225
+ def _spec_payload(spec):
1226
+ """把一份因子定义转成可稳定序列化的结构;认不出返回 None。"""
1227
+ if callable(spec):
1228
+ src = _callable_source(spec)
1229
+ return None if src is None else {'fn': src}
1230
+ if isinstance(spec, (list, tuple)):
1231
+ if all(isinstance(x, (str, int, float, bool)) for x in spec):
1232
+ return list(spec)
1233
+ return None
1234
+ return None
1235
+
1236
+ def _factor_cache_path(self, name: str, symbol: str) -> Path:
1237
+ return self.datadir / self.FACTOR_DIR / name / f'{symbol}.parquet'
1238
+
1239
+ def _read_factor_cache(self, name, symbol, fingerprint):
1240
+ """读该票该因子的缓存序列;文件不在 / 指纹不符 / 读不出来 → None。
1241
+
1242
+ 不在这里判断「盖没盖住范围」——那是 `_recompute_target` 的事:
1243
+ 盖住了就一个字不算,只缺尾巴就只补尾巴。
1244
+ """
1245
+ path = self._factor_cache_path(name, symbol)
1246
+ if not path.exists():
1247
+ return None
1248
+ try:
1249
+ raw = (pq.read_schema(path).metadata or {}).get(self.FACTOR_META_KEY)
1250
+ if not raw or json.loads(raw.decode()).get('fingerprint') != fingerprint:
1251
+ return None
1252
+ df = pd.read_parquet(path)
1253
+ except Exception:
1254
+ return None
1255
+ if df.empty or name not in df.columns or not isinstance(df.index, pd.DatetimeIndex):
1256
+ return None
1257
+ return df[name]
1258
+
1259
+ def _write_factor_cache(self, name, symbol, fingerprint, series) -> None:
1260
+ """把该票该因子的值并进缓存文件。
1261
+
1262
+ 同一指纹下只增不改(老值按索引保留,新算的覆盖同日);指纹变了说明配方或
1263
+ 口径换了,整条重写——绝不把两套口径的值混在一个文件里。
1264
+ """
1265
+ if series is None or series.empty:
1266
+ return
1267
+ path = self._factor_cache_path(name, symbol)
1268
+ merged = pd.DataFrame({name: series})
1269
+ if path.exists():
1270
+ try:
1271
+ old_meta = (pq.read_schema(path).metadata or {}).get(self.FACTOR_META_KEY)
1272
+ same = bool(old_meta) and \
1273
+ json.loads(old_meta.decode()).get('fingerprint') == fingerprint
1274
+ old = pd.read_parquet(path)
1275
+ if same and name in old.columns:
1276
+ merged = pd.concat([old[[name]], merged])
1277
+ merged = merged[~merged.index.duplicated(keep='last')].sort_index()
1278
+ except Exception:
1279
+ pass
1280
+ path.parent.mkdir(parents=True, exist_ok=True)
1281
+ table = pa.Table.from_pandas(merged)
1282
+ meta = dict(table.schema.metadata or {})
1283
+ meta[self.FACTOR_META_KEY] = json.dumps({
1284
+ 'fingerprint': fingerprint,
1285
+ 'v': self.FACTOR_CACHE_VERSION,
1286
+ 'source': self.source,
1287
+ 'period': self.period,
1288
+ 'adjust': self.adjust,
1289
+ }, ensure_ascii=False).encode()
1290
+ pq.write_table(table.replace_schema_metadata(meta), path)
1291
+
1292
+ def _recompute_target(self, full, cached):
1293
+ """算出「这次要重算哪一段」。
1294
+
1295
+ - 没有缓存 → 整段算(返回 full)
1296
+ - 缓存全盖住 → 不用算(返回 None)
1297
+ - 只缺尾巴 → **只算尾巴**(往前留出预热窗口,滚动指标才准)
1298
+ - 前面也缺 → 整段算
1299
+ """
1300
+ if cached is None:
1301
+ return full
1302
+ if cached.index.min() <= full.index.min() and cached.index.max() >= full.index.max():
1303
+ return None
1304
+ if cached.index.min() > full.index.min():
1305
+ return full
1306
+ min_rows = self._min_rows_for_factors()
1307
+ if min_rows <= 0:
1308
+ # 窗口完全靠猜(lambda + 没声明 warmup)⇒ 不敢只补尾巴,整段重算
1309
+ return full
1310
+ margin = pd.offsets.BDay(min_rows * 2)
1311
+ tail = full.loc[cached.index.max() - margin:].copy()
1312
+ if len(tail) < min_rows:
1313
+ return full
1314
+ return tail
1315
+
1316
+ @staticmethod
1317
+ def _symbol_slice(result, symbol):
1318
+ """从宽表里取出该票的列、去掉 {symbol}| 前缀(缺失返回空表)。"""
1319
+ prefix = f'{symbol}|'
1320
+ cols = [c for c in result.columns if c.startswith(prefix)]
1321
+ sub = result[cols].copy()
1322
+ sub.columns = [c[len(prefix):] for c in cols]
1323
+ return sub
1324
+
1325
+ @staticmethod
1326
+ def _attach_slice(result, symbol, sub, names):
1327
+ for name in names:
1328
+ if name in sub.columns:
1329
+ result[f'{symbol}|{name}'] = sub[name]
1330
+
1331
+ def _qka_factor_names(self):
1332
+ """`self._factors` 里属于 qka 内置因子(α/β/夏普…)的列名,按声明顺序。"""
1333
+ if not isinstance(self._factors, dict):
1334
+ return []
1335
+ return [
1336
+ n for n, spec in self._factors.items()
1337
+ if isinstance(spec, (list, tuple)) and _is_qka_factor(spec[0])
1338
+ ]
1339
+
1340
+ def _order_columns(self, result, qka_names):
1341
+ """把列重排回重构前的顺序,纯粹为了不改变既有行为。
1342
+
1343
+ 重构前:每票的行情列 → returns → ta/自定义因子,逐票成组;然后是 benchmark,
1344
+ 最后是 qka 内置因子(**先按因子名、再按标的**)。
1345
+ 因子缓存把那两类因子都挪到帧尾贴上,顺序就散了。消费者虽然都按列名取值,
1346
+ 但没理由让它们替我们承担这个差异。
1347
+ """
1348
+ known = set(self.symbols)
1349
+ qka_cols = {f'{s}|{n}' for n in qka_names for s in self.symbols}
1350
+ groups, rest, tail = {}, [], []
1351
+ for col in result.columns:
1352
+ if col in qka_cols:
1353
+ tail.append(col)
1354
+ continue
1355
+ symbol = col.split('|', 1)[0] if '|' in col else None
1356
+ if symbol in known:
1357
+ groups.setdefault(symbol, []).append(col)
1358
+ else:
1359
+ rest.append(col)
1360
+ order = []
1361
+ for symbol in self.symbols:
1362
+ order.extend(groups.get(symbol, ()))
1363
+ order.extend(rest)
1364
+ order.extend(tail)
1365
+ return result[order]
1366
+
1367
+ def _factors_with_cache(self, result):
1368
+ """逐票把 ta / 自定义因子贴到 result 上:命中缓存直接用,没命中才现算并写回。
1369
+
1370
+ 只用于默认(非懒加载)路径。懒加载路径行为完全不变。
1371
+ """
1372
+ if not self._factors:
1373
+ return result
1374
+ # 没开因子缓存 → plan 强制为 None,走下面「现算不落盘」那条路
1375
+ plan = self._factor_plan() if self.factor_cache else None
1376
+ for symbol in self.symbols:
1377
+ sub = self._symbol_slice(result, symbol)
1378
+ if sub.empty:
1379
+ continue
1380
+
1381
+ # 模拟标的没有缓存文件;没开缓存 / 配方认不出也一样 —— 一律现算不落盘
1382
+ if plan is None or symbol in self._sim_specs:
1383
+ before = set(sub.columns)
1384
+ sub = self._apply_factors(sub)
1385
+ self._attach_slice(
1386
+ result, symbol, sub, [c for c in sub.columns if c not in before]
1387
+ )
1388
+ continue
1389
+
1390
+ names = [
1391
+ n for n, (spec, _fp) in plan.items()
1392
+ if not (isinstance(spec, (list, tuple)) and _is_qka_factor(spec[0]))
1393
+ ]
1394
+ for name in names:
1395
+ spec, fp = plan[name]
1396
+ cached = self._read_factor_cache(name, symbol, fp)
1397
+ target = self._recompute_target(sub, cached)
1398
+ if target is None:
1399
+ sub[name] = cached.reindex(sub.index)
1400
+ continue
1401
+ self._compute_factor_cols(target, only={name})
1402
+ if target is sub:
1403
+ series = sub[name]
1404
+ else:
1405
+ # 补算的尾巴头部自带滚动预热 NaN,用缓存里的老值兜住,
1406
+ # 不能拿 NaN 去覆盖本来好好的值。
1407
+ series = target[name].reindex(sub.index).combine_first(
1408
+ cached.reindex(sub.index)
1409
+ )
1410
+ sub[name] = series
1411
+ self._write_factor_cache(name, symbol, fp, series)
1412
+ self._attach_slice(result, symbol, sub, names)
1413
+ return result
1414
+
1415
+ def _qka_factors_with_cache(self, result):
1416
+ """逐票把 qka 内置因子(α/β/夏普…)贴上;开了因子缓存才吃缓存。需在 benchmark 之后调用。"""
1417
+ if not self.factor_cache:
1418
+ return self._apply_qka_factors(result)
1419
+ plan = self._factor_plan()
1420
+ names = []
1421
+ if plan is not None:
1422
+ names = [
1423
+ n for n, (spec, _fp) in plan.items()
1424
+ if isinstance(spec, (list, tuple)) and _is_qka_factor(spec[0])
1425
+ ]
1426
+ if not names:
1427
+ return self._apply_qka_factors(result)
1428
+
1429
+ for name in names:
1430
+ spec, fp = plan[name]
1431
+ # 按「先因子名、再标的」的顺序贴,保持与重构前一致
1432
+ for symbol in self.symbols:
1433
+ if f'{symbol}|returns' not in result.columns:
1434
+ continue
1435
+ col = f'{symbol}|{name}'
1436
+ if symbol in self._sim_specs:
1437
+ # 模拟标的没有缓存文件,直接算
1438
+ self._compute_qka_factor_cols(result, [(name, spec)], symbols=[symbol])
1439
+ continue
1440
+ cached = self._read_factor_cache(name, symbol, fp)
1441
+ target = self._recompute_target(result, cached)
1442
+ if target is None:
1443
+ result[col] = cached.reindex(result.index)
1444
+ continue
1445
+ self._compute_qka_factor_cols(target, [(name, spec)])
1446
+ if target is result:
1447
+ series = result[col]
1448
+ else:
1449
+ # 同上:补算尾巴头部的预热 NaN 用缓存老值兜住
1450
+ series = target[col].reindex(result.index).combine_first(
1451
+ cached.reindex(result.index)
1452
+ )
1453
+ result[col] = series
1454
+ self._write_factor_cache(name, symbol, fp, series)
1455
+ return result
1456
+
1054
1457
  def _get_from_baostock(
1055
1458
  self, symbol: str,
1056
1459
  start_date: str = '1990-01-01',
@@ -1,13 +1,13 @@
1
1
  """
2
2
  QKA 内置指标模块
3
3
 
4
- 提供滚动窗口的内置指标函数,供 Data.indicators 的 dispatch 使用。
4
+ 提供滚动窗口的内置指标函数,供 Data.factors 的 dispatch 使用。
5
5
 
6
6
  与 analysis.py 的分工:
7
7
  - analysis.py:接收原始 Series,返回单个标量(事后分析,如一次性 α/β/夏普)
8
8
  - indicator.py:接收 DataFrame + symbol,返回滚动 Series(回测指标,每 bar 一个值)
9
9
 
10
- 所有函数签名统一为 fn(df, symbol, *args),由 _apply_qka_indicators 调用。
10
+ 所有函数签名统一为 fn(df, symbol, *args),由 _apply_qka_factors 调用。
11
11
  """
12
12
 
13
13
  import numpy as np
@@ -123,7 +123,7 @@ from qka import Data, Strategy, Backtest
123
123
 
124
124
  data = Data(
125
125
  symbols=['000001.SZ'],
126
- indicators={
126
+ factors={
127
127
  'sma_5': ('ta.trend.sma_indicator', 'close', 5),
128
128
  'sma_20': ('ta.trend.sma_indicator', 'close', 20),
129
129
  },
@@ -1,10 +1,10 @@
1
1
  ## Data
2
2
 
3
- 数据获取和指标预计算。
3
+ 数据获取和因子预计算。
4
4
 
5
5
  ### 构造
6
6
 
7
- 创建一个数据对象,配置数据源、股票代码和预计算指标。此时数据尚未下载,调用 `get()` 时才真正获取。
7
+ 创建一个数据对象,配置数据源、股票代码和预计算因子。此时数据尚未下载,调用 `get()` 时才真正获取。
8
8
 
9
9
  ```python
10
10
  from qka import Data
@@ -14,7 +14,7 @@ data = Data(
14
14
  period='1d',
15
15
  adjust='qfq',
16
16
  benchmark=None,
17
- indicators=None,
17
+ factors=None,
18
18
  )
19
19
  ```
20
20
 
@@ -22,22 +22,23 @@ data = Data(
22
22
  |------|------|--------|------|
23
23
  | `symbols` | `list[str \| Simulate]` | `None` | 标的列表。元素可以是 A 股代码(**`代码.市场`**,如 `'000001.SZ'`、`'600000.SH'`;也兼容 `sz.000001` 旧写法),也可以是 `Simulate` 对象(模拟标的),两者可混排,见下方「模拟标的」 |
24
24
  | `period` | `str` | `'1d'` | 数据周期,当前仅支持 `'1d'` |
25
- | `adjust` | `str` | `'qfq'` | 复权方式:`'qfq'` 前复权,`'hfq'` 后复权,`'bfq'` 不复权 |
25
+ | `adjust` | `str` | `'qfq'` | 复权方式:`'qfq'` 前复权,`'hfq'` 后复权,`'bfq'` 不复权。**它同时决定缓存策略**,见下方「缓存口径」 |
26
26
  | `source` | `str` | `'tickflow'` | 数据源。`'tickflow'`(默认)行情覆盖全,**含 ETF / 可转债全历史**;`'baostock'` 提供估值等扩展字段(见 `extra_fields`);`'tdx'` 走本机「TQ 版」通达信客户端(仅 Windows,需客户端在运行),仅行情、无扩展字段,安装目录见下方「通达信数据源」 |
27
27
  | `benchmark` | `str` | `None` | 基准指数代码,如 `'000300.SH'`。下载后在 `get()` 结果中追加 `benchmark|returns` 列 |
28
- | `indicators` | `dict` | `None` | 预计算指标,见下方 |
28
+ | `factors` | `dict \| Callable` | `None` | 预计算指标/因子,见下方。**旧名 `indicators` 完全等价、保留兼容,两者只能传一个** |
29
29
  | `extra_fields` | `list[str]` | `None` | 扩展字段(选股/估值用)。**仅 `source='baostock'` 提供**;`tickflow` / `tdx` 源无这些列,传入会被静默忽略,见下方 |
30
- | `warmup` | `int` | `0` | 指标预热天数。回测/取数时自动多读 `warmup` 个交易日历史用于计算指标,使第 1 个交易日即拿到有效指标值(无需手写跳过前 N 根 bar)。仅用于计算,不增加回测 bar 数 |
30
+ | `warmup` | `int` | `0` | 因子预热天数。回测/取数时自动多读 `warmup` 个交易日历史用于计算因子,使第 1 个交易日即拿到有效值(无需手写跳过前 N 根 bar)。仅用于计算,不增加回测 bar 数 |
31
+ | `factor_cache` | `bool` | `False` | 是否把算出来的因子**纵向落盘**(默认关)。开了才读写 `datadir/factors/…`,见下方「因子缓存」 |
31
32
 
32
- > **warmup(指标预热)**:若指标/因子需要较长历史窗口(如动量排名需看前 200 天),设 `warmup=200`。
33
- > qka 会在 `[start_date, end_date]` 之前额外多读 `warmup` 天数据用于计算指标,但返回和回测仍严格从
34
- > `start_date` 起——策略第 1 个 bar 指标即有值,无需 `continue` 跳过前 N 根 bar。
33
+ > **warmup(因子预热)**:若因子需要较长历史窗口(如动量排名需看前 200 天),设 `warmup=200`。
34
+ > qka 会在 `[start_date, end_date]` 之前额外多读 `warmup` 天数据用于计算因子,但返回和回测仍严格从
35
+ > `start_date` 起——策略第 1 个 bar 因子即有值,无需 `continue` 跳过前 N 根 bar。
35
36
  >
36
37
  > ```python
37
38
  > data = Data(
38
39
  > symbols=['000001.SZ'],
39
- > indicators={'mom200': lambda df: df['close'].pct_change(200)},
40
- > warmup=200, # 自定义指标 qka 推断不出窗口,显式声明预热天数
40
+ > factors={'mom200': lambda df: df['close'].pct_change(200)},
41
+ > warmup=200, # 自定义因子 qka 推断不出窗口,显式声明预热天数
41
42
  > )
42
43
  > ```
43
44
 
@@ -45,7 +46,44 @@ data = Data(
45
46
 
46
47
  | 常驻列 | 公式 | 说明 |
47
48
  |------|------|------|
48
- | `returns` | `close.diff() / close.shift(1)` | 日收益率,始终存在,无需在 indicators 中声明 |
49
+ | `returns` | `close.diff() / close.shift(1)` | 日收益率,始终存在,无需在 factors 中声明 |
50
+
51
+ ### 缓存口径
52
+
53
+ 缓存按 `datadir/{source}/{period}/{adjust}/` 分目录存放,各复权方式互不干扰。**是否走增量,由复权方式决定**:
54
+
55
+ | 复权方式 | 缓存策略 | 原因 |
56
+ |------|------|------|
57
+ | `bfq` 不复权、`hfq` 后复权 | **增量**:只补缓存缺的那几天 | 历史是死的,不会被将来的分红送股改写 |
58
+ | `qfq` 前复权 | **不做增量**:需要新数据就整段重取、整段覆盖 | 前复权以「最新价」为锚,今后的分红送股会**往回改写整段历史** |
59
+
60
+ 前复权为什么不能增量:只往后补新棒,文件里就成了「前段旧口径 + 后段新口径」的拼接,缝上会凭空多出一根假 K 线(实测某股一次分红后整段等比缩 5.57%,拼接处算出 −4.84%,而真实是 +0.91%),而且这个错一旦写进缓存就不会自愈。
61
+
62
+ 代价:前复权下只要请求碰到缓存之外(最常见的是要看最新一根),就会重新下载整段历史;请求完全落在缓存内时,仍然直接读缓存。
63
+
64
+ ### 因子缓存
65
+
66
+ **默认关闭**,要用得显式开:`Data(..., factor_cache=True)`。
67
+
68
+ 开启后,算出来的因子会像行情一样**纵向落盘**,但目录**按因子名分**(不按数据源/周期/复权):
69
+
70
+ ```
71
+ datadir/factors/{因子名}/{symbol}.parquet # 单列,日期索引
72
+ ```
73
+
74
+ 「因子名」= `factors` 字典里的 key(也就是策略里 `self.get('mom20')` 用的那个名字)。一个因子 = 一份独立的资产,可以单独查、单独发布、单独排名。
75
+
76
+ **什么时候用缓存、什么时候重算**:
77
+
78
+ | 情况 | 行为 |
79
+ |------|------|
80
+ | 缓存盖住了请求的日期范围,配方也没变 | **一个字都不算**,直接读 |
81
+ | 只缺后面的新数据 | **只补尾巴**(往前留出预热窗口,滚动指标才准),老值原样保留 |
82
+ | 前面也缺 / 缓存换了配方 / 换了数据源·周期·复权 | 该票该因子**整条重算**并覆盖 |
83
+
84
+ **配方指纹**:因子定义 + 数据源 + 周期 + 复权一起压成一个指纹写进文件。任一样变了指纹就对不上,自动重算——不会把两套口径的值混在一个文件里。可调用对象(lambda / 函数)取它的**源码文本**当指纹;取不到源码(REPL 里现写的、动态拼出来的)就**不缓存**,每次现算——宁可慢,也不冒"拿旧配方的值冒充新值"的风险。
85
+
86
+ **作用范围**:只作用于默认(`lazy=False`)路径;`lazy=True`(回测走的那条)行为不变。不传 `factor_cache` 时,一切跟没有这个功能一样:不读、不写、不建目录。
49
87
 
50
88
  ### 通达信数据源
51
89
 
@@ -112,15 +150,15 @@ r_t = drift + reversion * (logAnchor - logP_{t-1}) + vol * eps_t eps_t ~
112
150
  与真实标的的异同:
113
151
 
114
152
  - **日期轴**:取 `get(start_date, end_date)` 区间内的**真实交易日**(来自 baostock 交易日历,一日一根)。因此模拟数据同样需要能登录 baostock,且 `start_date=None` 时从 `Data.SIM_START`(`'2020-01-02'`)起算
115
- - **指标预热**:与真实源一致——有 `indicators` / `warmup` 时日期轴会自动向前扩展,返回结果仍严格限定在 `[start_date, end_date]`
116
- - **`indicators` / `extra_fields` / `warmup`**:全部照常生效,列名与真实源同样是 `{symbol}|{field}`,扩展列同样是「不请求就不生成」
153
+ - **因子预热**:与真实源一致——有 `factors` / `warmup` 时日期轴会自动向前扩展,返回结果仍严格限定在 `[start_date, end_date]`
154
+ - **`factors` / `extra_fields` / `warmup`**:全部照常生效,列名与真实源同样是 `{symbol}|{field}`,扩展列同样是「不请求就不生成」
117
155
  - **`benchmark`**:与标的是真是模拟无关——`Backtest.run(benchmark=...)` 照常生效,基准始终走真实数据源单独加载
118
156
  - **每次不同**:没有随机种子参数,每次调用都是新一批行情(不可复现)
119
157
  - **不落盘**:模拟标的不写缓存;`symbols` 里若全是模拟标的,连 `datadir` 都不会创建
120
158
 
121
159
  ### extra_fields 扩展字段
122
160
 
123
- `extra_fields` 追加扩展列(选股/估值用),列名同样遵循 `{symbol}|{field}` 约定(如 `000001.SZ|peTTM`),数值自动转为 `float64`。**不是指标,不参与 indicators 预计算**,是随行情一起下载的原始字段。
161
+ `extra_fields` 追加扩展列(选股/估值用),列名同样遵循 `{symbol}|{field}` 约定(如 `000001.SZ|peTTM`),数值自动转为 `float64`。**不是因子,不参与 factors 预计算**,是随行情一起下载的原始字段。
124
162
 
125
163
  > ⚠️ **仅 `source='baostock'` 提供**:这些扩展列来自 baostock 的 `query_history_k_data_plus` 接口。默认源 `'tickflow'` 与 `'tdx'` 的历史日线固定只有 OHLCV + 成交额,**没有**这些扩展列——此时传入的 `extra_fields` 会被静默忽略(不报错、也不生成对应列)。需要估值/换手率等字段时,显式用 `Data(..., source='baostock')`。
126
164
 
@@ -170,9 +208,11 @@ df['000001.SZ|peTTM'] # 市盈率(TTM),float64
170
208
  - 基准 `benchmark` 同样受 `extra_fields` 影响(同一套下载逻辑),但通常不需要
171
209
  - 数据源为 baostock 时 `extra_fields` 才生效;其他数据源忽略该参数
172
210
 
173
- ### indicators
211
+ ### factors
212
+
213
+ 在构造 `Data` 时传入,利用数据加载过程一次性预计算所有因子。**不能**在构造后补充。
174
214
 
175
- 在构造 `Data` 时传入,利用数据加载过程一次性预计算所有指标。**不能**在构造后补充。
215
+ > `factors` 的旧名为 `indicators`,两者完全等价、保留兼容,同时传会报错;新代码请一律用 `factors`。
176
216
 
177
217
  #### 内置指标
178
218
 
@@ -181,7 +221,7 @@ df['000001.SZ|peTTM'] # 市盈率(TTM),float64
181
221
  ```python
182
222
  data = Data(
183
223
  symbols=['000001.SZ'],
184
- indicators={
224
+ factors={
185
225
  'sma_5': ('ta.trend.sma_indicator', 'close', 5),
186
226
  'sma_20': ('ta.trend.sma_indicator', 'close', 20),
187
227
  'ema_20': ('ta.trend.ema_indicator', 'close', 20),
@@ -291,7 +331,7 @@ data = Data(
291
331
  非 ta 库的自定义因子用 callable:
292
332
 
293
333
  ```python
294
- indicators={
334
+ factors={
295
335
  'ma5': lambda df: df['close'].rolling(5).mean(),
296
336
  }
297
337
  ```
@@ -306,7 +346,7 @@ qka 框架内置的滚动窗口指标,格式为 `{'列名': ('qka.函数名',
306
346
  data = Data(
307
347
  symbols=['000001.SZ', '600000.SH'],
308
348
  benchmark='000300.SH', # alpha/beta/information_ratio 必需
309
- indicators={
349
+ factors={
310
350
  'sma_20': ('ta.trend.sma_indicator', 'close', 20),
311
351
  'beta_60': ('qka.beta', 60),
312
352
  'alpha_60': ('qka.alpha', 60),
@@ -363,7 +403,7 @@ df = data.get(start_date='2024-01-01', end_date='2024-12-31')
363
403
  **返回的宽表示例:**
364
404
 
365
405
  ```python
366
- data = Data(symbols=['000001.SZ', '600000.SH'], indicators={
406
+ data = Data(symbols=['000001.SZ', '600000.SH'], factors={
367
407
  'sma_5': ('ta.trend.sma_indicator', 'close', 5),
368
408
  })
369
409
  df = data.get(start_date='2024-01-02', end_date='2024-01-05')
@@ -380,7 +420,7 @@ df = data.get(start_date='2024-01-02', end_date='2024-01-05')
380
420
  ```
381
421
 
382
422
  - 每只股票独占一组列,列前缀 = symbol
383
- - `returns` 列自动存在,无需在 indicators 中声明
423
+ - `returns` 列自动存在,无需在 factors 中声明
384
424
  - 前 4 行 SMA 为 NaN(窗口=5,不足)
385
425
  - 若设了 `benchmark='000300.SH'`,末尾多一列 `benchmark|returns`
386
426
 
@@ -43,7 +43,7 @@ class MyStrategy(Strategy):
43
43
  ```python
44
44
  close = self.get('close') # pd.Series,index=股票代码,value=当前价格
45
45
  high = self.get('high')
46
- sma5 = self.get('sma_5') # indicators 中定义的指标列
46
+ sma5 = self.get('sma_5') # factors 中定义的因子列
47
47
  ```
48
48
 
49
49
  | 属性 | 说明 |
@@ -84,7 +84,7 @@ qka 自动在 `[start_date, end_date]` 之前多读 `warmup` 天数据用于计
84
84
  首个交易日开始(`on_bar` 调用次数不变),指标自始有效:
85
85
 
86
86
  ```python
87
- data = Data(symbols=[...], indicators={...}, warmup=200) # 构造时声明预热天数
87
+ data = Data(symbols=[...], factors={...}, warmup=200) # 构造时声明预热天数
88
88
  ```
89
89
 
90
90
  > 注意:qka 只能从 ta 指标的整数参数自动推断预热窗口;自定义 lambda 指标推断不到,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes