qka 2.4.8.dev3__tar.gz → 2.5.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.4.8.dev3 → qka-2.5.1}/PKG-INFO +1 -1
  2. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/data.py +176 -48
  3. {qka-2.4.8.dev3 → qka-2.5.1}/.github/workflows/release.yml +0 -0
  4. {qka-2.4.8.dev3 → qka-2.5.1}/.gitignore +0 -0
  5. {qka-2.4.8.dev3 → qka-2.5.1}/.vscode/settings.json +0 -0
  6. {qka-2.4.8.dev3 → qka-2.5.1}/CHANGELOG.md +0 -0
  7. {qka-2.4.8.dev3 → qka-2.5.1}/LICENSE +0 -0
  8. {qka-2.4.8.dev3 → qka-2.5.1}/README.md +0 -0
  9. {qka-2.4.8.dev3 → qka-2.5.1}/pyproject.toml +0 -0
  10. {qka-2.4.8.dev3 → qka-2.5.1}/qka/__init__.py +0 -0
  11. {qka-2.4.8.dev3 → qka-2.5.1}/qka/cli.py +0 -0
  12. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/__init__.py +0 -0
  13. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/accessor.py +0 -0
  14. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/analysis.py +0 -0
  15. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/backtest.py +0 -0
  16. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/broker.py +0 -0
  17. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/indicator.py +0 -0
  18. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/report.py +0 -0
  19. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/simulate.py +0 -0
  20. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/sizing.py +0 -0
  21. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/strategy.py +0 -0
  22. {qka-2.4.8.dev3 → qka-2.5.1}/qka/core/tdx.py +0 -0
  23. {qka-2.4.8.dev3 → qka-2.5.1}/qka/utils/__init__.py +0 -0
  24. {qka-2.4.8.dev3 → qka-2.5.1}/qka/utils/logger.py +0 -0
  25. {qka-2.4.8.dev3 → qka-2.5.1}/skills/qka/SKILL.md +0 -0
  26. {qka-2.4.8.dev3 → qka-2.5.1}/skills/qka/references/analysis.md +0 -0
  27. {qka-2.4.8.dev3 → qka-2.5.1}/skills/qka/references/backtest.md +0 -0
  28. {qka-2.4.8.dev3 → qka-2.5.1}/skills/qka/references/broker.md +0 -0
  29. {qka-2.4.8.dev3 → qka-2.5.1}/skills/qka/references/data.md +0 -0
  30. {qka-2.4.8.dev3 → qka-2.5.1}/skills/qka/references/sizing.md +0 -0
  31. {qka-2.4.8.dev3 → qka-2.5.1}/skills/qka/references/strategy.md +0 -0
  32. {qka-2.4.8.dev3 → qka-2.5.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.4.8.dev3
3
+ Version: 2.5.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
@@ -372,6 +372,9 @@ class Data():
372
372
  self.drop_head = int(drop_head)
373
373
  # 因子缓存:默认关。要用得显式开(见 _factors_with_cache)
374
374
  self.factor_cache = bool(factor_cache)
375
+ # 因子现在写进行情文件本身(因子就是行情表的一列),所以一票只读写一次。
376
+ # {symbol: (DataFrame, {因子名: 元数据})} —— 一次取数流程内复用,不跨流程持久化。
377
+ self._quote_cache = {}
375
378
  # 停牌日按前收填充:默认关。见 _fill_suspended_prices
376
379
  self.fill_suspended = bool(fill_suspended)
377
380
  # 只读模式:一次网络请求都不发(见 _needs_download / get 的缓存校验)
@@ -749,6 +752,11 @@ class Data():
749
752
  combined = pd.concat(pieces)
750
753
  combined = combined[~combined.index.duplicated(keep='last')]
751
754
  combined = combined.sort_index()
755
+ # 🚨 因子列是派生数据,必须在合并后补回旧值:
756
+ # 增量取数会往前多要 5 天(见上面的 start_after),那些新行只有行情列。
757
+ # keep='last' 之后,重叠日期的因子值会被新行的空值覆盖 —— 不补的话,
758
+ # 每天更新都会悄悄抹掉最近几天的因子,而且完全看不出来。
759
+ combined = self._restore_factor_columns(combined, existing, path)
752
760
  table = pa.Table.from_pandas(combined)
753
761
  else:
754
762
  table = pa.Table.from_pandas(existing) # 数据没动,只是要更新「已确认起点」
@@ -1004,7 +1012,9 @@ class Data():
1004
1012
  if not parquet_path.exists():
1005
1013
  logger.warning(f"数据文件不存在,跳过: {parquet_path}")
1006
1014
  continue
1007
- ddf = dd.read_parquet(str(parquet_path), filters=pq_filters)
1015
+ ddf = self._strip_factor_cols(
1016
+ dd.read_parquet(str(parquet_path), filters=pq_filters),
1017
+ parquet_path)
1008
1018
  ddf['returns'] = ddf['close'].diff() / ddf['close'].shift(1)
1009
1019
  ddf = self._drop_head_lazy(ddf)
1010
1020
  ddf = self._apply_factors(ddf)
@@ -1045,7 +1055,9 @@ class Data():
1045
1055
  if not parquet_path.exists():
1046
1056
  logger.warning(f"数据文件不存在,跳过: {parquet_path}")
1047
1057
  continue
1048
- df = dd.read_parquet(str(parquet_path), filters=pq_filters)
1058
+ df = self._strip_factor_cols(
1059
+ dd.read_parquet(str(parquet_path), filters=pq_filters),
1060
+ parquet_path)
1049
1061
  df['returns'] = df['close'].diff() / df['close'].shift(1)
1050
1062
  column_mapping = {col: f'{symbol}|{col}' for col in df.columns}
1051
1063
  dfs.append(df.rename(columns=column_mapping))
@@ -1495,6 +1507,21 @@ class Data():
1495
1507
  'last': str(s.index[-1].date()),
1496
1508
  }
1497
1509
 
1510
+ def _factor_uses_rf(self, spec) -> bool:
1511
+ """这个因子定义对应的指标是否吃 `rf`。
1512
+
1513
+ 只有 α / 夏普吃。把无风险利率的摘要塞进所有因子的指纹会让改一次 rf
1514
+ 就作废全部 32 个因子(其中 24 个跟 rf 毫无关系),白白多算四倍。
1515
+ """
1516
+ if not isinstance(spec, (list, tuple)) or not spec:
1517
+ return False
1518
+ name = spec[0]
1519
+ if not isinstance(name, str) or not _is_qka_factor(name):
1520
+ return False
1521
+ fn_name = name.split('.', 1)[1] if name.startswith('qka.') else name
1522
+ fn = getattr(indicator, fn_name, None)
1523
+ return fn is not None and _accepts_rf(fn)
1524
+
1498
1525
  def _factor_signature(self, name, spec):
1499
1526
  """单个因子的配方指纹(定义 + 数据口径);认不出返回 None。
1500
1527
 
@@ -1513,8 +1540,10 @@ class Data():
1513
1540
  'adjust': self.adjust,
1514
1541
  'fill_suspended': self.fill_suspended,
1515
1542
  'drop_head': self.drop_head,
1516
- # 无风险利率变了,α / 夏普的值就变了 —— 必须让指纹跟着变
1517
- 'risk_free': self._risk_free_digest(),
1543
+ # 无风险利率变了,α / 夏普的值就变了 —— 只对这两个系列的因子
1544
+ # 让指纹跟着变,别把不相关的因子一起作废
1545
+ 'risk_free': (self._risk_free_digest()
1546
+ if self._factor_uses_rf(spec) else None),
1518
1547
  }, sort_keys=True, ensure_ascii=False)
1519
1548
  return hashlib.sha1(blob.encode('utf-8')).hexdigest()
1520
1549
 
@@ -1543,61 +1572,144 @@ class Data():
1543
1572
  return items
1544
1573
  return None
1545
1574
 
1546
- def _factor_cache_path(self, name: str, symbol: str) -> Path:
1547
- return self.datadir / self.FACTOR_DIR / name / f'{symbol}.parquet'
1575
+ def _strip_factor_cols(self, df, path):
1576
+ """从一只票的行情里剔掉「缓存的因子列」。
1548
1577
 
1549
- def _read_factor_cache(self, name, symbol, fingerprint):
1550
- """读该票该因子的缓存序列;文件不在 / 指纹不符 / 读不出来 → None。
1578
+ 因子现在存在行情文件里,读行情时会被一起读进来 —— 但那些是缓存产物,
1579
+ 不属于行情这一层。不剔的后果:
1580
+ ① 白读一遍用不上的列;
1581
+ ② 本次没请求的因子也会混进返回结果(缓存里有几个就带几个);
1582
+ ③ 后面拼装时会和新算出来的同名列撞上。
1551
1583
 
1552
- 不在这里判断「盖没盖住范围」——那是 `_recompute_target` 的事:
1553
- 盖住了就一个字不算,只缺尾巴就只补尾巴。
1584
+ 要剔的是「文件里登记过的**全部**因子列」,不是本次请求的那几个 ——
1585
+ 否则上轮缓存的因子会漏进结果。因子列名从文件元数据取(那是唯一可靠的
1586
+ 来源)。
1587
+
1588
+ dask 的 drop 是纯元数据操作,不会真去读数据。
1554
1589
  """
1555
- path = self._factor_cache_path(name, symbol)
1556
- if not path.exists():
1557
- return None
1590
+ drop = set()
1558
1591
  try:
1559
1592
  raw = (pq.read_schema(path).metadata or {}).get(self.FACTOR_META_KEY)
1560
- if not raw or json.loads(raw.decode()).get('fingerprint') != fingerprint:
1561
- return None
1593
+ metas = json.loads(raw.decode()) if raw else {}
1594
+ if isinstance(metas, dict) and 'fingerprint' not in metas:
1595
+ drop |= set(metas)
1596
+ except Exception:
1597
+ pass
1598
+ if isinstance(self._factors, dict):
1599
+ drop |= set(self._factors)
1600
+ drop = [c for c in df.columns if c in drop]
1601
+ return df.drop(columns=drop) if drop else df
1602
+
1603
+ def _restore_factor_columns(self, combined, existing, path):
1604
+ """把因子列(派生数据)的旧值补回增量合并结果。
1605
+
1606
+ 「哪些列是因子」以文件元数据里登记的为准 —— 靠列名猜不准,而元数据是
1607
+ 写因子列时一起写进去的,是唯一可靠来源。
1608
+ """
1609
+ try:
1610
+ raw = (pq.read_schema(path).metadata or {}).get(self.FACTOR_META_KEY)
1611
+ metas = json.loads(raw.decode()) if raw else {}
1612
+ except Exception:
1613
+ return combined
1614
+ if not isinstance(metas, dict) or not metas:
1615
+ return combined
1616
+ cols = [c for c in metas if c in existing.columns and c in combined.columns]
1617
+ if not cols:
1618
+ return combined
1619
+ combined[cols] = combined[cols].combine_first(
1620
+ existing[cols].reindex(combined.index))
1621
+ return combined
1622
+
1623
+ def _quote_cache_path(self, symbol: str) -> Path:
1624
+ """行情文件路径 —— 因子现在也写在这里(因子就是行情表的一列)。"""
1625
+ return self.target_dir / f'{symbol}.parquet'
1626
+
1627
+ def _load_quote_cached(self, symbol: str):
1628
+ """读该票的行情文件(含已有因子列),一次取数流程内只读一次。
1629
+
1630
+ 因子按列存在行情文件里,所以「读因子缓存」和「读行情」是同一个动作。
1631
+ 逐票的读写次数从「每因子一次」降到「每票一次」。
1632
+
1633
+ Returns:
1634
+ (DataFrame 或 None, {因子名: 元数据})
1635
+ """
1636
+ hit = self._quote_cache.get(symbol)
1637
+ if hit is not None:
1638
+ return hit
1639
+
1640
+ path = self._quote_cache_path(symbol)
1641
+ if not path.exists():
1642
+ self._quote_cache[symbol] = (None, {})
1643
+ return None, {}
1644
+
1645
+ try:
1562
1646
  df = pd.read_parquet(path)
1647
+ raw = (pq.read_schema(path).metadata or {}).get(self.FACTOR_META_KEY)
1648
+ metas = json.loads(raw.decode()) if raw else {}
1649
+ # 老格式是整个文件记一个 fingerprint(不区分列)。这种记录按列用不了,
1650
+ # 视作「该票没有可用的因子记录」,那些因子会按新口径重算一次。
1651
+ if not isinstance(metas, dict) or 'fingerprint' in metas:
1652
+ metas = {}
1563
1653
  except Exception:
1654
+ self._quote_cache[symbol] = (None, {})
1655
+ return None, {}
1656
+
1657
+ if not isinstance(df.index, pd.DatetimeIndex):
1658
+ self._quote_cache[symbol] = (None, {})
1659
+ return None, {}
1660
+
1661
+ self._quote_cache[symbol] = (df, metas)
1662
+ return df, metas
1663
+
1664
+ def _read_factor_cache(self, name, symbol, fingerprint):
1665
+ """从行情文件里取该因子那一列;列不在 / 指纹不符 / 读不出来 → None。
1666
+
1667
+ 不在这里判断「盖没盖住范围」——那是 `_recompute_target` 的事:
1668
+ 盖住了就一个字不算,只缺尾巴就只补尾巴。
1669
+ """
1670
+ df, metas = self._load_quote_cached(symbol)
1671
+ if df is None or name not in df.columns:
1564
1672
  return None
1565
- if df.empty or name not in df.columns or not isinstance(df.index, pd.DatetimeIndex):
1673
+ if (metas.get(name) or {}).get('fp') != fingerprint:
1566
1674
  return None
1567
1675
  return df[name]
1568
1676
 
1569
- def _write_factor_cache(self, name, symbol, fingerprint, series) -> None:
1570
- """把该票该因子的值并进缓存文件。
1677
+ def _flush_factor_columns(self, pending) -> None:
1678
+ """把这一轮新算出来的因子列并回行情文件,**每票只写一次**。
1679
+
1680
+ pending = {symbol: {因子名: (序列, 指纹)}}
1571
1681
 
1572
- 同一指纹下只增不改(老值按索引保留,新算的覆盖同日);指纹变了说明配方或
1573
- 口径换了,整条重写——绝不把两套口径的值混在一个文件里。
1682
+ 必须攒够再写:parquet 不能原地加列,每次写都是整文件重写。若沿用旧的
1683
+ 「算一个因子写一次」,32 个因子就要把同一只票的文件读写 32 遍 ——
1684
+ 7819 只票累计几百 GB 的无效 IO。
1574
1685
  """
1575
- if series is None or series.empty:
1576
- return
1577
- path = self._factor_cache_path(name, symbol)
1578
- merged = pd.DataFrame({name: series})
1579
- if path.exists():
1580
- try:
1581
- old_meta = (pq.read_schema(path).metadata or {}).get(self.FACTOR_META_KEY)
1582
- same = bool(old_meta) and \
1583
- json.loads(old_meta.decode()).get('fingerprint') == fingerprint
1584
- old = pd.read_parquet(path)
1585
- if same and name in old.columns:
1586
- merged = pd.concat([old[[name]], merged])
1587
- merged = merged[~merged.index.duplicated(keep='last')].sort_index()
1588
- except Exception:
1589
- pass
1590
- path.parent.mkdir(parents=True, exist_ok=True)
1591
- table = pa.Table.from_pandas(merged)
1592
- meta = dict(table.schema.metadata or {})
1593
- meta[self.FACTOR_META_KEY] = json.dumps({
1594
- 'fingerprint': fingerprint,
1595
- 'v': self.FACTOR_CACHE_VERSION,
1596
- 'source': self.source,
1597
- 'period': self.period,
1598
- 'adjust': self.adjust,
1599
- }, ensure_ascii=False).encode()
1600
- pq.write_table(table.replace_schema_metadata(meta), path)
1686
+ for symbol, cols in pending.items():
1687
+ if not cols:
1688
+ continue
1689
+ df, metas = self._load_quote_cached(symbol)
1690
+ if df is None:
1691
+ continue
1692
+
1693
+ out = df.copy()
1694
+ new_metas = dict(metas)
1695
+ wrote = False
1696
+ for name, (series, fp) in cols.items():
1697
+ if series is None or series.empty:
1698
+ continue
1699
+ # series 覆盖的是「宽表的日期轴」,裁到该票自己的日期上再落盘
1700
+ out[name] = series.reindex(out.index)
1701
+ new_metas[name] = {'fp': fp, 'v': self.FACTOR_CACHE_VERSION}
1702
+ wrote = True
1703
+ if not wrote:
1704
+ continue
1705
+
1706
+ path = self._quote_cache_path(symbol)
1707
+ table = pa.Table.from_pandas(out)
1708
+ meta = dict(table.schema.metadata or {})
1709
+ meta[self.FACTOR_META_KEY] = json.dumps(
1710
+ new_metas, ensure_ascii=False).encode()
1711
+ pq.write_table(table.replace_schema_metadata(meta), path)
1712
+ self._quote_cache[symbol] = (out, new_metas)
1601
1713
 
1602
1714
  def _recompute_target(self, full, cached):
1603
1715
  """算出「这次要重算哪一段」。
@@ -1669,9 +1781,16 @@ class Data():
1669
1781
 
1670
1782
  这是整套流程里唯一「往结果表加列」的动作 —— 一次 concat 取代原先的
1671
1783
  几万次逐列插入。
1784
+
1785
+ 要先剔掉宽表里同名的列:因子存在行情文件里,拼宽表时它们会被当作行情
1786
+ 一起读进来;不剔就直接 concat,会拼出重复列,之后任何按列名取数据的
1787
+ 地方都会出问题(实测:加第二个因子时整个流程崩在索引对齐上)。
1672
1788
  """
1673
1789
  if not collected:
1674
1790
  return result
1791
+ overlap = [c for c in collected if c in result.columns]
1792
+ if overlap:
1793
+ result = result.drop(columns=overlap)
1675
1794
  new = pd.DataFrame(collected, index=result.index)
1676
1795
  return pd.concat([result, new], axis=1)
1677
1796
 
@@ -1723,6 +1842,8 @@ class Data():
1723
1842
  plan = self._factor_plan() if self.factor_cache else None
1724
1843
  col_index = self._columns_by_symbol(result.columns)
1725
1844
  collected = {}
1845
+ # {symbol: {因子名: (序列, 指纹)}} —— 攒够一轮再写盘,见 _flush_factor_columns
1846
+ pending = {}
1726
1847
  for symbol in self.symbols:
1727
1848
  cols = col_index.get(symbol)
1728
1849
  if not cols:
@@ -1744,6 +1865,7 @@ class Data():
1744
1865
  n for n, (spec, _fp) in plan.items()
1745
1866
  if not (isinstance(spec, (list, tuple)) and _is_qka_factor(spec[0]))
1746
1867
  ]
1868
+ pending_cols = {}
1747
1869
  for name in names:
1748
1870
  spec, fp = plan[name]
1749
1871
  cached = self._read_factor_cache(name, symbol, fp)
@@ -1761,8 +1883,11 @@ class Data():
1761
1883
  cached.reindex(sub.index)
1762
1884
  )
1763
1885
  sub[name] = series
1764
- self._write_factor_cache(name, symbol, fp, series)
1886
+ pending_cols[name] = (series, fp)
1887
+ if pending_cols:
1888
+ pending[symbol] = pending_cols
1765
1889
  collected.update(self._collect_slice(symbol, sub, names))
1890
+ self._flush_factor_columns(pending)
1766
1891
  return self._attach_columns(result, collected)
1767
1892
 
1768
1893
  def _qka_factors_with_cache(self, result):
@@ -1787,6 +1912,8 @@ class Data():
1787
1912
  col_index = self._columns_by_symbol(result.columns)
1788
1913
  bench_cols = col_index.get('benchmark', [])
1789
1914
  collected = {}
1915
+ # {symbol: {因子名: (序列, 指纹)}} —— 攒够一轮再写盘
1916
+ pending = {}
1790
1917
  for name in names:
1791
1918
  spec, fp = plan[name]
1792
1919
  # 按「先因子名、再标的」的顺序贴,保持与重构前一致
@@ -1820,7 +1947,8 @@ class Data():
1820
1947
  cached.reindex(result.index)
1821
1948
  )
1822
1949
  collected[col] = series
1823
- self._write_factor_cache(name, symbol, fp, series)
1950
+ pending.setdefault(symbol, {})[name] = (series, fp)
1951
+ self._flush_factor_columns(pending)
1824
1952
  return self._attach_columns(result, collected)
1825
1953
 
1826
1954
  def _get_from_baostock(
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
File without changes