qka 2.4.1.dev2__tar.gz → 2.4.2__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.4.1.dev2 → qka-2.4.2}/PKG-INFO +2 -1
  2. {qka-2.4.1.dev2 → qka-2.4.2}/pyproject.toml +1 -0
  3. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/data.py +156 -25
  4. {qka-2.4.1.dev2 → qka-2.4.2}/skills/qka/references/data.md +21 -0
  5. {qka-2.4.1.dev2 → qka-2.4.2}/.github/workflows/release.yml +0 -0
  6. {qka-2.4.1.dev2 → qka-2.4.2}/.gitignore +0 -0
  7. {qka-2.4.1.dev2 → qka-2.4.2}/.vscode/settings.json +0 -0
  8. {qka-2.4.1.dev2 → qka-2.4.2}/CHANGELOG.md +0 -0
  9. {qka-2.4.1.dev2 → qka-2.4.2}/LICENSE +0 -0
  10. {qka-2.4.1.dev2 → qka-2.4.2}/README.md +0 -0
  11. {qka-2.4.1.dev2 → qka-2.4.2}/qka/__init__.py +0 -0
  12. {qka-2.4.1.dev2 → qka-2.4.2}/qka/cli.py +0 -0
  13. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/__init__.py +0 -0
  14. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/accessor.py +0 -0
  15. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/analysis.py +0 -0
  16. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/backtest.py +0 -0
  17. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/broker.py +0 -0
  18. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/indicator.py +0 -0
  19. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/report.py +0 -0
  20. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/simulate.py +0 -0
  21. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/sizing.py +0 -0
  22. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/strategy.py +0 -0
  23. {qka-2.4.1.dev2 → qka-2.4.2}/qka/core/tdx.py +0 -0
  24. {qka-2.4.1.dev2 → qka-2.4.2}/qka/utils/__init__.py +0 -0
  25. {qka-2.4.1.dev2 → qka-2.4.2}/qka/utils/logger.py +0 -0
  26. {qka-2.4.1.dev2 → qka-2.4.2}/skills/qka/SKILL.md +0 -0
  27. {qka-2.4.1.dev2 → qka-2.4.2}/skills/qka/references/analysis.md +0 -0
  28. {qka-2.4.1.dev2 → qka-2.4.2}/skills/qka/references/backtest.md +0 -0
  29. {qka-2.4.1.dev2 → qka-2.4.2}/skills/qka/references/broker.md +0 -0
  30. {qka-2.4.1.dev2 → qka-2.4.2}/skills/qka/references/sizing.md +0 -0
  31. {qka-2.4.1.dev2 → qka-2.4.2}/skills/qka/references/strategy.md +0 -0
  32. {qka-2.4.1.dev2 → qka-2.4.2}/tools/generate_api_ref.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qka
3
- Version: 2.4.1.dev2
3
+ Version: 2.4.2
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
@@ -38,6 +38,7 @@ Requires-Dist: baostock>=0.9.1
38
38
  Requires-Dist: dask[dataframe]>=2025.7.0
39
39
  Requires-Dist: plotly>=6.1.1
40
40
  Requires-Dist: pyarrow>=21.0.0
41
+ Requires-Dist: requests>=2.31.0
41
42
  Requires-Dist: statsmodels>=0.14.6
42
43
  Requires-Dist: ta>=0.11.0
43
44
  Requires-Dist: tickflow>=0.1.25
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "baostock>=0.9.1",
27
27
  "tickflow>=0.1.25",
28
28
  "statsmodels>=0.14.6",
29
+ "requests>=2.31.0",
29
30
  ]
30
31
 
31
32
  [project.optional-dependencies]
@@ -153,6 +153,7 @@ class Data():
153
153
  extra_fields: Optional[List[str]] = None,
154
154
  warmup: int = 0,
155
155
  factor_cache: bool = False,
156
+ fill_suspended: bool = False,
156
157
  ):
157
158
  """
158
159
  初始化数据对象
@@ -229,6 +230,13 @@ class Data():
229
230
  详见 `skills/qka/references/data.md` 的「因子缓存」。
230
231
  只作用于默认(`lazy=False`)路径。
231
232
 
233
+ fill_suspended: 是否把**停牌日按前收补齐**(默认 False)。
234
+ 各标的只在自己的交易日有 K 线,对齐到同一张宽表后停牌日就是空缺;
235
+ 滚动类因子(α/β/波动率……)默认要求窗口内一天都不能缺,**停过一天牌
236
+ 就会整条算不出来**。开启后:价格四列取前一交易日的值(当天收益率
237
+ 变成 0),`returns` 按补齐后的 close 重算;**上市前的空缺仍然留空**。
238
+ 只作用于默认(`lazy=False`)路径。
239
+
232
240
  """
233
241
  # ── 归一化 symbols:str = 真实标的,Simulate 对象 = 模拟标的,可混排 ──
234
242
  names, sim_specs = [], {}
@@ -261,6 +269,8 @@ class Data():
261
269
  self.warmup = warmup
262
270
  # 因子缓存:默认关。要用得显式开(见 _factors_with_cache)
263
271
  self.factor_cache = bool(factor_cache)
272
+ # 停牌日按前收填充:默认关。见 _fill_suspended_prices
273
+ self.fill_suspended = bool(fill_suspended)
264
274
 
265
275
  # 当前数据源实际支持的扩展字段(源能力不同,按源过滤)
266
276
  whitelist = self._extra_fields_whitelist(source)
@@ -935,6 +945,10 @@ class Data():
935
945
  ddf = dd.concat(dfs, axis=1, join='outer')
936
946
  result = ddf.compute()
937
947
 
948
+ # 停牌日按前收补齐(默认关,见 _fill_suspended_prices)。必须在算因子之前 ——
949
+ # 否则因子看到的还是带空缺的序列,长窗口照样算不出来。
950
+ result = self._fill_suspended_prices(result)
951
+
938
952
  # 因子:逐票逐因子贴上(带缓存 —— 全盖住就不算,只缺尾巴就只补尾巴)
939
953
  # 注意在切片之前算:此时还带着预热数据,滚动窗口才够长。
940
954
  result = self._factors_with_cache(result)
@@ -1182,6 +1196,59 @@ class Data():
1182
1196
  FACTOR_CACHE_VERSION = 1
1183
1197
  _UNSET = object()
1184
1198
 
1199
+ # 停牌日要按前收填充的列(价格类)。停牌当天没有成交,价格就跟前一天一样;
1200
+ # volume / amount 不填 —— 那天确实没有成交量,硬填反而不诚实。
1201
+ SUSPENDED_FILL_COLUMNS = ('open', 'high', 'low', 'close')
1202
+
1203
+ def _fill_suspended_prices(self, result):
1204
+ """把停牌日按「前收」补齐:当天价格 = 前一个交易日的收盘价。
1205
+
1206
+ 为什么需要:各标的只在自己的交易日有 K 线,对齐到同一张宽表后,**停牌日就是空缺**。
1207
+ 而滚动类因子(α/β/波动率……)默认要求窗口内一天都不能缺 —— 只要这只票在过去 N 天里
1208
+ 停过一天牌,指标就整条算不出来。
1209
+
1210
+ 填充口径:价格四列取前一交易日的值(当天收益率因此为 0,等价于「那天没交易」);
1211
+ `returns` 按填充后的 close 重算。**上市前的空缺没有前值,仍然留空** ——
1212
+ 不会凭空造出一段历史。
1213
+
1214
+ 只作用于默认(`lazy=False`)路径,由 `fill_suspended=True` 显式开启。
1215
+ """
1216
+ if not self.fill_suspended:
1217
+ return result
1218
+ col_index = self._columns_by_symbol(result.columns)
1219
+ replacements = {}
1220
+ for symbol in self.symbols:
1221
+ sym_cols = col_index.get(symbol)
1222
+ if not sym_cols:
1223
+ continue
1224
+ cols = [
1225
+ c for c in sym_cols
1226
+ if c.split('|', 1)[1] in self.SUSPENDED_FILL_COLUMNS
1227
+ ]
1228
+ if not cols:
1229
+ continue
1230
+ before = result[cols]
1231
+ filled = before.ffill()
1232
+ if filled.equals(before):
1233
+ continue # 这只票一次没停过牌,什么都别动(省掉重算 returns)
1234
+ for c in cols:
1235
+ replacements[c] = filled[c]
1236
+ if f'{symbol}|close' in cols:
1237
+ # 口径与 get() 里一致:diff/shift,不用 pct_change(它的 fill_method 默认值在弃用中)
1238
+ close = filled[f'{symbol}|close']
1239
+ replacements[f'{symbol}|returns'] = close.diff() / close.shift(1)
1240
+ if not replacements:
1241
+ return result
1242
+ # 一次性换掉,而不是每票各写两遍(全市场就是上万次逐列插入)。
1243
+ # 拼回后按原列序重排,保证列顺序一点不变。
1244
+ names = list(replacements)
1245
+ merged = pd.concat(
1246
+ [result.drop(columns=names),
1247
+ pd.DataFrame(replacements, index=result.index)],
1248
+ axis=1,
1249
+ )
1250
+ return merged[result.columns]
1251
+
1185
1252
  def _factor_plan(self):
1186
1253
  """{因子名: (定义, 指纹)};只要有因子认不出配方就整体返回 None(这次不缓存)。
1187
1254
 
@@ -1207,7 +1274,11 @@ class Data():
1207
1274
  return plan
1208
1275
 
1209
1276
  def _factor_signature(self, name, spec):
1210
- """单个因子的配方指纹(定义 + 数据口径);认不出返回 None。"""
1277
+ """单个因子的配方指纹(定义 + 数据口径);认不出返回 None。
1278
+
1279
+ 数据口径 = 数据源 / 周期 / 复权 / **停牌是否补齐** —— 任一样变了,
1280
+ 算出来的值就不一样,指纹必须跟着变,否则缓存会拿旧口径的值冒充新值。
1281
+ """
1211
1282
  payload = self._spec_payload(spec)
1212
1283
  if payload is None:
1213
1284
  return None
@@ -1218,6 +1289,7 @@ class Data():
1218
1289
  'source': self.source,
1219
1290
  'period': self.period,
1220
1291
  'adjust': self.adjust,
1292
+ 'fill_suspended': self.fill_suspended,
1221
1293
  }, sort_keys=True, ensure_ascii=False)
1222
1294
  return hashlib.sha1(blob.encode('utf-8')).hexdigest()
1223
1295
 
@@ -1314,19 +1386,56 @@ class Data():
1314
1386
  return tail
1315
1387
 
1316
1388
  @staticmethod
1317
- def _symbol_slice(result, symbol):
1318
- """从宽表里取出该票的列、去掉 {symbol}| 前缀(缺失返回空表)。"""
1389
+ def _columns_by_symbol(columns):
1390
+ """一次遍历建「票 → 它有哪些列」的索引。
1391
+
1392
+ 全市场宽表 6 万列,而下面几处都是按票走的:若每票都重新扫一遍全部列名,
1393
+ 就是 7556 × 6 万次字符串比较 —— 光这一项就足以把整轮跑批拖垮。
1394
+ """
1395
+ groups = {}
1396
+ for col in columns:
1397
+ if '|' in col:
1398
+ groups.setdefault(col.split('|', 1)[0], []).append(col)
1399
+ return groups
1400
+
1401
+ @staticmethod
1402
+ def _symbol_slice(result, symbol, cols=None):
1403
+ """从宽表里取出该票的列、去掉 {symbol}| 前缀(缺失返回空表)。
1404
+
1405
+ `cols` 传 ``_columns_by_symbol`` 里该票那一份,省掉一次全表扫描。
1406
+ """
1319
1407
  prefix = f'{symbol}|'
1320
- cols = [c for c in result.columns if c.startswith(prefix)]
1408
+ if cols is None:
1409
+ cols = [c for c in result.columns if c.startswith(prefix)]
1321
1410
  sub = result[cols].copy()
1322
1411
  sub.columns = [c[len(prefix):] for c in cols]
1323
1412
  return sub
1324
1413
 
1325
1414
  @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]
1415
+ def _collect_slice(symbol, sub, names):
1416
+ """把该票新算出来的列收进 ``{列名: 序列}``,交给调用方最后一次性拼上。
1417
+
1418
+ 逐列写进结果表会让 pandas 在内存里碎成几万块(每插一列就可能多出一个
1419
+ block),此后每次读写都得逐个遍历这些块。收集起来一次 concat,块数就稳住。
1420
+ """
1421
+ prefix = f'{symbol}|'
1422
+ return {
1423
+ f'{prefix}{name}': sub[name]
1424
+ for name in names
1425
+ if name in sub.columns
1426
+ }
1427
+
1428
+ @staticmethod
1429
+ def _attach_columns(result, collected):
1430
+ """把收集到的列**一次**拼上。
1431
+
1432
+ 这是整套流程里唯一「往结果表加列」的动作 —— 一次 concat 取代原先的
1433
+ 几万次逐列插入。
1434
+ """
1435
+ if not collected:
1436
+ return result
1437
+ new = pd.DataFrame(collected, index=result.index)
1438
+ return pd.concat([result, new], axis=1)
1330
1439
 
1331
1440
  def _qka_factor_names(self):
1332
1441
  """`self._factors` 里属于 qka 内置因子(α/β/夏普…)的列名,按声明顺序。"""
@@ -1365,16 +1474,22 @@ class Data():
1365
1474
  return result[order]
1366
1475
 
1367
1476
  def _factors_with_cache(self, result):
1368
- """逐票把 ta / 自定义因子贴到 result 上:命中缓存直接用,没命中才现算并写回。
1477
+ """逐票把 ta / 自定义因子算出来:命中缓存直接用,没命中才现算并写回。
1369
1478
 
1370
1479
  只用于默认(非懒加载)路径。懒加载路径行为完全不变。
1480
+ 新列先收在 collected 里,循环结束一次拼上(见 `_attach_columns`)。
1371
1481
  """
1372
1482
  if not self._factors:
1373
1483
  return result
1374
1484
  # 没开因子缓存 → plan 强制为 None,走下面「现算不落盘」那条路
1375
1485
  plan = self._factor_plan() if self.factor_cache else None
1486
+ col_index = self._columns_by_symbol(result.columns)
1487
+ collected = {}
1376
1488
  for symbol in self.symbols:
1377
- sub = self._symbol_slice(result, symbol)
1489
+ cols = col_index.get(symbol)
1490
+ if not cols:
1491
+ continue
1492
+ sub = self._symbol_slice(result, symbol, cols)
1378
1493
  if sub.empty:
1379
1494
  continue
1380
1495
 
@@ -1382,9 +1497,9 @@ class Data():
1382
1497
  if plan is None or symbol in self._sim_specs:
1383
1498
  before = set(sub.columns)
1384
1499
  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
- )
1500
+ collected.update(self._collect_slice(
1501
+ symbol, sub, [c for c in sub.columns if c not in before]
1502
+ ))
1388
1503
  continue
1389
1504
 
1390
1505
  names = [
@@ -1409,11 +1524,16 @@ class Data():
1409
1524
  )
1410
1525
  sub[name] = series
1411
1526
  self._write_factor_cache(name, symbol, fp, series)
1412
- self._attach_slice(result, symbol, sub, names)
1413
- return result
1527
+ collected.update(self._collect_slice(symbol, sub, names))
1528
+ return self._attach_columns(result, collected)
1414
1529
 
1415
1530
  def _qka_factors_with_cache(self, result):
1416
- """逐票把 qka 内置因子(α/β/夏普…)贴上;开了因子缓存才吃缓存。需在 benchmark 之后调用。"""
1531
+ """逐票把 qka 内置因子(α/β/夏普…)算出来;开了因子缓存才吃缓存。需在 benchmark 之后调用。
1532
+
1533
+ 与 `_factors_with_cache` 一样:新列先收集、最后一次性拼上。另外这里不再
1534
+ 拿整张宽表当计算台 —— 内置因子只看该票的 returns 加基准,给它一张只含
1535
+ 这几列的小表就够了(宽表 6 万列,逐票拿它来算太贵)。
1536
+ """
1417
1537
  if not self.factor_cache:
1418
1538
  return self._apply_qka_factors(result)
1419
1539
  plan = self._factor_plan()
@@ -1426,33 +1546,44 @@ class Data():
1426
1546
  if not names:
1427
1547
  return self._apply_qka_factors(result)
1428
1548
 
1549
+ col_index = self._columns_by_symbol(result.columns)
1550
+ bench_cols = col_index.get('benchmark', [])
1551
+ collected = {}
1429
1552
  for name in names:
1430
1553
  spec, fp = plan[name]
1431
1554
  # 按「先因子名、再标的」的顺序贴,保持与重构前一致
1432
1555
  for symbol in self.symbols:
1433
- if f'{symbol}|returns' not in result.columns:
1556
+ sym_cols = col_index.get(symbol)
1557
+ if not sym_cols or f'{symbol}|returns' not in result.columns:
1434
1558
  continue
1435
1559
  col = f'{symbol}|{name}'
1560
+ # 明确复制一份当计算台:内置因子要往它上面加列,而它是从宽表
1561
+ # 切出来的视图 —— 不复制的话 pandas 每次都会警告「在切片副本上赋值」。
1562
+ view = result[sym_cols + bench_cols].copy()
1563
+ if view.empty:
1564
+ continue
1436
1565
  if symbol in self._sim_specs:
1437
1566
  # 模拟标的没有缓存文件,直接算
1438
- self._compute_qka_factor_cols(result, [(name, spec)], symbols=[symbol])
1567
+ self._compute_qka_factor_cols(view, [(name, spec)], symbols=[symbol])
1568
+ collected[col] = view[col]
1439
1569
  continue
1440
1570
  cached = self._read_factor_cache(name, symbol, fp)
1441
- target = self._recompute_target(result, cached)
1571
+ target = self._recompute_target(view, cached)
1442
1572
  if target is None:
1443
- result[col] = cached.reindex(result.index)
1573
+ collected[col] = cached.reindex(result.index)
1444
1574
  continue
1445
- self._compute_qka_factor_cols(target, [(name, spec)])
1446
- if target is result:
1447
- series = result[col]
1575
+ # 这里给的是一张只有该票的小表,必须把标的限定成它自己
1576
+ self._compute_qka_factor_cols(target, [(name, spec)], symbols=[symbol])
1577
+ if target is view:
1578
+ series = view[col]
1448
1579
  else:
1449
1580
  # 同上:补算尾巴头部的预热 NaN 用缓存老值兜住
1450
1581
  series = target[col].reindex(result.index).combine_first(
1451
1582
  cached.reindex(result.index)
1452
1583
  )
1453
- result[col] = series
1584
+ collected[col] = series
1454
1585
  self._write_factor_cache(name, symbol, fp, series)
1455
- return result
1586
+ return self._attach_columns(result, collected)
1456
1587
 
1457
1588
  def _get_from_baostock(
1458
1589
  self, symbol: str,
@@ -29,6 +29,7 @@ data = Data(
29
29
  | `extra_fields` | `list[str]` | `None` | 扩展字段(选股/估值用)。**仅 `source='baostock'` 提供**;`tickflow` / `tdx` 源无这些列,传入会被静默忽略,见下方 |
30
30
  | `warmup` | `int` | `0` | 因子预热天数。回测/取数时自动多读 `warmup` 个交易日历史用于计算因子,使第 1 个交易日即拿到有效值(无需手写跳过前 N 根 bar)。仅用于计算,不增加回测 bar 数 |
31
31
  | `factor_cache` | `bool` | `False` | 是否把算出来的因子**纵向落盘**(默认关)。开了才读写 `datadir/factors/…`,见下方「因子缓存」 |
32
+ | `fill_suspended` | `bool` | `False` | 是否把**停牌日按前收补齐**(默认关)。滚动类因子要求窗口内一天不缺,停过牌就整条算不出来,见下方「停牌日」 |
32
33
 
33
34
  > **warmup(因子预热)**:若因子需要较长历史窗口(如动量排名需看前 200 天),设 `warmup=200`。
34
35
  > qka 会在 `[start_date, end_date]` 之前额外多读 `warmup` 天数据用于计算因子,但返回和回测仍严格从
@@ -61,6 +62,26 @@ data = Data(
61
62
 
62
63
  代价:前复权下只要请求碰到缓存之外(最常见的是要看最新一根),就会重新下载整段历史;请求完全落在缓存内时,仍然直接读缓存。
63
64
 
65
+ ### 停牌日
66
+
67
+ **默认关闭**,要用得显式开:`Data(..., fill_suspended=True)`。
68
+
69
+ 各标的只在自己的交易日有 K 线。对齐到同一张宽表(多标的)之后,**别人在交易、它停牌的那些天就是空缺**。
70
+
71
+ 问题在于:滚动类因子(α / β / 夏普 / 波动率……)内部是 `rolling(window)`,**默认要求窗口内一天都不能缺** ⇒ 只要这只票在过去 N 天里停过一天牌,这个因子就整条算不出来(哪怕它上市二十年)。
72
+
73
+ 开启后会做两件事:
74
+
75
+ - **价格四列**(open / high / low / close)取**前一交易日的值**——当天价格 = 前一天,收益率因此为 0,等价于「那天没交易」;
76
+ - `returns` 按补齐后的 close 重算。
77
+
78
+ 两点边界:
79
+
80
+ - **上市前的空缺不填**(前面没有值可取),仍然留空——不会凭空造出一段历史。
81
+ - `volume` / `amount` **不填**:停牌那天确实没有成交,硬填反而不诚实。所以量类因子仍要自己处理停牌。
82
+
83
+ 只作用于默认(`lazy=False`)路径。
84
+
64
85
  ### 因子缓存
65
86
 
66
87
  **默认关闭**,要用得显式开:`Data(..., factor_cache=True)`。
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
File without changes