qka 2.5.0__tar.gz → 2.5.1.dev3__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.
- {qka-2.5.0 → qka-2.5.1.dev3}/PKG-INFO +1 -1
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/data.py +176 -48
- {qka-2.5.0 → qka-2.5.1.dev3}/.github/workflows/release.yml +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/.gitignore +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/.vscode/settings.json +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/CHANGELOG.md +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/LICENSE +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/README.md +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/pyproject.toml +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/__init__.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/cli.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/__init__.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/accessor.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/analysis.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/backtest.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/broker.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/indicator.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/report.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/simulate.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/sizing.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/strategy.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/core/tdx.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/utils/__init__.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/qka/utils/logger.py +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/skills/qka/SKILL.md +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/skills/qka/references/analysis.md +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/skills/qka/references/backtest.md +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/skills/qka/references/broker.md +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/skills/qka/references/data.md +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/skills/qka/references/sizing.md +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/skills/qka/references/strategy.md +0 -0
- {qka-2.5.0 → qka-2.5.1.dev3}/tools/generate_api_ref.py +0 -0
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1547
|
-
|
|
1575
|
+
def _strip_factor_cols(self, df, path):
|
|
1576
|
+
"""从一只票的行情里剔掉「缓存的因子列」。
|
|
1548
1577
|
|
|
1549
|
-
|
|
1550
|
-
|
|
1578
|
+
因子现在存在行情文件里,读行情时会被一起读进来 —— 但那些是缓存产物,
|
|
1579
|
+
不属于行情这一层。不剔的后果:
|
|
1580
|
+
① 白读一遍用不上的列;
|
|
1581
|
+
② 本次没请求的因子也会混进返回结果(缓存里有几个就带几个);
|
|
1582
|
+
③ 后面拼装时会和新算出来的同名列撞上。
|
|
1551
1583
|
|
|
1552
|
-
|
|
1553
|
-
|
|
1584
|
+
要剔的是「文件里登记过的**全部**因子列」,不是本次请求的那几个 ——
|
|
1585
|
+
否则上轮缓存的因子会漏进结果。因子列名从文件元数据取(那是唯一可靠的
|
|
1586
|
+
来源)。
|
|
1587
|
+
|
|
1588
|
+
dask 的 drop 是纯元数据操作,不会真去读数据。
|
|
1554
1589
|
"""
|
|
1555
|
-
|
|
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
|
-
|
|
1561
|
-
|
|
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
|
|
1673
|
+
if (metas.get(name) or {}).get('fp') != fingerprint:
|
|
1566
1674
|
return None
|
|
1567
1675
|
return df[name]
|
|
1568
1676
|
|
|
1569
|
-
def
|
|
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
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|