qka 2.0.1.dev2__tar.gz → 2.0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qka
3
- Version: 2.0.1.dev2
3
+ Version: 2.0.2.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
@@ -36,9 +36,9 @@ Classifier: Programming Language :: Python :: 3.12
36
36
  Requires-Python: >=3.10
37
37
  Requires-Dist: baostock>=0.9.1
38
38
  Requires-Dist: dask[dataframe]>=2025.7.0
39
- Requires-Dist: pandas<3.0
40
39
  Requires-Dist: plotly>=6.1.1
41
40
  Requires-Dist: pyarrow>=21.0.0
41
+ Requires-Dist: statsmodels>=0.14.6
42
42
  Requires-Dist: ta>=0.11.0
43
43
  Requires-Dist: tqdm>=4.67.1
44
44
  Provides-Extra: dev
@@ -24,7 +24,7 @@ dependencies = [
24
24
  "dask[dataframe]>=2025.7.0",
25
25
  "ta>=0.11.0",
26
26
  "baostock>=0.9.1",
27
- "pandas<3.0",
27
+ "statsmodels>=0.14.6",
28
28
  ]
29
29
 
30
30
  [project.optional-dependencies]
@@ -1,30 +1,33 @@
1
- """
2
- QKA - 量化交易框架
3
-
4
- 统一的访问接口,支持 qka.xxx 的访问模式
5
- """
6
-
7
- from importlib.metadata import version, PackageNotFoundError
8
-
9
- try:
10
- __version__ = version("qka")
11
- except PackageNotFoundError:
12
- __version__ = "0.1.0" # fallback version
13
-
14
- # 核心功能直接导入
15
- from qka.core.data import Data
16
- from qka.core.accessor import DataAccessor
17
- from qka.core.backtest import Backtest
18
- from qka.core.strategy import Strategy
19
- from qka.core.broker import Broker
20
- from qka.core.sizing import SizingAccessor
21
-
22
- # 子模块导入
23
- from qka import core, utils
24
-
25
- __all__ = [
26
- # 核心功能
27
- 'Data', 'Backtest', 'Strategy', 'Broker', 'DataAccessor', 'SizingAccessor',
28
- # 子模块
29
- 'core', 'utils'
1
+ """
2
+ QKA - 量化交易框架
3
+
4
+ 统一的访问接口,支持 qka.xxx 的访问模式
5
+ """
6
+
7
+ from importlib.metadata import version, PackageNotFoundError
8
+
9
+ try:
10
+ __version__ = version("qka")
11
+ except PackageNotFoundError:
12
+ __version__ = "0.1.0" # fallback version
13
+
14
+ # 核心功能直接导入
15
+ from qka.core.data import Data
16
+ from qka.core.accessor import DataAccessor
17
+ from qka.core.backtest import Backtest
18
+ from qka.core.strategy import Strategy
19
+ from qka.core.broker import Broker
20
+ from qka.core.sizing import SizingAccessor
21
+ from qka.core.analysis import Analysis, Segment, AlphaBeta
22
+
23
+ # 子模块导入
24
+ from qka import core, utils
25
+
26
+ __all__ = [
27
+ # 核心功能
28
+ 'Data', 'Backtest', 'Strategy', 'Broker', 'DataAccessor', 'SizingAccessor',
29
+ # 分析
30
+ 'Analysis', 'Segment', 'AlphaBeta',
31
+ # 子模块
32
+ 'core', 'utils'
30
33
  ]
@@ -0,0 +1,315 @@
1
+ """
2
+ 事后分析模块
3
+
4
+ 包含需要在全序列范围内计算的函数(非逐 bar 可算),
5
+ 禁止用于实时交易和回测策略。
6
+ """
7
+
8
+ from collections import namedtuple
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from statsmodels.tsa.filters.hp_filter import hpfilter
13
+
14
+ Segment = namedtuple('Segment', ['start', 'end', 'direction', 'change'])
15
+ AlphaBeta = namedtuple('AlphaBeta', ['alpha', 'beta'])
16
+
17
+
18
+ class Analysis:
19
+ """
20
+ 事后分析工具集。
21
+
22
+ 所有方法均为无状态的静态分析,接受数据作为参数,
23
+ 不做回测、不做实时判断。
24
+
25
+ Methods:
26
+ zigzag: HP 滤波 + Zigzag 趋势分段
27
+ alpha_beta: OLS 回归,返回 AlphaBeta(α 已年化)
28
+ sharpe_ratio: 夏普比率(年化)
29
+ max_drawdown: 最大回撤
30
+ information_ratio: 信息比率(年化)
31
+
32
+ Example:
33
+ >>> from qka import Data, Analysis
34
+ >>> data = Data(symbols=['sh.000300'])
35
+ >>> df = data.get()
36
+ >>> analysis = Analysis()
37
+ >>> segs = analysis.zigzag(df['sh.000300|close'], threshold=0.3, min_days=90)
38
+ >>> ab = analysis.alpha_beta(df['sh.000300|returns'].dropna(),
39
+ ... df['benchmark|returns'].dropna())
40
+ """
41
+
42
+ def zigzag(self, series, threshold=0.3, min_days=90, lamb=1600):
43
+ """
44
+ HP 滤波 + Zigzag 趋势分段。
45
+
46
+ 对价格序列做 Hodrick–Prescott 滤波去噪,在趋势线上运行 Zigzag 状态机
47
+ 识别交替的上行/下行段,短于 min_days 的段被相邻段合并吸收。
48
+
49
+ ⚠️ 包含未来函数:
50
+ - HP 滤波是双边滤波,序列末尾新增数据后,末尾段的趋势线可能微调
51
+ - Zigzag 需要后续价格突破 threshold 才能确认当前转折点
52
+ 仅供事后分析,禁止用于回测策略。
53
+
54
+ Args:
55
+ series: pandas Series,index 为日期(datetime),values 为价格
56
+ threshold: 反转阈值,默认 0.3。峰顶回落 30% 确认顶,谷底反弹 30% 确认底
57
+ min_days: 最小段长(自然日),默认 90。短于此的段被前后段三合一吸收
58
+ lamb: HP 滤波平滑参数,默认 1600(日线数据标准值)
59
+
60
+ Returns:
61
+ list[Segment]: 分段列表,按时序排列。每个 Segment 包含:
62
+ - start: 段起始日期(pd.Timestamp)
63
+ - end: 段结束日期(pd.Timestamp)
64
+ - direction: '上行' 或 '下行'
65
+ - change: 段内涨跌幅(百分比,如 +368.1 或 -57.2)
66
+ """
67
+ if not isinstance(series, pd.Series):
68
+ raise TypeError(f'series 必须是 pandas Series,got {type(series)}')
69
+ if series.empty:
70
+ return []
71
+
72
+ close = series.dropna()
73
+ if len(close) < 10:
74
+ raise ValueError(f'数据点不足({len(close)}),需要至少 10 个点做 HP 滤波')
75
+
76
+ # ── HP 滤波 ──
77
+ log_price = np.log(close.values)
78
+ _, trend_log = hpfilter(log_price, lamb=lamb)
79
+ hp_series = pd.Series(np.exp(trend_log), index=close.index)
80
+
81
+ # ── Zigzag 状态机 ──
82
+ segments_raw = _zigzag_core(hp_series, threshold)
83
+
84
+ # ── 短段合并 ──
85
+ segments = _merge_short_segments(hp_series, segments_raw, min_days)
86
+
87
+ return [Segment(s['start'], s['end'], s['dir'], s['chg']) for s in segments]
88
+
89
+ def alpha_beta(self, returns, bench_returns, rf=0.0, periods_per_year=252):
90
+ """
91
+ OLS 回归:Rp - Rf = α + β × (Rm - Rf),返回詹森 α。
92
+
93
+ Args:
94
+ returns: array-like,收益率序列(与 periods_per_year 对应频率)
95
+ bench_returns: array-like,基准收益率序列
96
+ rf: 年化无风险利率,默认 0
97
+ periods_per_year: 年化周期数,日线=252,周线=52,月线=12
98
+
99
+ Returns:
100
+ AlphaBeta(alpha=float, beta=float),α 已年化
101
+ """
102
+ rp = np.asarray(returns, dtype=float)
103
+ rm = np.asarray(bench_returns, dtype=float)
104
+
105
+ mask = ~(np.isnan(rp) | np.isnan(rm))
106
+ rp, rm = rp[mask], rm[mask]
107
+
108
+ if len(rp) < 2:
109
+ return AlphaBeta(alpha=np.nan, beta=np.nan)
110
+
111
+ rf_per_period = rf / periods_per_year
112
+
113
+ cov = np.cov(rp, rm, ddof=1)
114
+ beta_val = cov[0, 1] / cov[1, 1]
115
+ alpha_val = ((np.mean(rp) - rf_per_period) - beta_val * (np.mean(rm) - rf_per_period)) * periods_per_year
116
+
117
+ return AlphaBeta(alpha=alpha_val, beta=beta_val)
118
+
119
+ def sharpe_ratio(self, returns, rf=0.0, periods_per_year=252):
120
+ """
121
+ 夏普比率(年化)。
122
+
123
+ Args:
124
+ returns: array-like,收益率序列(与 periods_per_year 对应频率)
125
+ rf: 年化无风险利率,默认 0
126
+ periods_per_year: 年化周期数,日线=252,周线=52,月线=12
127
+
128
+ Returns:
129
+ float
130
+ """
131
+ rp = np.asarray(returns, dtype=float)
132
+ rp = rp[~np.isnan(rp)]
133
+
134
+ if len(rp) < 2:
135
+ return np.nan
136
+
137
+ rf_per_period = rf / periods_per_year
138
+ excess = np.mean(rp) - rf_per_period
139
+ std = np.std(rp, ddof=1)
140
+
141
+ if std == 0:
142
+ return np.nan
143
+
144
+ return float(excess / std * np.sqrt(periods_per_year))
145
+
146
+ def max_drawdown(self, returns):
147
+ """
148
+ 最大回撤。
149
+
150
+ Args:
151
+ returns: array-like,日收益率
152
+
153
+ Returns:
154
+ float,正数(0.35 = 35% 回撤)
155
+ """
156
+ rp = np.asarray(returns, dtype=float)
157
+ rp = rp[~np.isnan(rp)]
158
+
159
+ if len(rp) < 1:
160
+ return np.nan
161
+
162
+ equity = np.cumprod(1 + rp)
163
+ running_max = np.maximum.accumulate(equity)
164
+ drawdown = 1 - equity / running_max
165
+
166
+ return float(np.max(drawdown))
167
+
168
+ def information_ratio(self, returns, bench_returns, periods_per_year=252):
169
+ """
170
+ 信息比率(年化)。
171
+
172
+ Args:
173
+ returns: array-like,收益率序列
174
+ bench_returns: array-like,基准收益率序列
175
+ periods_per_year: 年化周期数,日线=252,周线=52,月线=12
176
+
177
+ Returns:
178
+ float
179
+ """
180
+ rp = np.asarray(returns, dtype=float)
181
+ rm = np.asarray(bench_returns, dtype=float)
182
+
183
+ mask = ~(np.isnan(rp) | np.isnan(rm))
184
+ rp, rm = rp[mask], rm[mask]
185
+
186
+ if len(rp) < 2:
187
+ return np.nan
188
+
189
+ excess = rp - rm
190
+ return float(np.mean(excess) / np.std(excess, ddof=1) * np.sqrt(periods_per_year))
191
+
192
+
193
+ def _zigzag_core(trend_series, threshold):
194
+ """Zigzag 状态机:在趋势线上识别交替的顶和底。"""
195
+ s = trend_series.values
196
+ dates = trend_series.index
197
+ n = len(s)
198
+
199
+ # 初始方向:前 60 天涨→找顶,跌→找底
200
+ lookahead = min(60, n // 4)
201
+ looking_for_peak = s[lookahead] > s[0]
202
+
203
+ turning_points = []
204
+ extreme_val = s[0]
205
+ extreme_idx = 0
206
+
207
+ for i in range(1, n):
208
+ if looking_for_peak:
209
+ if s[i] > extreme_val:
210
+ extreme_val, extreme_idx = s[i], i
211
+ if extreme_idx > 0 and (extreme_val - s[i]) / extreme_val > threshold:
212
+ turning_points.append((extreme_idx, 'peak', extreme_val))
213
+ extreme_val, extreme_idx = s[i], i
214
+ looking_for_peak = False
215
+ else:
216
+ if s[i] < extreme_val:
217
+ extreme_val, extreme_idx = s[i], i
218
+ if extreme_idx > 0 and (s[i] - extreme_val) / extreme_val > threshold:
219
+ turning_points.append((extreme_idx, 'trough', extreme_val))
220
+ extreme_val, extreme_idx = s[i], i
221
+ looking_for_peak = True
222
+
223
+ if not turning_points:
224
+ total_chg = (s[-1] - s[0]) / s[0] * 100
225
+ return [{'start': dates[0], 'end': dates[-1],
226
+ 'dir': '上行' if total_chg >= 0 else '下行', 'chg': total_chg,
227
+ 'dur': (dates[-1] - dates[0]).days}]
228
+
229
+ # 从转折点构建初始段
230
+ segs = []
231
+ seg_start = 0
232
+ direction = '上行' if turning_points[0][1] == 'peak' else '下行'
233
+
234
+ for idx, kind, val in turning_points:
235
+ if idx <= seg_start:
236
+ continue
237
+ dur = (dates[idx] - dates[seg_start]).days
238
+ chg = (val - s[seg_start]) / s[seg_start] * 100
239
+ segs.append({'start': dates[seg_start], 'end': dates[idx],
240
+ 'dir': direction, 'chg': chg, 'dur': dur})
241
+ seg_start = idx
242
+ direction = '下行' if direction == '上行' else '上行'
243
+
244
+ if seg_start < n - 1:
245
+ dur = (dates[-1] - dates[seg_start]).days
246
+ chg = (s[-1] - s[seg_start]) / s[seg_start] * 100
247
+ segs.append({'start': dates[seg_start], 'end': dates[-1],
248
+ 'dir': direction, 'chg': chg, 'dur': dur})
249
+
250
+ return segs
251
+
252
+
253
+ def _merge_short_segments(trend_series, segs, min_days):
254
+ """合并短于 min_days 的段:与前后段三合一,然后同向段合并。"""
255
+ if min_days <= 0 or not segs:
256
+ return segs
257
+
258
+ s = trend_series.values
259
+ dates = trend_series.index
260
+
261
+ # 吸收短段
262
+ changed = True
263
+ while changed:
264
+ changed = False
265
+ i = 0
266
+ while i < len(segs):
267
+ if segs[i]['dur'] >= min_days:
268
+ i += 1
269
+ continue
270
+ changed = True
271
+ if 0 < i < len(segs) - 1:
272
+ m_start = segs[i - 1]['start']
273
+ m_end = segs[i + 1]['end']
274
+ i_start = segs[i - 1]['start']
275
+ elif i == 0 and len(segs) > 1:
276
+ m_start = segs[i]['start']
277
+ m_end = segs[i + 1]['end']
278
+ i_start = segs[i]['start']
279
+ elif i == len(segs) - 1 and len(segs) > 1:
280
+ m_start = segs[i - 1]['start']
281
+ m_end = segs[i]['end']
282
+ i_start = segs[i - 1]['start']
283
+ else:
284
+ i += 1
285
+ continue
286
+
287
+ # 用索引重算涨跌幅,避免价格版本不一致
288
+ start_idx = dates.get_loc(m_start)
289
+ end_idx = dates.get_loc(m_end)
290
+ m_chg = (s[end_idx] - s[start_idx]) / s[start_idx] * 100
291
+ m_dur = (m_end - m_start).days
292
+
293
+ del_start = dates.get_loc(i_start)
294
+ segs[i - 1:i + 2] = [{'start': m_start, 'end': m_end,
295
+ 'dir': '上行' if m_chg >= 0 else '下行',
296
+ 'chg': m_chg, 'dur': m_dur}]
297
+ i -= 1
298
+
299
+ # 合并连续同向段
300
+ i = 1
301
+ while i < len(segs):
302
+ if segs[i]['dir'] == segs[i - 1]['dir']:
303
+ m_start = segs[i - 1]['start']
304
+ m_end = segs[i]['end']
305
+ start_idx = dates.get_loc(m_start)
306
+ end_idx = dates.get_loc(m_end)
307
+ m_chg = (s[end_idx] - s[start_idx]) / s[start_idx] * 100
308
+ m_dur = (m_end - m_start).days
309
+ segs[i - 1:i + 1] = [{'start': m_start, 'end': m_end,
310
+ 'dir': segs[i - 1]['dir'],
311
+ 'chg': m_chg, 'dur': m_dur}]
312
+ else:
313
+ i += 1
314
+
315
+ return segs