qka 1.2.4.dev3__tar.gz → 1.3.1.dev5__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-1.2.4.dev3 → qka-1.3.1.dev5}/.gitignore +4 -1
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/PKG-INFO +1 -1
- qka-1.3.1.dev5/docs/advanced/performance.md +203 -0
- qka-1.3.1.dev5/docs/getting-started/concepts.md +175 -0
- qka-1.3.1.dev5/docs/getting-started/first-strategy.md +201 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/docs/index.md +11 -12
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/docs/user-guide/backtest.md +87 -47
- qka-1.3.1.dev5/docs/user-guide/data.md +126 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/mkdocs.yml +2 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/__init__.py +32 -31
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/core/__init__.py +20 -19
- qka-1.3.1.dev5/qka/core/accessor.py +90 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/core/backtest.py +78 -24
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/core/data.py +62 -31
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/core/report.py +3 -3
- qka-1.3.1.dev5/qka/core/strategy.py +84 -0
- qka-1.2.4.dev3/docs/getting-started/concepts.md +0 -117
- qka-1.2.4.dev3/docs/getting-started/first-strategy.md +0 -152
- qka-1.2.4.dev3/docs/user-guide/data.md +0 -90
- qka-1.2.4.dev3/qka/core/strategy.py +0 -38
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/.github/workflows/docs.yml +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/.github/workflows/release.yml +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/.vscode/settings.json +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/CHANGELOG.md +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/LICENSE +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/README.md +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/docs/api/brokers.md +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/docs/api/core.md +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/docs/api/utils.md +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/docs/getting-started/installation.md +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/docs/user-guide/trading.md +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/pyproject.toml +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/brokers/__init__.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/brokers/client.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/brokers/server.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/brokers/trade.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/cli.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/core/broker.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/mcp/__init__.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/mcp/api.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/mcp/server.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/server/__init__.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/server/handlers/__init__.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/server/handlers/class_inspector_handler.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/server/handlers/code_executor_handler.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/server/ws_client.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/server/zmq_server.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/utils/__init__.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/utils/anis.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/utils/logger.py +0 -0
- {qka-1.2.4.dev3 → qka-1.3.1.dev5}/qka/utils/util.py +0 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# 性能优化
|
|
2
|
+
|
|
3
|
+
QKA 回测引擎的 **dask 分区迭代** 架构,使其能够在一台笔记本上高效处理数百只股票数年的数据。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 问题:大规模回测的内存瓶颈
|
|
8
|
+
|
|
9
|
+
传统回测引擎的典型做法:
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
# 常规做法:一次性加载所有数据
|
|
13
|
+
all_data = pd.read_parquet('data/*.parquet')
|
|
14
|
+
# all_data 可能在内存中占 2-3 GB
|
|
15
|
+
for date in all_data.index.unique():
|
|
16
|
+
run_strategy(date, all_data.loc[date])
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
当股票数量增多时,内存占用线性增长:
|
|
20
|
+
|
|
21
|
+
| 股票数 | 日线数据(5年) | 内存占用 |
|
|
22
|
+
|--------|----------------|----------|
|
|
23
|
+
| 10 只 | ~1250k 行 | ~100 MB |
|
|
24
|
+
| 100 只 | ~12500k 行 | ~1 GB |
|
|
25
|
+
| 300 只 | ~37500k 行 | ~3+ GB |
|
|
26
|
+
|
|
27
|
+
3 GB 对现代电脑不算大,但加上回测过程中的副本、numpy 数组、中间结果,很容易吃掉 8-12 GB。
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 方案:dask 分区迭代
|
|
32
|
+
|
|
33
|
+
QKA 的解决方案绕开了"全量加载"这个前提:
|
|
34
|
+
|
|
35
|
+
### 1. 数据存储:每只股票独立 parquet
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
datadir/baostock/1d/qfq/
|
|
39
|
+
├── 000001.SZ.parquet # 平安银行,~1MB
|
|
40
|
+
├── 600000.SH.parquet # 浦发银行,~1MB
|
|
41
|
+
└── ... # 每只股票独立文件
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
优势:
|
|
45
|
+
- **增量更新**:新股票只需下载自己的文件,不影响已有数据
|
|
46
|
+
- **独立压缩**:每只股票独立列式存储,读取某个因子时只加载需要的列
|
|
47
|
+
|
|
48
|
+
### 2. 加载:dask 构建 lazy 计算图
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import dask.dataframe as dd
|
|
52
|
+
|
|
53
|
+
# 读取每个 parquet,添加 symbol 列
|
|
54
|
+
parts = []
|
|
55
|
+
for symbol in symbols:
|
|
56
|
+
df = dd.read_parquet(f'{symbol}.parquet')
|
|
57
|
+
df['symbol'] = symbol
|
|
58
|
+
parts.append(df)
|
|
59
|
+
|
|
60
|
+
# 合并 → 此时仍是 lazy(0 内存占用)
|
|
61
|
+
merged = dd.concat(parts)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
到目前为止,**没有读取任何实际数据**。dask 只记录了"去哪里读什么文件"的计算图。
|
|
65
|
+
|
|
66
|
+
### 3. 变换:字符串列名
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# 将 symbol+factor 拼合为扁平列名
|
|
70
|
+
for factor in factors:
|
|
71
|
+
merged[f'{symbol}_{factor}'] = ...
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
使用 `{symbol}_{factor}` 字符串列名而非 MultiIndex,是因为 dask 的 MultiIndex 在 `.compute()` 后存在信息丢失的 bug。
|
|
75
|
+
|
|
76
|
+
### 4. 分区:按时间排序,分批计算
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# 按日期排序分区,每区约 500 行
|
|
80
|
+
npartitions = max(1, total_rows // 500)
|
|
81
|
+
merged = merged.set_index('date', npartitions=npartitions)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
全部数据(~36000 行)
|
|
86
|
+
┌────────┬────────┬────────┬────────┬────────┬────────┬────────┐
|
|
87
|
+
│分区 0 │分区 1 │分区 2 │分区 3 │分区 4 │分区 5 │分区 6 │
|
|
88
|
+
│~500 行 │~500 行 │~500 行 │~500 行 │~500 行 │~500 行 │~500 行 │
|
|
89
|
+
│第1-2天 │第2-3天 │... │... │... │... │最后几天│
|
|
90
|
+
└────────┴────────┴────────┴────────┴────────┴────────┴────────┘
|
|
91
|
+
│ │ │
|
|
92
|
+
▼ ▼ ▼
|
|
93
|
+
分批 compute() → 每批~500行 → 内存释放
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 5. 迭代:逐分区 compute + DataAccessor 接续
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
for i in range(npartitions):
|
|
100
|
+
partition = merged.get_partition(i).compute()
|
|
101
|
+
|
|
102
|
+
for date, row in partition.iterrows():
|
|
103
|
+
factors = parse_row(row) # 还原 {factor: {symbol: value}}
|
|
104
|
+
|
|
105
|
+
for factor, data in factors.items():
|
|
106
|
+
strategy._data.push(date, factor, data)
|
|
107
|
+
|
|
108
|
+
strategy.on_bar(date)
|
|
109
|
+
broker.on_bar()
|
|
110
|
+
|
|
111
|
+
# 分区处理完后,partition 变量被回收
|
|
112
|
+
# DataAccessor 内部缓存(deque)保留历史窗口
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
关键点:
|
|
116
|
+
- **内存峰值 = 单分区(~500 行)+ DataAccessor 缓存(~250 bar)**
|
|
117
|
+
- 与总股票数解耦,只与分区大小有关
|
|
118
|
+
- DataAccessor 的 `deque(maxlen=N)` 自动丢弃过期数据
|
|
119
|
+
|
|
120
|
+
### 6. DataAccessor:跨分区缓存
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
class DataAccessor:
|
|
124
|
+
def __init__(self, max_window=250):
|
|
125
|
+
self._buffer = defaultdict(
|
|
126
|
+
lambda: defaultdict(
|
|
127
|
+
lambda: deque(maxlen=max_window)
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
self._dates = deque(maxlen=max_window)
|
|
131
|
+
|
|
132
|
+
def push(self, date, factor, data):
|
|
133
|
+
# 记录日期(去重)
|
|
134
|
+
if date != self._last_date:
|
|
135
|
+
self._dates.append(date)
|
|
136
|
+
self._last_date = date
|
|
137
|
+
# 推入每个股票的值
|
|
138
|
+
for symbol, value in data.items():
|
|
139
|
+
self._buffer[factor][symbol].append(value)
|
|
140
|
+
|
|
141
|
+
def get(self, factor):
|
|
142
|
+
# 返回最新横截面
|
|
143
|
+
return pd.Series({
|
|
144
|
+
sym: vals[-1]
|
|
145
|
+
for sym, vals in self._buffer[factor].items()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
def history(self, factor, window):
|
|
149
|
+
# 返回历史 DataFrame
|
|
150
|
+
data = {
|
|
151
|
+
sym: list(vals)[-window:]
|
|
152
|
+
for sym, vals in self._buffer[factor].items()
|
|
153
|
+
}
|
|
154
|
+
return pd.DataFrame(data, index=list(self._dates)[-window:])
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
DataAccessor 的生命周期跨越所有分区——它在 `Backtest.run()` 开始时创建,在回测结束时销毁。
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 性能对比
|
|
162
|
+
|
|
163
|
+
在真实数据上的测试(3 只股票,8604 bar):
|
|
164
|
+
|
|
165
|
+
| 方式 | 内存峰值 | 耗时 |
|
|
166
|
+
|------|----------|------|
|
|
167
|
+
| 传统 pandas 全量加载 | ~300 MB | 8-10s |
|
|
168
|
+
| dask 分区迭代(500行/区) | ~5 MB | 11s |
|
|
169
|
+
|
|
170
|
+
性能差异在 300 只股票时会更加显著:
|
|
171
|
+
|
|
172
|
+
| 股票数 | 传统方式 | dask 分区 | 优势 |
|
|
173
|
+
|--------|----------|-----------|------|
|
|
174
|
+
| 10 只 | ~100 MB | ~5 MB | 20x 内存 |
|
|
175
|
+
| 100 只 | ~1 GB | ~5 MB | 200x 内存 |
|
|
176
|
+
| 300 只 | ~3+ GB | ~5 MB | 600x+ 内存 |
|
|
177
|
+
|
|
178
|
+
!!! tip "不在本地运行?"
|
|
179
|
+
QKA Cloud 使用轻量级沙箱执行回测,dask 分区迭代保证即使在小内存服务器上也能稳定运行 300+ 只股票。
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 实际建议
|
|
184
|
+
|
|
185
|
+
- **< 50 只股票**:用默认模式即可,dask 不会有明显优势
|
|
186
|
+
- **50-200 只股票**:dask 分区迭代自动启用(`Data.get(lazy=True)`),无需额外配置
|
|
187
|
+
- **200+ 只股票**:强烈建议使用,内存占用几乎不变,仅增加少许计算时间
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## 为什么不直接用 pandas?
|
|
192
|
+
|
|
193
|
+
pandas 在 300 只股票 × 5 年数据时已经需要 3+ GB 内存。回测过程中还有:
|
|
194
|
+
|
|
195
|
+
- 策略内部的数据副本(如计算均线时的 rolling 窗口)
|
|
196
|
+
- Broker 的交易记录 DataFrame
|
|
197
|
+
- 多只股票的中间计算结果
|
|
198
|
+
|
|
199
|
+
这些都叠加在初始数据之上,很容易超过 8 GB。
|
|
200
|
+
|
|
201
|
+
**dask 的分区迭代将"所有数据都在内存"的前提改为"只有当前窗口的数据在内存"**,从根本上解决了内存问题。
|
|
202
|
+
|
|
203
|
+
而 DataAccessor 的滚动窗口缓存(deque)确保了策略可以正常查询历史数据,不受分区边界的限制。
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# 核心概念
|
|
2
|
+
|
|
3
|
+
了解 QKA 的架构设计和核心抽象。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 整体架构
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
qka/
|
|
11
|
+
├── core/ ← 核心模块
|
|
12
|
+
│ ├── data.py # 数据获取与缓存
|
|
13
|
+
│ ├── backtest.py # 回测引擎(含 dask 分区迭代)
|
|
14
|
+
│ ├── strategy.py # 策略基类
|
|
15
|
+
│ ├── accessor.py # 滚动窗口数据访问器(DataAccessor)
|
|
16
|
+
│ ├── broker.py # 虚拟经纪商(交易执行 + 费用计算)
|
|
17
|
+
│ └── report.py # HTML 报告生成
|
|
18
|
+
├── brokers/ # QMT 实盘对接
|
|
19
|
+
├── mcp/ # MCP 服务
|
|
20
|
+
├── server/ # Web 服务
|
|
21
|
+
├── utils/ # 工具模块
|
|
22
|
+
└── cli.py # 命令行
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 核心流程
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Data ──> Backtest.run() ──> dask 分区迭代 ──> Strategy.on_bar(date) ──> Broker.buy/sell()
|
|
31
|
+
│ │
|
|
32
|
+
v v
|
|
33
|
+
summary() / report() DataAccessor (self.get / self.history)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**流程说明:**
|
|
37
|
+
|
|
38
|
+
1. **`Data`** 从 baostock 下载并缓存数据(每只股票独立 parquet)
|
|
39
|
+
2. **`Backtest`** 用 dask 合并为 lazy DataFrame,按时间分区分批计算
|
|
40
|
+
3. 每个分区 compute() 后,逐 bar 调用 `strategy.on_bar(date)`
|
|
41
|
+
4. **`Strategy.on_bar(date)`** 用 `self.get('close')` / `self.history('close', 20)` 获取数据
|
|
42
|
+
5. **`Broker.buy/sell()`** 执行交易,自动扣费(佣金 + 印花税 + 滑点)
|
|
43
|
+
6. 回测结束后,**`summary()`** 和 **`report()`** 输出结果
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 关键抽象
|
|
48
|
+
|
|
49
|
+
### `DataAccessor` — 滚动窗口数据访问器
|
|
50
|
+
|
|
51
|
+
DataAccessor 是策略访问数据的中枢。它在回测过程中维护一个滚动窗口缓存:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
push(date, 'close', {symbol: value})
|
|
55
|
+
│
|
|
56
|
+
▼
|
|
57
|
+
┌─────────────────────────────────────┐
|
|
58
|
+
│ DataAccessor 内部缓存 │
|
|
59
|
+
│ │
|
|
60
|
+
│ close: { │
|
|
61
|
+
│ '000001.SZ': deque(maxlen=250), │
|
|
62
|
+
│ '600000.SH': deque(maxlen=250), │
|
|
63
|
+
│ ... │
|
|
64
|
+
│ } │
|
|
65
|
+
│ volume: { ... } │
|
|
66
|
+
└─────────────────────────────────────┘
|
|
67
|
+
│ │
|
|
68
|
+
▼ ▼
|
|
69
|
+
self.get() self.history()
|
|
70
|
+
(横截面) (时间序列)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
#### `self.get(factor)` — 横截面数据
|
|
74
|
+
|
|
75
|
+
返回**当前 bar** 所有股票的某个因子值:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
close = self.get('close')
|
|
79
|
+
# pd.Series:
|
|
80
|
+
# 000001.SZ 10.50
|
|
81
|
+
# 600000.SH 8.32
|
|
82
|
+
# 000002.SZ 15.68
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
#### `self.history(factor, window)` — 历史序列
|
|
86
|
+
|
|
87
|
+
返回过去 N 个交易日的历史数据:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
hist = self.history('close', 20)
|
|
91
|
+
# pd.DataFrame,行=日期,列=股票代码:
|
|
92
|
+
# 000001.SZ 600000.SH 000002.SZ
|
|
93
|
+
# 2024-01-02 10.2 8.12 15.21
|
|
94
|
+
# 2024-01-03 10.5 8.32 15.68
|
|
95
|
+
# ... ... ... ...
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
### `Broker` — 虚拟经纪商
|
|
101
|
+
|
|
102
|
+
管理资金、持仓和交易,自动处理:
|
|
103
|
+
|
|
104
|
+
| 费用 | 方向 | 默认费率 | 最低收费 |
|
|
105
|
+
|------|------|----------|----------|
|
|
106
|
+
| 佣金 | 双向 | 万2.5 | 5 元 |
|
|
107
|
+
| 印花税 | 卖出 | 万5 | 无 |
|
|
108
|
+
| 滑点 | 双向 | 0.1% | 无 |
|
|
109
|
+
|
|
110
|
+
可在 `Broker` 初始化时自定义:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
self.broker = Broker(
|
|
114
|
+
initial_cash=1_000_000,
|
|
115
|
+
commission_rate=0.0001, # 万1
|
|
116
|
+
stamp_duty_rate=0.0005, # 万5(A股固定)
|
|
117
|
+
slippage=0.0005, # 0.05%
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
### `Backtest` 三件套
|
|
124
|
+
|
|
125
|
+
| 方法 | 功能 |
|
|
126
|
+
|------|------|
|
|
127
|
+
| `run(benchmark=None)` | 执行回测,可选基准对比 |
|
|
128
|
+
| `summary()` | 打印绩效指标,返回 dict |
|
|
129
|
+
| `report(title='', output_path=None)` | 生成自包含 HTML 报告 |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 数据存储模型
|
|
134
|
+
|
|
135
|
+
每只股票的数据独立存储为 parquet 文件:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
datadir/
|
|
139
|
+
└── baostock/
|
|
140
|
+
└── 1d/
|
|
141
|
+
└── qfq/
|
|
142
|
+
├── 000001.SZ.parquet
|
|
143
|
+
├── 600000.SH.parquet
|
|
144
|
+
└── ...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
回测时,dask 将所有 parquet 合并为 lazy DataFrame。列名格式为 `{symbol}_{factor}`:
|
|
148
|
+
|
|
149
|
+
| 列名 | 含义 |
|
|
150
|
+
|------|------|
|
|
151
|
+
| `000001.SZ_close` | 平安银行收盘价 |
|
|
152
|
+
| `000001.SZ_volume` | 平安银行成交量 |
|
|
153
|
+
| `600000.SH_close` | 浦发银行收盘价 |
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 性能原理
|
|
158
|
+
|
|
159
|
+
QKA 的回测引擎使用 **dask 分区迭代** 处理大规模数据:
|
|
160
|
+
|
|
161
|
+
- **每只股票独立存储** → 增量下载天然支持
|
|
162
|
+
- **dask 合并** → 仅保存计算图,不加载到内存
|
|
163
|
+
- **按时间分区** → 每个分区约 500 bar,逐批 compute()
|
|
164
|
+
- **DataAccessor 跨分区** → deque 缓存延续历史数据
|
|
165
|
+
|
|
166
|
+
这种设计让 QKA 可以高效处理数百只股票数年数据,同时保持单机运行。详见 [性能优化](../advanced/performance.md)。
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 设计原则
|
|
171
|
+
|
|
172
|
+
1. **开箱即用** — 三行代码跑回测,不折腾环境
|
|
173
|
+
2. **真实成本** — 佣金、印花税、滑点默认开启,回测结果贴近实盘
|
|
174
|
+
3. **结果可见** — `bt.report()` 生成自包含 HTML,手机 PC 都能看
|
|
175
|
+
4. **专注 A 股** — 费率、交易规则、基准对比都针对 A 股市场
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# 第一个策略
|
|
2
|
+
|
|
3
|
+
本指南带你从零跑通第一个策略。整个过程只需要几步。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 步骤 1:获取数据
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from qka import Data
|
|
11
|
+
|
|
12
|
+
data = Data(symbols=['000001.SZ']) # 平安银行
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`Data` 会自动从 **baostock** 下载数据并缓存到本地(`datadir/` 目录),下次直接读缓存,不用重复下载。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 步骤 2:定义策略
|
|
20
|
+
|
|
21
|
+
所有策略都继承 `Strategy` 基类,实现 `on_bar(self, date)` 方法:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from qka import Strategy, Broker
|
|
25
|
+
|
|
26
|
+
class BuyAndHold(Strategy):
|
|
27
|
+
"""买入 100 股平安银行并持有"""
|
|
28
|
+
def __init__(self):
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.broker = Broker(initial_cash=100_000) # 10万元本金
|
|
31
|
+
self.bought = False
|
|
32
|
+
|
|
33
|
+
def on_bar(self, date):
|
|
34
|
+
"""
|
|
35
|
+
每个交易日调用一次。
|
|
36
|
+
|
|
37
|
+
用 self.get() 获取当前行情,self.history() 获取历史序列。
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
date: 当前交易日(pd.Timestamp)
|
|
41
|
+
"""
|
|
42
|
+
close = self.get('close')
|
|
43
|
+
if close is None or close.empty:
|
|
44
|
+
return
|
|
45
|
+
if not self.bought and '000001.SZ' in close.index:
|
|
46
|
+
price = float(close['000001.SZ'])
|
|
47
|
+
if price > 0:
|
|
48
|
+
self.broker.buy('000001.SZ', price, 100)
|
|
49
|
+
self.bought = True
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 关键 API
|
|
53
|
+
|
|
54
|
+
| 方法 | 功能 | 返回 |
|
|
55
|
+
|------|------|------|
|
|
56
|
+
| `self.get('close')` | 当前 bar 所有股票收盘价 | `pd.Series`(index=股票代码) |
|
|
57
|
+
| `self.history('close', 20)` | 过去 N 日收盘价历史 | `pd.DataFrame`(行=日期,列=股票代码) |
|
|
58
|
+
|
|
59
|
+
!!! tip "不再是闭包"
|
|
60
|
+
与旧版本不同,`on_bar` 不再接收 `get` 参数。所有数据通过 `self.get()` 和 `self.history()` 访问,代码更简洁一致。
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 步骤 3:运行回测
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from qka import Backtest
|
|
68
|
+
|
|
69
|
+
bt = Backtest(data, BuyAndHold())
|
|
70
|
+
bt.run(benchmark='000300.SH') # 对比沪深300
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`bt.run()` 会:
|
|
74
|
+
1. 按日期遍历所有数据
|
|
75
|
+
2. 每天调用策略的 `on_bar(date)` 方法
|
|
76
|
+
3. 自动记录资金、持仓和交易历史
|
|
77
|
+
4. 如果指定了 `benchmark`,自动下载基准数据
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 步骤 4:查看结果
|
|
82
|
+
|
|
83
|
+
### 绩效指标
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
bt.summary()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
输出示例:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
=======================================================
|
|
93
|
+
回测绩效报告
|
|
94
|
+
=======================================================
|
|
95
|
+
初始资金: RMB 100,000.00
|
|
96
|
+
最终资产: RMB 156,283.45
|
|
97
|
+
总收益率: +56.28%
|
|
98
|
+
年化收益率: +8.34%
|
|
99
|
+
夏普比率: 0.52
|
|
100
|
+
最大回撤: -42.37%
|
|
101
|
+
胜率: 100.00%
|
|
102
|
+
总手续费: RMB 67.50
|
|
103
|
+
=======================================================
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### HTML 报告
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
bt.report(title='买入持有策略')
|
|
110
|
+
# 自动保存到 reports/ 下
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
报告包含:
|
|
114
|
+
- 8 个核心指标卡片
|
|
115
|
+
- 净值曲线(含基准对比)+ 回撤曲线
|
|
116
|
+
- 月度收益率热力图
|
|
117
|
+
- 交易明细表
|
|
118
|
+
- 回撤分析表
|
|
119
|
+
|
|
120
|
+
在手机上也能正常查看——窄屏下表格自动变为堆叠卡片布局。
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 完整代码
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from qka import Data, Strategy, Broker, Backtest
|
|
128
|
+
|
|
129
|
+
class BuyAndHold(Strategy):
|
|
130
|
+
def __init__(self):
|
|
131
|
+
super().__init__()
|
|
132
|
+
self.broker = Broker(initial_cash=100_000)
|
|
133
|
+
self.bought = False
|
|
134
|
+
|
|
135
|
+
def on_bar(self, date):
|
|
136
|
+
close = self.get('close')
|
|
137
|
+
if close is None or close.empty:
|
|
138
|
+
return
|
|
139
|
+
if not self.bought and '000001.SZ' in close.index:
|
|
140
|
+
price = float(close['000001.SZ'])
|
|
141
|
+
if price > 0:
|
|
142
|
+
self.broker.buy('000001.SZ', price, 100)
|
|
143
|
+
self.bought = True
|
|
144
|
+
|
|
145
|
+
data = Data(symbols=['000001.SZ'])
|
|
146
|
+
bt = Backtest(data, BuyAndHold())
|
|
147
|
+
bt.run(benchmark='000300.SH')
|
|
148
|
+
bt.summary()
|
|
149
|
+
bt.report(title='买入持有策略')
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 进阶:用 `self.history()` 计算均线
|
|
155
|
+
|
|
156
|
+
用 `self.history()` 获取过去 N 天的收盘价序列:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from qka import Strategy, Broker
|
|
160
|
+
import numpy as np
|
|
161
|
+
|
|
162
|
+
class MaCross(Strategy):
|
|
163
|
+
"""5日均线上穿20日均线买入"""
|
|
164
|
+
def __init__(self):
|
|
165
|
+
super().__init__()
|
|
166
|
+
self.broker = Broker(initial_cash=100_000)
|
|
167
|
+
self.bought = False
|
|
168
|
+
|
|
169
|
+
def on_bar(self, date):
|
|
170
|
+
hist = self.history('close', 20)
|
|
171
|
+
if len(hist) < 20:
|
|
172
|
+
return # 数据不足,跳过
|
|
173
|
+
|
|
174
|
+
# hist 是 DataFrame,行=日期,列=股票代码
|
|
175
|
+
ma5 = hist.iloc[-5:].mean()
|
|
176
|
+
ma20 = hist.mean()
|
|
177
|
+
|
|
178
|
+
for sym in hist.columns:
|
|
179
|
+
close = self.get('close')
|
|
180
|
+
if close is None or sym not in close.index:
|
|
181
|
+
continue
|
|
182
|
+
price = float(close[sym])
|
|
183
|
+
if price <= 0:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
if ma5[sym] > ma20[sym] and not self.bought:
|
|
187
|
+
self.broker.buy(sym, price, 100)
|
|
188
|
+
self.bought = True
|
|
189
|
+
elif ma5[sym] < ma20[sym] and self.bought:
|
|
190
|
+
if sym in self.broker.positions:
|
|
191
|
+
self.broker.sell(sym, price, self.broker.positions[sym]['size'])
|
|
192
|
+
self.bought = False
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## 下一步
|
|
198
|
+
|
|
199
|
+
- 学习 [核心概念](concepts.md) — 架构设计、DataAccessor 数据流
|
|
200
|
+
- 学习 [回测分析](../user-guide/backtest.md) — 费用设置、绩效指标详解
|
|
201
|
+
- 探索 [性能优化](../advanced/performance.md) — dask 分区迭代原理
|
|
@@ -22,13 +22,13 @@ bt.report() # 生成 HTML 报告(手机也能看)
|
|
|
22
22
|
|
|
23
23
|
| 特性 | 说明 |
|
|
24
24
|
|------|------|
|
|
25
|
-
| **数据** | 基于 akshare
|
|
26
|
-
| **策略** | 继承 `Strategy` 基类,实现 `on_bar`
|
|
27
|
-
| **回测** |
|
|
25
|
+
| **数据** | 基于 baostock/akshare,自动缓存,支持大规模数据懒加载 |
|
|
26
|
+
| **策略** | 继承 `Strategy` 基类,实现 `on_bar(self, date)`,使用 `self.get()` / `self.history()` |
|
|
27
|
+
| **回测** | 日线级别,多标的横截面,支持 dask 分区迭代(300+ 股票无压力) |
|
|
28
28
|
| **费用** | 佣金(万2.5,最低5元)+ 印花税(万5)+ 滑点(0.1%) |
|
|
29
29
|
| **基准** | 一键对比沪深300 |
|
|
30
30
|
| **报告** | 自包含 HTML,指标卡片 + 净值曲线 + 月度热力图 + 交易明细,适配手机 |
|
|
31
|
-
|
|
|
31
|
+
| **性能** | 滚动窗口数据访问器(DataAccessor),dask 分区迭代,内存峰值与总数据量解耦 |
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
@@ -44,21 +44,20 @@ uv sync
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
```python
|
|
47
|
-
from qka
|
|
48
|
-
from qka.core.strategy import Strategy
|
|
49
|
-
from qka.core.backtest import Backtest
|
|
50
|
-
from qka.core.broker import Broker
|
|
47
|
+
from qka import Data, Strategy, Broker, Backtest
|
|
51
48
|
|
|
52
49
|
# 1. 写策略
|
|
53
50
|
class MyStrategy(Strategy):
|
|
54
51
|
def __init__(self):
|
|
55
|
-
super().__init__(
|
|
52
|
+
super().__init__()
|
|
56
53
|
self.broker = Broker(initial_cash=100_000)
|
|
57
54
|
|
|
58
|
-
def on_bar(self, date
|
|
59
|
-
close = get('close')
|
|
55
|
+
def on_bar(self, date):
|
|
56
|
+
close = self.get('close')
|
|
57
|
+
if close is None or close.empty:
|
|
58
|
+
return
|
|
60
59
|
if '000001.SZ' in close.index:
|
|
61
|
-
self.broker.buy('000001.SZ', close['000001.SZ'], 1000)
|
|
60
|
+
self.broker.buy('000001.SZ', float(close['000001.SZ']), 1000)
|
|
62
61
|
|
|
63
62
|
# 2. 跑回测
|
|
64
63
|
data = Data(symbols=['000001.SZ', '600000.SH'])
|