qka 2.5.3.dev2__tar.gz → 2.5.4__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.3.dev2 → qka-2.5.4}/PKG-INFO +1 -1
  2. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/accessor.py +16 -1
  3. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/backtest.py +165 -56
  4. {qka-2.5.3.dev2 → qka-2.5.4}/.github/workflows/release.yml +0 -0
  5. {qka-2.5.3.dev2 → qka-2.5.4}/.gitignore +0 -0
  6. {qka-2.5.3.dev2 → qka-2.5.4}/.vscode/settings.json +0 -0
  7. {qka-2.5.3.dev2 → qka-2.5.4}/CHANGELOG.md +0 -0
  8. {qka-2.5.3.dev2 → qka-2.5.4}/LICENSE +0 -0
  9. {qka-2.5.3.dev2 → qka-2.5.4}/README.md +0 -0
  10. {qka-2.5.3.dev2 → qka-2.5.4}/pyproject.toml +0 -0
  11. {qka-2.5.3.dev2 → qka-2.5.4}/qka/__init__.py +0 -0
  12. {qka-2.5.3.dev2 → qka-2.5.4}/qka/cli.py +0 -0
  13. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/__init__.py +0 -0
  14. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/analysis.py +0 -0
  15. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/broker.py +0 -0
  16. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/data.py +0 -0
  17. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/indicator.py +0 -0
  18. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/report.py +0 -0
  19. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/simulate.py +0 -0
  20. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/sizing.py +0 -0
  21. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/strategy.py +0 -0
  22. {qka-2.5.3.dev2 → qka-2.5.4}/qka/core/tdx.py +0 -0
  23. {qka-2.5.3.dev2 → qka-2.5.4}/qka/utils/__init__.py +0 -0
  24. {qka-2.5.3.dev2 → qka-2.5.4}/qka/utils/logger.py +0 -0
  25. {qka-2.5.3.dev2 → qka-2.5.4}/skills/qka/SKILL.md +0 -0
  26. {qka-2.5.3.dev2 → qka-2.5.4}/skills/qka/references/analysis.md +0 -0
  27. {qka-2.5.3.dev2 → qka-2.5.4}/skills/qka/references/backtest.md +0 -0
  28. {qka-2.5.3.dev2 → qka-2.5.4}/skills/qka/references/broker.md +0 -0
  29. {qka-2.5.3.dev2 → qka-2.5.4}/skills/qka/references/data.md +0 -0
  30. {qka-2.5.3.dev2 → qka-2.5.4}/skills/qka/references/sizing.md +0 -0
  31. {qka-2.5.3.dev2 → qka-2.5.4}/skills/qka/references/strategy.md +0 -0
  32. {qka-2.5.3.dev2 → qka-2.5.4}/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.3.dev2
3
+ Version: 2.5.4
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
@@ -34,6 +34,10 @@ class DataAccessor:
34
34
  )
35
35
  self._dates: deque = deque(maxlen=max_window)
36
36
  self._last_date = None # 避免同一 bar 内多次 push 重复记录日期
37
+ # 同一根 bar 内 get() 的结果是不变的,缓存起来复用。
38
+ # 实测一次回测里 get 被调 28953 次(平均每根 bar 17 次),每次都重建
39
+ # 451 项 dict + Series,累计 15 秒。缓存后每根 bar 每个因子只建一次。
40
+ self._cache: dict[str, pd.Series] = {}
37
41
 
38
42
  def push(self, date, factor: str, data: dict):
39
43
  """
@@ -48,6 +52,9 @@ class DataAccessor:
48
52
  if date != self._last_date:
49
53
  self._dates.append(date)
50
54
  self._last_date = date
55
+ self._cache.clear() # 换 bar:上一根 bar 的横截面全失效
56
+ else:
57
+ self._cache.pop(factor, None) # 同一 bar 内同因子被重推,旧值作废
51
58
  for symbol, value in data.items():
52
59
  self._buffer[factor][symbol].append(value)
53
60
 
@@ -60,9 +67,16 @@ class DataAccessor:
60
67
 
61
68
  Returns:
62
69
  pd.Series,index=股票代码,values=最新值
70
+
71
+ 注意:返回值是同一根 bar 内共享的缓存对象,**只读**,不要原地修改。
63
72
  """
73
+ cached = self._cache.get(factor)
74
+ if cached is not None:
75
+ return cached
64
76
  data = {sym: vals[-1] for sym, vals in self._buffer.get(factor, {}).items()}
65
- return pd.Series(data)
77
+ series = pd.Series(data)
78
+ self._cache[factor] = series
79
+ return series
66
80
 
67
81
  def history(self, factor: str, window: int = 20) -> pd.DataFrame:
68
82
  """
@@ -88,3 +102,4 @@ class DataAccessor:
88
102
  self._buffer.clear()
89
103
  self._dates.clear()
90
104
  self._last_date = None
105
+ self._cache.clear()
@@ -4,6 +4,9 @@
4
4
  包含完整的交易记录、绩效分析和可视化功能。
5
5
  """
6
6
 
7
+ import logging
8
+ from pathlib import Path
9
+
7
10
  import pandas as pd
8
11
  import numpy as np
9
12
  import dask.dataframe as dd
@@ -13,6 +16,8 @@ from qka.core.broker import Broker
13
16
  from qka.core.accessor import DataAccessor
14
17
  from qka.core.sizing import SizingAccessor
15
18
 
19
+ logger = logging.getLogger(__name__)
20
+
16
21
 
17
22
  class Backtest:
18
23
  """
@@ -41,7 +46,8 @@ class Backtest:
41
46
  self.results = None
42
47
  self.initial_cash = None # 由 run() 设置
43
48
  self.metrics = None
44
- self._benchmark_data = None
49
+ self._benchmark_data = None # 基准收盘价序列(HTML 报告画曲线用)
50
+ self._benchmark_returns = None # 基准日收益(算 alpha/beta 用)
45
51
  # 报告里算夏普用的无风险利率(年化,小数)。默认 3%,与历史报告可比。
46
52
  # 要换口径就在 run() 之后直接改这个属性。因子那边用 Data(risk_free=...)
47
53
  # 可以逐窗口时变,回测是一整段区间的单一指标,这里保持一个可覆写的数。
@@ -81,6 +87,42 @@ class Backtest:
81
87
  by_factor[factor][symbol] = val
82
88
  return dict(by_factor)
83
89
 
90
+ @staticmethod
91
+ def _build_layout(columns):
92
+ """列名 → {factor: [(symbol, 列号)]},**整段回测只解析一次**。
93
+
94
+ 列名格式 `{symbol}|{factor}`,所有 bar 共用同一套列。原来每根 bar 都
95
+ `rsplit` 一遍全部列名:451 只 × 8 字段 × 1646 天 = 594 万次字符串切分,
96
+ 纯属重复劳动(实测 `_parse_row` 累计 19 秒)。
97
+ """
98
+ layout: dict[str, list[tuple[str, int]]] = defaultdict(list)
99
+ for i, col in enumerate(columns):
100
+ if not isinstance(col, str) or '|' not in col:
101
+ continue
102
+ sym, _, factor = col.rpartition('|')
103
+ layout[factor].append((sym, i))
104
+ return dict(layout)
105
+
106
+ def _run_bars(self, dates, values, layout):
107
+ """逐 bar 驱动:推数据 → 策略决策 → broker 撮合。
108
+
109
+ values 是 (days × cols) 的 numpy 数组,按位置取值。比 `df.iterrows()`
110
+ 快一个量级 —— iterrows 每行都要构造一个 Series 并做 dtype 推断
111
+ (实测这两项合计 20 秒以上)。
112
+ """
113
+ data_acc = self.strategy._data
114
+ broker = self.strategy.broker
115
+ push = data_acc.push
116
+ on_bar = self.strategy.on_bar
117
+
118
+ for i, date in enumerate(dates):
119
+ row = values[i]
120
+ for factor, entries in layout.items():
121
+ push(date, factor, {sym: row[ci] for sym, ci in entries})
122
+ broker.timestamp = date
123
+ on_bar(date)
124
+ broker.on_bar(date, data_acc.get)
125
+
84
126
  def run(self, cash: float = 100000.0,
85
127
  start_date: str = None, end_date: str = None,
86
128
  benchmark: Optional[str] = None,
@@ -125,85 +167,112 @@ class Backtest:
125
167
  # 获取数据
126
168
  raw = self.data.get(lazy=True, start_date=start_date, end_date=end_date)
127
169
 
128
- # 加载基准数据
170
+ # 加载基准数据(同时算出日收益,供 metrics 里的 alpha/beta 使用)
129
171
  if benchmark:
130
- self._load_benchmark(benchmark)
172
+ self._load_benchmark(benchmark, start_date, end_date)
131
173
 
132
174
  if isinstance(raw, dd.DataFrame):
133
- # ── dask 模式:分区迭代 ──
175
+ # ── dask 模式 ──
134
176
  ddf: dd.DataFrame = raw
135
177
  n_rows = len(ddf)
136
178
 
137
- if n_rows > 500:
138
- # 大规模:先算 index,再按日期分块读取
139
- index = ddf.index.compute()
140
- chunk_size = 500
179
+ # 分块是为了控制内存,**不是越小越好**:原来写死每块 500 行,
180
+ # 451 只票 × 1646 天被切成 4 块,dask 反复建图、反复读 parquet 元数据
181
+ # (实测 dask.tokenize 被调 50 万次、累计 10 秒)。按 ~200MB 预算反推行数。
182
+ _cols = max(1, len(ddf.columns))
183
+ chunk_size = max(500, int(200 * 1024 * 1024 / (_cols * 8)))
141
184
 
185
+ if n_rows <= chunk_size:
186
+ # 一次装得下:直接 compute。**不要**再走 `ddf.loc[首:尾].compute()` ——
187
+ # 单块时那等于多套一层 dask 图,实测白白多花 25 秒。
188
+ df = ddf.compute()
189
+ self._run_bars(df.index, df.to_numpy(), self._build_layout(df.columns))
190
+ else:
191
+ index = ddf.index.compute()
192
+ layout = None
142
193
  for start in range(0, n_rows, chunk_size):
143
194
  end = min(start + chunk_size, n_rows)
144
195
  date_start, date_end = index[start], index[end - 1]
145
196
 
146
197
  chunk = ddf.loc[date_start:date_end].compute()
147
- for dt, row in chunk.iterrows():
148
- by_factor = self._parse_row(row)
149
- for factor, data in by_factor.items():
150
- self.strategy._data.push(dt, factor, data)
151
- # dask 路径:策略使用 self.get() / self.history()
152
- self.strategy.broker.timestamp = dt
153
- self.strategy.on_bar(dt)
154
- self.strategy.broker.on_bar(
155
- dt, self.strategy._data.get
156
- )
157
- else:
158
- # 小规模:一次加载,零开销
159
- df = ddf.compute()
160
- for date, row in df.iterrows():
161
- by_factor = self._parse_row(row)
162
- for factor, data in by_factor.items():
163
- self.strategy._data.push(date, factor, data)
164
- self.strategy.broker.timestamp = date
165
- self.strategy.on_bar(date)
166
- self.strategy.broker.on_bar(
167
- date, self.strategy._data.get
168
- )
198
+ # 列布局整段只算一次:所有分块的列完全一致
199
+ if layout is None:
200
+ layout = self._build_layout(chunk.columns)
201
+ self._run_bars(chunk.index, chunk.to_numpy(), layout)
169
202
  else:
170
203
  # ── pandas 模式:向后兼容(测试 mock 数据等) ──
171
204
  df: pd.DataFrame = raw
172
- for date, row in df.iterrows():
173
- by_factor = self._parse_row(row)
174
- for factor, data in by_factor.items():
175
- self.strategy._data.push(date, factor, data)
176
- self.strategy.broker.timestamp = date
177
- self.strategy.on_bar(date)
178
- self.strategy.broker.on_bar(
179
- date, self.strategy._data.get
180
- )
205
+ self._run_bars(df.index, df.to_numpy(), self._build_layout(df.columns))
181
206
 
182
207
  # 保存回测结果
183
208
  self.results = self.strategy.broker.trades
184
209
  self.metrics = self._compute_metrics()
185
210
 
186
- def _load_benchmark(self, benchmark_code: str):
187
- """
188
- 加载基准指数数据
211
+ def _load_benchmark(self, benchmark_code: str, start_date=None, end_date=None):
212
+ """加载基准指数,同时算出**日收益序列**(`self._benchmark_returns`)。
213
+
214
+ 🚨 两个坑(都在这次改掉):
215
+
216
+ 1. 原来这里新建一个 `Data(symbols=[code])` 再 `get()` —— 走的是 Data 的默认
217
+ source/adjust,跟回测本体可能根本不是同一个目录;本地没缓存时还会去联网。
218
+ 现在直接用回测本体已经定好的缓存目录(`self.data.target_dir`),同源同口径。
219
+
220
+ 2. 原来的数据只喂给 `generate_report()` 画一条对照曲线,`_compute_metrics()`
221
+ 一点没用上 —— 所以 `bt.run(benchmark='000300.SH')` 跑完,metrics 里
222
+ **看不到 alpha / beta**。现在把日收益留一份,指标那边直接用。
189
223
 
190
224
  Args:
191
225
  benchmark_code: 基准代码,如 '000300.SH'
226
+ start_date / end_date: 回测区间,基准只取这一段(对齐才有意义)
192
227
  """
228
+ self._benchmark_data = None
229
+ self._benchmark_returns = None
230
+
231
+ # 基准文件的候选位置 —— 按顺序找,第一个存在的就用:
232
+ # ① `{datadir}/{source}/{period}/{adjust}/` —— 和回测本体同一个目录
233
+ # (qkadata 也把指数写进了 hfq,跟个股同步更新,最新)
234
+ # ② `{datadir}/{source}/{period}/index/` —— 指数集中的地方
235
+ # (桌面端/其他环境可能只同步这一个目录)
236
+ candidates = []
237
+ target = getattr(self.data, 'target_dir', None)
238
+ if target:
239
+ candidates.append(Path(target) / f"{benchmark_code}.parquet")
240
+ datadir = getattr(self.data, 'datadir', None)
241
+ if datadir:
242
+ candidates.append(
243
+ Path(datadir) / str(getattr(self.data, 'source', 'tickflow'))
244
+ / str(getattr(self.data, 'period', '1d')) / 'index'
245
+ / f"{benchmark_code}.parquet")
246
+
247
+ path = next((p for p in candidates if p.exists()), None)
248
+ if path is None:
249
+ logger.warning('基准行情文件不存在,alpha/beta 算不了,找过:%s',
250
+ [str(p) for p in candidates])
251
+ return
252
+
193
253
  try:
194
- from qka.core.data import Data
195
- bm_data = Data(symbols=[benchmark_code])
196
- bm_df = bm_data.get(lazy=False)
197
- if bm_df is not None and not bm_df.empty:
198
- # 提取 close 列(可能是多股票 MultiIndex,取第一只)
199
- close_col = [c for c in bm_df.columns if 'close' in str(c).lower()]
200
- if close_col:
201
- self._benchmark_data = bm_df[close_col[0]]
202
- else:
203
- self._benchmark_data = bm_df.iloc[:, 0]
204
- print(f"基准数据加载成功: {benchmark_code},{len(bm_df)} 个交易日")
205
- except Exception as e:
206
- print(f"基准数据加载失败: {e}")
254
+ df = pd.read_parquet(path)
255
+ if 'date' not in df.columns:
256
+ df = df.reset_index() # 写入时把 date 存成了 index
257
+ if 'close' not in df.columns:
258
+ logger.warning(f'基准文件缺 close 列,跳过:{path}')
259
+ return
260
+
261
+ df['date'] = df['date'].astype(str).str[:10]
262
+ if start_date is not None:
263
+ df = df[df['date'] >= str(start_date)[:10]]
264
+ if end_date is not None:
265
+ df = df[df['date'] <= str(end_date)[:10]]
266
+ if len(df) < 20:
267
+ logger.warning(f'基准数据只有 {len(df)} 天,样本太短,跳过 alpha/beta')
268
+ return
269
+
270
+ df = df.drop_duplicates('date').set_index('date').sort_index()
271
+ close = df['close'].astype(float)
272
+ self._benchmark_data = close
273
+ self._benchmark_returns = close.pct_change().dropna()
274
+ except Exception as exc: # 基准出问题不该让整个回测失败
275
+ logger.warning(f'基准数据加载失败(不影响回测):{exc}')
207
276
 
208
277
  def _compute_metrics(self) -> dict:
209
278
  """计算绩效指标。无数据时返回空 dict。"""
@@ -294,7 +363,7 @@ class Backtest:
294
363
  # 总手续费
295
364
  total_commission = self.strategy.broker.total_commission
296
365
 
297
- return {
366
+ metrics = {
298
367
  'initial_cash': initial,
299
368
  'final_equity': final,
300
369
  'total_return_pct': total_return,
@@ -310,6 +379,46 @@ class Backtest:
310
379
  'n_days': n_days,
311
380
  }
312
381
 
382
+ metrics.update(self._benchmark_metrics(daily_returns))
383
+ return metrics
384
+
385
+ def _benchmark_metrics(self, daily_returns: pd.Series) -> dict:
386
+ """相对基准的指标:基准收益 / alpha / beta / 信息比率。
387
+
388
+ 只在 `run(benchmark=...)` 且基准文件读到了才有;否则返回空 dict,
389
+ metrics 里就不出现这些键(调用方按缺字段处理,别造假数据)。
390
+
391
+ 回归本身交给 `Analysis.alpha_beta` —— 口径只有一处,不在这里重写公式。
392
+ """
393
+ bench = getattr(self, '_benchmark_returns', None)
394
+ if bench is None or len(bench) < 2:
395
+ return {}
396
+
397
+ rp = daily_returns.copy()
398
+ rp.index = [str(i)[:10] for i in rp.index]
399
+ rp = rp[rp.index.isin(bench.index)]
400
+ if len(rp) < 20: # 样本太短,回归没有意义
401
+ return {}
402
+
403
+ from qka.core.analysis import Analysis
404
+
405
+ x = bench.loc[rp.index].astype(float)
406
+ y = rp.astype(float)
407
+
408
+ ab = Analysis().alpha_beta(y.values, x.values, rf=self.risk_free_rate)
409
+ out = {
410
+ 'benchmark_return_pct': float((1 + x).prod() - 1) * 100,
411
+ 'alpha_pct': float(ab.alpha) * 100,
412
+ 'beta': float(ab.beta),
413
+ 'benchmark_n_days': int(len(x)),
414
+ }
415
+ try:
416
+ out['information_ratio'] = float(
417
+ Analysis().information_ratio(y.values, x.values))
418
+ except Exception: # 跟踪误差为 0 时会算不出来,不致命
419
+ pass
420
+ return out
421
+
313
422
  def report(self, title: str = "未命名策略", output_path: Optional[str] = None) -> str:
314
423
  """
315
424
  生成自包含的 HTML 回测报告
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