qka 2.5.3.dev2__py3-none-any.whl → 2.5.4.dev3__py3-none-any.whl
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/core/accessor.py +16 -1
- qka/core/backtest.py +165 -56
- {qka-2.5.3.dev2.dist-info → qka-2.5.4.dev3.dist-info}/METADATA +1 -1
- {qka-2.5.3.dev2.dist-info → qka-2.5.4.dev3.dist-info}/RECORD +7 -7
- {qka-2.5.3.dev2.dist-info → qka-2.5.4.dev3.dist-info}/WHEEL +0 -0
- {qka-2.5.3.dev2.dist-info → qka-2.5.4.dev3.dist-info}/entry_points.txt +0 -0
- {qka-2.5.3.dev2.dist-info → qka-2.5.4.dev3.dist-info}/licenses/LICENSE +0 -0
qka/core/accessor.py
CHANGED
|
@@ -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
|
-
|
|
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()
|
qka/core/backtest.py
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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 回测报告
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
qka/__init__.py,sha256=xFkw3C-0TzeNkr5hPtz-0ZWwLao3cihUAUCXSE8FO1c,1183
|
|
2
2
|
qka/cli.py,sha256=12ZkbSZmE7hSmz5_O67gOXVKROIdymCQevnNfEOApd8,466
|
|
3
3
|
qka/core/__init__.py,sha256=kVhvzSA915MXOk9axq9T-COlv3OeTPfv2avX58WxElE,578
|
|
4
|
-
qka/core/accessor.py,sha256=
|
|
4
|
+
qka/core/accessor.py,sha256=wGDoFdqgf772DDexpM0Sbwg8cxO8et-bQjMMg65tpNs,3722
|
|
5
5
|
qka/core/analysis.py,sha256=EXCO4qhzd2av5Yb4jihcNji1V-VFPOceUHKtg-q8bLs,11142
|
|
6
|
-
qka/core/backtest.py,sha256=
|
|
6
|
+
qka/core/backtest.py,sha256=uZE-N9lKqYKHHEBqQymNG9xgcqZC419zWmAAJIpZLzo,19486
|
|
7
7
|
qka/core/broker.py,sha256=FxeHh9qZ9M6FNFLd8EXwztA4kmC9zZzkrXin2hdVFa4,9662
|
|
8
8
|
qka/core/data.py,sha256=xm7FoVhZtBauQjtnIxC60GR5mfsXkxf3loTFdCWA6r8,110382
|
|
9
9
|
qka/core/indicator.py,sha256=WA8UrQW-ecqVaqZ_eSkwtKjUF5olBxegQE8PiUpkSuY,15323
|
|
@@ -21,8 +21,8 @@ qka/skills/qka/references/broker.md,sha256=wr62v1asRM-wBdsADIsB406eXm_ENYcrwGMAN
|
|
|
21
21
|
qka/skills/qka/references/data.md,sha256=6mL44eGOk_xXLqQ62Wmv1DFYcXn3kayYWYWMd8-hFOk,28857
|
|
22
22
|
qka/skills/qka/references/sizing.md,sha256=3SsX2ddQoHjE177dberW0Q7wYxW-M_2DG_-sV3CCo9A,656
|
|
23
23
|
qka/skills/qka/references/strategy.md,sha256=-6xa_oigTTj77Siity5bTC5MbdF3UKfld9imnevlAAc,4455
|
|
24
|
-
qka-2.5.
|
|
25
|
-
qka-2.5.
|
|
26
|
-
qka-2.5.
|
|
27
|
-
qka-2.5.
|
|
28
|
-
qka-2.5.
|
|
24
|
+
qka-2.5.4.dev3.dist-info/METADATA,sha256=w8xGVbFEQUb885Hzj7B93nJvDVXgFuDde2DyGGm_Ibc,7545
|
|
25
|
+
qka-2.5.4.dev3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
26
|
+
qka-2.5.4.dev3.dist-info/entry_points.txt,sha256=quG9zg-rOKKtbg-6FiXkoxkUUTMltMBD0hZ0QUnJ6RM,37
|
|
27
|
+
qka-2.5.4.dev3.dist-info/licenses/LICENSE,sha256=3iIG35SnJNF11yIzm3vxa2QUvy9SS5rktM3O1DL2vqM,1075
|
|
28
|
+
qka-2.5.4.dev3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|