qka 2.5.2.dev2__tar.gz → 2.5.3.dev2__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.5.2.dev2 → qka-2.5.3.dev2}/PKG-INFO +1 -1
  2. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/data.py +21 -18
  3. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/indicator.py +140 -22
  4. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/.github/workflows/release.yml +0 -0
  5. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/.gitignore +0 -0
  6. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/.vscode/settings.json +0 -0
  7. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/CHANGELOG.md +0 -0
  8. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/LICENSE +0 -0
  9. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/README.md +0 -0
  10. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/pyproject.toml +0 -0
  11. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/__init__.py +0 -0
  12. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/cli.py +0 -0
  13. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/__init__.py +0 -0
  14. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/accessor.py +0 -0
  15. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/analysis.py +0 -0
  16. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/backtest.py +0 -0
  17. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/broker.py +0 -0
  18. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/report.py +0 -0
  19. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/simulate.py +0 -0
  20. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/sizing.py +0 -0
  21. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/strategy.py +0 -0
  22. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/core/tdx.py +0 -0
  23. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/utils/__init__.py +0 -0
  24. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/qka/utils/logger.py +0 -0
  25. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/skills/qka/SKILL.md +0 -0
  26. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/skills/qka/references/analysis.md +0 -0
  27. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/skills/qka/references/backtest.md +0 -0
  28. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/skills/qka/references/broker.md +0 -0
  29. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/skills/qka/references/data.md +0 -0
  30. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/skills/qka/references/sizing.md +0 -0
  31. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/skills/qka/references/strategy.md +0 -0
  32. {qka-2.5.2.dev2 → qka-2.5.3.dev2}/tools/generate_api_ref.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: qka
3
- Version: 2.5.2.dev2
3
+ Version: 2.5.3.dev2
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
@@ -1375,8 +1375,8 @@ class Data():
1375
1375
  def _min_rows_for_factors(self):
1376
1376
  """计算所有因子所需的最小行数(含显式预热天数)。
1377
1377
 
1378
- 遍历所有因子条目的整数参数,取最大值作为窗口上限的保守估计,
1379
- 再与显式 warmup 取较大者。
1378
+ 遍历所有因子条目的窗口参数(整数按交易日数、'1Y' 这类按日历折算成行数),
1379
+ 取最大值作为窗口上限的保守估计,再与显式 warmup 取较大者。
1380
1380
  """
1381
1381
  if not self._factors or not isinstance(self._factors, dict):
1382
1382
  return self.warmup
@@ -1384,14 +1384,16 @@ class Data():
1384
1384
  for spec in self._factors.values():
1385
1385
  if callable(spec) or not isinstance(spec, (list, tuple)):
1386
1386
  continue
1387
- # spec[1:] 中跳过所有前导字符串(列名),收集剩余整数
1388
- rest = list(spec[1:])
1389
- idx = 0
1390
- while idx < len(rest) and isinstance(rest[idx], str):
1391
- idx += 1
1392
- for v in rest[idx:]:
1387
+ # 窗口可能是整数(交易日数),也可能是 '1Y' / '3M' / '20D' 这样的日历写法。
1388
+ # 字符串要按交易日折算成行数,否则「数据够不够算」的门槛会漏掉它。
1389
+ for v in list(spec[1:]):
1393
1390
  if isinstance(v, int):
1394
1391
  max_window = max(max_window, v)
1392
+ elif isinstance(v, str) and v.strip() and v.strip()[-1].upper() in 'YMD':
1393
+ try:
1394
+ max_window = max(max_window, indicator.window_to_rows(v))
1395
+ except ValueError:
1396
+ pass # 不是窗口写法(如列名 'close_d'),忽略
1395
1397
  return max(max_window, self.warmup)
1396
1398
 
1397
1399
  # ── 因子缓存 ────────────────────────────────────────────────
@@ -1767,21 +1769,22 @@ class Data():
1767
1769
 
1768
1770
  @staticmethod
1769
1771
  def _spec_window(spec) -> int:
1770
- """从因子定义里取窗口长度(最大的整数参数);取不到返回 0。
1772
+ """从因子定义里取窗口,**统一换算成行数**;取不到返回 0。
1771
1773
 
1772
- 口径与 `_min_rows_for_factors` 一致:跳过前导字符串(那些是列名),
1773
- 剩下的整数里取最大。`('qka.alpha', 250, {'max_abs_return': 0.5})` → 250。
1774
+ 整数原样;字符串('1Y' / '3M' / '20D')按交易日折算 —— 见
1775
+ `indicator.window_to_rows`。增量预热要用它算「往回留多少行」。
1774
1776
  """
1775
1777
  if not isinstance(spec, (list, tuple)):
1776
1778
  return 0
1777
- rest = list(spec[1:])
1778
- idx = 0
1779
- while idx < len(rest) and isinstance(rest[idx], str):
1780
- idx += 1
1781
1779
  win = 0
1782
- for v in rest[idx:]:
1783
- if isinstance(v, int) and v > win:
1784
- win = v
1780
+ for v in list(spec[1:]):
1781
+ if isinstance(v, int):
1782
+ win = max(win, v)
1783
+ elif isinstance(v, str) and v.strip() and v.strip()[-1].upper() in 'YMD':
1784
+ try:
1785
+ win = max(win, indicator.window_to_rows(v))
1786
+ except ValueError:
1787
+ pass # 不是窗口写法(如列名),忽略
1785
1788
  return win
1786
1789
 
1787
1790
  @staticmethod
@@ -17,6 +17,105 @@ from statsmodels.tsa.filters.hp_filter import hpfilter
17
17
  from qka.core.analysis import _zigzag_core
18
18
 
19
19
 
20
+ # ── 窗口参数:整数 = 行数,字符串 = 日历周期 ──────────────────────
21
+ # ('qka.alpha', 250) → 往前数 250 根 K 线(老写法,行为完全不变)
22
+ # ('qka.alpha', '1Y') → 过去一个日历年(约 243 个交易日,每年自动适配)
23
+ #
24
+ # 🚨 字符串**必须自己解析**,不能直接丢给 pandas:pandas 里小写 m 是「分钟」、
25
+ # 大写 M 是「月末」,`'12m'` 会被理解成 12 分钟还静默算出一堆垃圾。
26
+ # 规范写法大写(1Y / 3M / 20D),解析时大小写都认。
27
+ _WINDOW_UNITS = {'Y': 365.0, 'M': 30.0, 'D': 1.0}
28
+
29
+ # 一年按多少个交易日折算 —— 只用来定「窗口内至少要攒够几根 K 线」。
30
+ # 实测(600519 全历史,365 天窗口内行数):中位 243,最少 235,最多 250。
31
+ # 门槛取 233(比最少的 235 再低一点)—— 取 240 的话,遇上节假日特别密的年份
32
+ # 那几天会凑不满、因子留空,白丢数据。
33
+ _TRADING_DAYS_PER_YEAR = 233
34
+
35
+ # 真正折算「一个时间窗口里该有多少根 K 线」时用这个数(实际交易日的中位数)。
36
+ # 🚨 跟上面的门槛不是一回事:门槛是「够不够算」,这个数是「要留多少行当预热」。
37
+ # 用 233 去预热 10 年期会少 100 根样本,尾部算出来的值就是错的。
38
+ _TRADING_DAYS_ACTUAL = 243
39
+
40
+
41
+ def resolve_window(window):
42
+ """把窗口参数翻译成 (rolling 的窗口, 最小样本数)。
43
+
44
+ Args:
45
+ window: 整数(行数)或字符串('1Y' / '3M' / '20D',大小写都认)
46
+
47
+ Returns:
48
+ (win, min_periods)
49
+ - 整数:win 是 int,min_periods 等于它本身 —— 与旧行为逐位一致
50
+ - 字符串:win 是 pd.Timedelta,min_periods 是折合出来的交易日数
51
+
52
+ Raises:
53
+ ValueError: 写法认不出,或数值 <= 0
54
+ """
55
+ if isinstance(window, str):
56
+ text = window.strip().upper()
57
+ if not text:
58
+ raise ValueError('窗口参数不能为空字符串')
59
+ unit = text[-1]
60
+ if unit not in _WINDOW_UNITS:
61
+ raise ValueError(
62
+ f"认不出的窗口单位 {window!r}:只支持 Y(年)/ M(月)/ D(天),如 '1Y'、'3M'、'20D'"
63
+ )
64
+ try:
65
+ num = float(text[:-1])
66
+ except ValueError:
67
+ raise ValueError(f"认不出的窗口写法 {window!r}:应为「数字+单位」,如 '1Y'") from None
68
+ if num <= 0:
69
+ raise ValueError(f'窗口必须大于 0:{window!r}')
70
+ days = num * _WINDOW_UNITS[unit]
71
+ min_periods = max(1, int(round(days * _TRADING_DAYS_PER_YEAR / 365.0)))
72
+ return pd.Timedelta(days=days), min_periods
73
+
74
+ try:
75
+ win = int(window)
76
+ except (TypeError, ValueError):
77
+ raise ValueError(f'窗口参数只能是整数或字符串,收到 {window!r}') from None
78
+ if win <= 0:
79
+ raise ValueError(f'窗口必须大于 0:{window!r}')
80
+ return win, win
81
+
82
+
83
+ def window_to_rows(window) -> int:
84
+ """这个窗口总共跨多少个交易日 —— 用来判断「数据够不够」和「预热要留多少行」。
85
+
86
+ - 整数:原样返回
87
+ - 字符串:按**实际交易日**折算('1Y' → 243、'10Y' → 2430)。
88
+
89
+ 🚨 这里用实际交易日(243/年)而不是 `min_periods`(240/年):差的那 3 根在
90
+ 10 年期会放大成 30 根,增量预热按 2400 行回溯的话,尾部算出来的值会比全量算的少
91
+ 30 个样本 —— 结果不一致,而且不会报错。
92
+ """
93
+ win, _min_periods = resolve_window(window)
94
+ if isinstance(win, int):
95
+ return win
96
+ days = win / pd.Timedelta(days=1)
97
+ return int(np.ceil(days * _TRADING_DAYS_ACTUAL / 365.0))
98
+
99
+
100
+ def _window_starts(index, window):
101
+ """算出「每个位置对应的窗口起点行号」,供手写循环的指标使用。
102
+
103
+ Returns:
104
+ (starts, min_periods):starts[i] 是第 i 行的窗口起始行号;
105
+ 窗口还不够长时给 -1,调用方跳过即可。
106
+ """
107
+ n = len(index)
108
+ if isinstance(window, str):
109
+ win, min_periods = resolve_window(window)
110
+ left = index - win # DatetimeIndex - Timedelta
111
+ starts = np.asarray(index.searchsorted(left, side='left'), dtype=np.int64)
112
+ return starts, min_periods
113
+ w = int(window)
114
+ starts = np.arange(n, dtype=np.int64) - w + 1
115
+ starts[starts < 0] = -1
116
+ return starts, w
117
+
118
+
20
119
  def alpha(df: pd.DataFrame, symbol: str, window: int,
21
120
  rf: float | pd.Series = 0.0, periods_per_year: int = 252) -> pd.Series:
22
121
  """滚动詹森 α(年化)。
@@ -27,7 +126,8 @@ def alpha(df: pd.DataFrame, symbol: str, window: int,
27
126
  Args:
28
127
  df: 含 {symbol}|returns 和 benchmark|returns 列的 DataFrame
29
128
  symbol: 股票代码
30
- window: 滚动窗口(交易日数)
129
+ window: 滚动窗口。整数 = 交易日数(往前数多少根);
130
+ 字符串 = 日历周期,如 '1Y'(一个日历年)/ '3M' / '20D',大小写都认
31
131
  rf: 无风险利率(**年化**,小数)。两种写法:
32
132
  - 标量:全程同一个数,如 0.02
33
133
  - 序列(带日期索引):**取该窗口内的平均值**,见 _rf_window_mean
@@ -42,10 +142,11 @@ def alpha(df: pd.DataFrame, symbol: str, window: int,
42
142
  """
43
143
  returns = df[f'{symbol}|returns']
44
144
  bench = _get_benchmark(df)
45
- rp = returns.rolling(window).mean() * periods_per_year
46
- rm = bench.rolling(window).mean() * periods_per_year
47
- cov = returns.rolling(window).cov(bench)
48
- var = bench.rolling(window).var()
145
+ win, mp = resolve_window(window)
146
+ rp = returns.rolling(win, min_periods=mp).mean() * periods_per_year
147
+ rm = bench.rolling(win, min_periods=mp).mean() * periods_per_year
148
+ cov = returns.rolling(win, min_periods=mp).cov(bench)
149
+ var = bench.rolling(win, min_periods=mp).var()
49
150
  beta_series = cov / var
50
151
  rf = _rf_window_mean(rf, df.index, window)
51
152
  return rp - (rf + beta_series * (rm - rf))
@@ -59,15 +160,17 @@ def beta(df: pd.DataFrame, symbol: str, window: int) -> pd.Series:
59
160
  Args:
60
161
  df: 含 {symbol}|returns 和 benchmark|returns 列的 DataFrame
61
162
  symbol: 股票代码
62
- window: 滚动窗口(交易日数)
163
+ window: 滚动窗口。整数 = 交易日数(往前数多少根);
164
+ 字符串 = 日历周期,如 '1Y'(一个日历年)/ '3M' / '20D',大小写都认
63
165
 
64
166
  Returns:
65
167
  pd.Series: 滚动 β 值
66
168
  """
67
169
  returns = df[f'{symbol}|returns']
68
170
  bench = _get_benchmark(df)
69
- cov = returns.rolling(window).cov(bench)
70
- var = bench.rolling(window).var()
171
+ win, mp = resolve_window(window)
172
+ cov = returns.rolling(win, min_periods=mp).cov(bench)
173
+ var = bench.rolling(win, min_periods=mp).var()
71
174
  return cov / var
72
175
 
73
176
 
@@ -81,7 +184,8 @@ def sharpe(df: pd.DataFrame, symbol: str, window: int,
81
184
  Args:
82
185
  df: 含 {symbol}|returns 列的 DataFrame
83
186
  symbol: 股票代码
84
- window: 滚动窗口(交易日数)
187
+ window: 滚动窗口。整数 = 交易日数(往前数多少根);
188
+ 字符串 = 日历周期,如 '1Y'(一个日历年)/ '3M' / '20D',大小写都认
85
189
  rf: 无风险利率(**年化**,小数)。标量 = 全程一个数;
86
190
  序列 = 取该窗口内的平均值(见 _rf_window_mean)
87
191
  periods_per_year: 每年交易日数,默认 252
@@ -90,8 +194,9 @@ def sharpe(df: pd.DataFrame, symbol: str, window: int,
90
194
  pd.Series: 滚动夏普比率
91
195
  """
92
196
  returns = df[f'{symbol}|returns']
93
- rp = returns.rolling(window).mean() * periods_per_year
94
- vol = returns.rolling(window).std() * np.sqrt(periods_per_year)
197
+ win, mp = resolve_window(window)
198
+ rp = returns.rolling(win, min_periods=mp).mean() * periods_per_year
199
+ vol = returns.rolling(win, min_periods=mp).std() * np.sqrt(periods_per_year)
95
200
  rf = _rf_window_mean(rf, df.index, window)
96
201
  return (rp - rf) / vol
97
202
 
@@ -104,7 +209,8 @@ def max_drawdown(df: pd.DataFrame, symbol: str, window: int) -> pd.Series:
104
209
  Args:
105
210
  df: 含 {symbol}|returns 列的 DataFrame
106
211
  symbol: 股票代码
107
- window: 滚动窗口(交易日数)
212
+ window: 滚动窗口。整数 = 交易日数(往前数多少根);
213
+ 字符串 = 日历周期,如 '1Y'(一个日历年)/ '3M' / '20D',大小写都认
108
214
 
109
215
  Returns:
110
216
  pd.Series: 滚动最大回撤(负数)
@@ -113,8 +219,13 @@ def max_drawdown(df: pd.DataFrame, symbol: str, window: int) -> pd.Series:
113
219
  cum = (1 + returns).cumprod()
114
220
  cum_values = cum.values
115
221
  result = np.full(len(cum_values), np.nan)
116
- for i in range(window - 1, len(cum_values)):
117
- seg = cum_values[i - window + 1 : i + 1]
222
+ # 窗口起点按参数类型算:整数按行数、字符串按日历回溯(见 _window_starts)
223
+ starts, min_periods = _window_starts(cum.index, window)
224
+ for i in range(len(cum_values)):
225
+ s = starts[i]
226
+ if s < 0 or (i - s + 1) < min_periods:
227
+ continue
228
+ seg = cum_values[s : i + 1]
118
229
  # 窗口内逐日刷新峰值,再取「任意时刻从峰到谷」最深的那一次。
119
230
  # 旧实现只算 (末值 - 窗口峰值) / 窗口峰值 —— 那是「当前回撤」(现在离最高点多远),
120
231
  # 会漏掉中间已经发生过的深坑:净值 1 → 1.2 → 0.6 → 1.3,旧算法给 0,
@@ -133,7 +244,8 @@ def information_ratio(df: pd.DataFrame, symbol: str, window: int,
133
244
  Args:
134
245
  df: 含 {symbol}|returns 和 benchmark|returns 列的 DataFrame
135
246
  symbol: 股票代码
136
- window: 滚动窗口(交易日数)
247
+ window: 滚动窗口。整数 = 交易日数(往前数多少根);
248
+ 字符串 = 日历周期,如 '1Y'(一个日历年)/ '3M' / '20D',大小写都认
137
249
  periods_per_year: 每年交易日数,默认 252
138
250
 
139
251
  Returns:
@@ -142,8 +254,9 @@ def information_ratio(df: pd.DataFrame, symbol: str, window: int,
142
254
  returns = df[f'{symbol}|returns']
143
255
  bench = _get_benchmark(df)
144
256
  excess = returns - bench
145
- mean_excess = excess.rolling(window).mean() * periods_per_year
146
- vol_excess = excess.rolling(window).std() * np.sqrt(periods_per_year)
257
+ win, mp = resolve_window(window)
258
+ mean_excess = excess.rolling(win, min_periods=mp).mean() * periods_per_year
259
+ vol_excess = excess.rolling(win, min_periods=mp).std() * np.sqrt(periods_per_year)
147
260
  return mean_excess / vol_excess
148
261
 
149
262
 
@@ -157,7 +270,8 @@ def rolling_zigzag(df: pd.DataFrame, symbol: str, window: int,
157
270
  Args:
158
271
  df: 含 {symbol}|close 列的 DataFrame
159
272
  symbol: 股票代码
160
- window: 滚动窗口(交易日数),至少 10
273
+ window: 滚动窗口。整数 = 交易日数(往前数多少根);
274
+ 字符串 = 日历周期,如 '1Y'(一个日历年)/ '3M' / '20D',大小写都认,至少 10
161
275
  threshold: 反转阈值,默认 0.3。峰顶回落 30% 确认顶
162
276
  lamb: HP 滤波平滑参数,默认 1600(日线数据标准值)
163
277
 
@@ -173,11 +287,14 @@ def rolling_zigzag(df: pd.DataFrame, symbol: str, window: int,
173
287
  idx = close.index
174
288
  result = np.full(len(values), np.nan)
175
289
 
176
- if len(values) < window or window < 10:
290
+ starts, min_periods = _window_starts(idx, window)
291
+ if len(values) < min_periods or min_periods < 10:
177
292
  return pd.Series(result, index=idx)
178
293
 
179
- for i in range(window - 1, len(values)):
180
- win_start = i - window + 1
294
+ for i in range(len(values)):
295
+ win_start = starts[i]
296
+ if win_start < 0 or (i - win_start + 1) < min_periods:
297
+ continue
181
298
  win_vals = values[win_start : i + 1]
182
299
  win_idx = idx[win_start : i + 1]
183
300
 
@@ -237,7 +354,8 @@ def _rf_window_mean(rf, index, window: int):
237
354
  if index is not None and len(s) and isinstance(index, pd.DatetimeIndex):
238
355
  s = s.reindex(s.index.union(index)).ffill().reindex(index)
239
356
 
240
- return s.rolling(window, min_periods=window).mean()
357
+ win, min_periods = resolve_window(window)
358
+ return s.rolling(win, min_periods=min_periods).mean()
241
359
 
242
360
 
243
361
  def _get_benchmark(df: pd.DataFrame) -> pd.Series:
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