hamuna-quant-cli 0.1.0.dev93__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.
- hamuna_quant_cli/README.md +117 -0
- hamuna_quant_cli/__init__.py +17 -0
- hamuna_quant_cli/__main__.py +978 -0
- hamuna_quant_cli/_market_fallback.py +82 -0
- hamuna_quant_cli/_metrics_15.py +342 -0
- hamuna_quant_cli/_test_akquant_parity.py +530 -0
- hamuna_quant_cli/akquant_data_adapter.py +295 -0
- hamuna_quant_cli/akquant_runner.py +620 -0
- hamuna_quant_cli/akquant_schema_adapter.py +443 -0
- hamuna_quant_cli/base_strategy.py +80 -0
- hamuna_quant_cli/cross_sectional_helpers.py +118 -0
- hamuna_quant_cli/live/__init__.py +25 -0
- hamuna_quant_cli/live/loader.py +121 -0
- hamuna_quant_cli/live/qmt_broker.py +683 -0
- hamuna_quant_cli/live/qmt_market.py +448 -0
- hamuna_quant_cli/live/runner.py +449 -0
- hamuna_quant_cli/prebuilt_downloader.py +263 -0
- hamuna_quant_cli/prebuilt_resolver.py +470 -0
- hamuna_quant_cli/qmt_translator.py +609 -0
- hamuna_quant_cli/runtime/__init__.py +2 -0
- hamuna_quant_cli/runtime/backtest.py +38 -0
- hamuna_quant_cli/runtime/cache.py +255 -0
- hamuna_quant_cli/runtime/discipline.py +359 -0
- hamuna_quant_cli/runtime/http_client.py +209 -0
- hamuna_quant_cli/runtime/s3client.py +109 -0
- hamuna_quant_cli/runtime/server_client.py +285 -0
- hamuna_quant_cli/scripts/server.json +4 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/METADATA +154 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/RECORD +32 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/WHEEL +5 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/entry_points.txt +2 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""ADR-0040 Phase B — parity test (v2 自有版本, B8).
|
|
2
|
+
|
|
3
|
+
来源: 原仓根 `hamuna_quant_cli/references/_test_akquant_parity.py`.
|
|
4
|
+
本文件是 wholesale copy, 算法/STRATEGY_DEFS 不变. 迁移原因: v2 skill 独立分发.
|
|
5
|
+
唯一改动: line 493 `from references.akquant_runner` → `from hamuna_quant_cli.references.akquant_runner` (Round 14 顶层包化后).
|
|
6
|
+
|
|
7
|
+
跑 5 个内置 strategy × 2 engine 在同 data / 同 period 下, 对比 15 metrics 差异分布,
|
|
8
|
+
给出 p50/p95 容差, 落 `bench_out/parity_<ts>.json`.
|
|
9
|
+
|
|
10
|
+
5 内置 strategy (每个都有 hamuna handlebar 版 + akquant on_bar 版):
|
|
11
|
+
- buy_and_hold (永久持仓, 无信号)
|
|
12
|
+
- ma_cross_5_20 (5/20 均线穿越, 经典)
|
|
13
|
+
- momentum_20d (20 日动量, top-in bottom-out)
|
|
14
|
+
- mean_reversion_z (z-score > 2 反向)
|
|
15
|
+
- random_with_seed (固定 seed 随机, 对照基线)
|
|
16
|
+
|
|
17
|
+
容差计算:
|
|
18
|
+
对 15 metrics 各算 5 strategy 的相对差异 `abs(h - a) / max(|h|, 1e-9)`,
|
|
19
|
+
取 p50 / p95 → 落 _PARITY_TOLERANCE dict (硬编码供后续 CI 用).
|
|
20
|
+
|
|
21
|
+
Ponytail ceiling: 单标的 universe (1 只稳定大票避免生存偏差).
|
|
22
|
+
多标的 parity 在 Phase C 接 multi-symbol 时再跑.
|
|
23
|
+
|
|
24
|
+
Open question 探针 (Q1/Q2/Q3 一次性跑):
|
|
25
|
+
_probe_price_limit() - 涨跌停: 3 只票 + 已知涨停日 limit-buy
|
|
26
|
+
_probe_multi_symbol() - 多标: 3 只 buy_and_hold
|
|
27
|
+
_probe_volume_limit() - volume_limit_pct: 默认 vs 显式 0.10
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import math
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
import time
|
|
37
|
+
from datetime import datetime
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
import pandas as pd
|
|
42
|
+
|
|
43
|
+
OUT_DIR = Path('bench_out')
|
|
44
|
+
OUT_DIR.mkdir(exist_ok=True)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---- 容差 (Q4 calibration 一次跑后写死) ----
|
|
48
|
+
# Phase B calibration 落盘 (parity_<ts>.json 跑出, 2026-08-13):
|
|
49
|
+
# 5 strategy × 600000.SH × 2024-07~2025-12, 真实 prebuilt 数据.
|
|
50
|
+
# 默认 inf = 不强校验; calibration 跑后由 commiter 决定哪些 metric 写死阈值.
|
|
51
|
+
# 当前 calibration 输出 (见 bench_out/parity_20260813_213416.json):
|
|
52
|
+
_PARITY_TOLERANCE: dict[str, dict[str, float]] = {
|
|
53
|
+
'total_return': {'p50': 0.0002, 'p95': 0.0005, 'n': 5},
|
|
54
|
+
'sharpe': {'p50': 0.6110, 'p95': 1.5481, 'n': 5},
|
|
55
|
+
'max_drawdown': {'p50': 0.0003, 'p95': 0.0006, 'n': 5},
|
|
56
|
+
'volatility': {'p50': 0.0002, 'p95': 0.0003, 'n': 5},
|
|
57
|
+
'win_rate': {'p50': 50.0000, 'p95': 58.5556, 'n': 5},
|
|
58
|
+
'sortino': {'p50': 0.4059, 'p95': 1.5653, 'n': 5},
|
|
59
|
+
'calmar': {'p50': 0.5244, 'p95': 0.8792, 'n': 5},
|
|
60
|
+
'var_95': {'p50': 0.0000, 'p95': 0.0000, 'n': 5},
|
|
61
|
+
'annual_volatility': {'p50': 0.0002, 'p95': 0.0003, 'n': 5},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _tolerance_for(metric: str) -> tuple[float, float]:
|
|
66
|
+
t = _PARITY_TOLERANCE.get(metric)
|
|
67
|
+
if not t:
|
|
68
|
+
return (float('inf'), float('inf'))
|
|
69
|
+
return (t.get('p50', float('inf')), t.get('p95', float('inf')))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---- 工具: 写 tmp strategy 文件 (hamuna + akquant 各一份) ----
|
|
73
|
+
HAMUNA_STRATEGY_TEMPLATE = '''"""hamuna handlebar 版 {name} (parity test).
|
|
74
|
+
|
|
75
|
+
QTS synthetic panel 不可用 → 用 prebuilt 数据 + QMT-style mock (driver.py 路径).
|
|
76
|
+
跑真实 driver.run_backtest, 不是合成 panel (Phase A bench 阶段是 synthetic,
|
|
77
|
+
Phase B parity 要真回测对齐).
|
|
78
|
+
"""
|
|
79
|
+
from __future__ import annotations
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
{hamuna_body}
|
|
83
|
+
'''
|
|
84
|
+
|
|
85
|
+
AKQUANT_STRATEGY_TEMPLATE = '''"""akquant on_bar 版 {name} (parity test)."""
|
|
86
|
+
from __future__ import annotations
|
|
87
|
+
|
|
88
|
+
from akquant import Strategy
|
|
89
|
+
import numpy as np
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
{akquant_body}
|
|
93
|
+
'''
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---- 5 strategy 定义 (双实现) ----
|
|
97
|
+
STRATEGY_DEFS = [
|
|
98
|
+
{
|
|
99
|
+
'name': 'buy_and_hold',
|
|
100
|
+
'hamuna_body': '''
|
|
101
|
+
def init(C):
|
|
102
|
+
C._target_value = float(C.capital)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def handlebar(C):
|
|
106
|
+
if not hasattr(C, "_bought"):
|
|
107
|
+
C._bought = False
|
|
108
|
+
if not C._bought:
|
|
109
|
+
sym = C.universe[0] if hasattr(C, "universe") and C.universe else C.stockcode
|
|
110
|
+
if not sym:
|
|
111
|
+
return
|
|
112
|
+
# strategy_cli.fundamental.data 已被 hamuna_quant_cli 独立化 (Round 14, 2026-08-18)
|
|
113
|
+
bars = m.get_market_data_ex([sym], "1d", str(C.cur_date), str(C.cur_date))
|
|
114
|
+
if not bars.get(sym):
|
|
115
|
+
return
|
|
116
|
+
price = bars[sym][0]["close"]
|
|
117
|
+
qty = int(C.capital / price / 100) * 100
|
|
118
|
+
if qty > 0:
|
|
119
|
+
C.passorder(0, 1101, "test", sym, 5, 0, price, qty, "", 0, "", C)
|
|
120
|
+
C._bought = True
|
|
121
|
+
''',
|
|
122
|
+
'akquant_body': '''
|
|
123
|
+
class StrategyImpl(Strategy):
|
|
124
|
+
warmup_period = 1
|
|
125
|
+
def on_bar(self, bar):
|
|
126
|
+
if self.get_position(bar.symbol) == 0:
|
|
127
|
+
self.buy(bar.symbol, 100)
|
|
128
|
+
''',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
'name': 'ma_cross_5_20',
|
|
132
|
+
'hamuna_body': '''
|
|
133
|
+
FAST, SLOW = 5, 20
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def init(C):
|
|
137
|
+
C._fast = FAST
|
|
138
|
+
C._slow = SLOW
|
|
139
|
+
C._prev_fast = None
|
|
140
|
+
C._prev_slow = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def handlebar(C):
|
|
144
|
+
# strategy_cli.fundamental.data 已被 hamuna_quant_cli 独立化 (Round 14, 2026-08-18)
|
|
145
|
+
sym = C.stockcode
|
|
146
|
+
bars = m.get_market_data_ex([sym], "1d", str(int(C.cur_date) - 30), str(C.cur_date))
|
|
147
|
+
if not bars.get(sym) or len(bars[sym]) < SLOW:
|
|
148
|
+
return
|
|
149
|
+
closes = [b["close"] for b in bars[sym]][-SLOW:]
|
|
150
|
+
fast = sum(closes[-FAST:]) / FAST
|
|
151
|
+
slow = sum(closes) / SLOW
|
|
152
|
+
pos = C.position_holding.get(sym, 0) if hasattr(C, "position_holding") else 0
|
|
153
|
+
if C._prev_fast is not None and C._prev_slow is not None:
|
|
154
|
+
if fast > slow and C._prev_fast <= C._prev_slow and pos == 0:
|
|
155
|
+
price = closes[-1]
|
|
156
|
+
qty = int(C.capital / price / 100) * 100
|
|
157
|
+
if qty > 0:
|
|
158
|
+
C.passorder(0, 1101, "test", sym, 5, 0, price, qty, "", 0, "", C)
|
|
159
|
+
elif fast < slow and C._prev_fast >= C._prev_slow and pos > 0:
|
|
160
|
+
price = closes[-1]
|
|
161
|
+
C.passorder(1, 1101, "test", sym, 5, 0, price, pos, "", 0, "", C)
|
|
162
|
+
C._prev_fast = fast
|
|
163
|
+
C._prev_slow = slow
|
|
164
|
+
''',
|
|
165
|
+
'akquant_body': '''
|
|
166
|
+
class StrategyImpl(Strategy):
|
|
167
|
+
warmup_period = 21
|
|
168
|
+
def __init__(self, fast=5, slow=20):
|
|
169
|
+
self.fast = fast
|
|
170
|
+
self.slow = slow
|
|
171
|
+
def on_bar(self, bar):
|
|
172
|
+
closes = self.get_history(self.slow, bar.symbol, "close")
|
|
173
|
+
if len(closes) < self.slow:
|
|
174
|
+
return
|
|
175
|
+
fast_ma = float(np.mean(closes[-self.fast:]))
|
|
176
|
+
slow_ma = float(np.mean(closes))
|
|
177
|
+
pos = self.get_position(bar.symbol)
|
|
178
|
+
if fast_ma > slow_ma and pos == 0:
|
|
179
|
+
self.buy(bar.symbol, 100)
|
|
180
|
+
elif fast_ma < slow_ma and pos > 0:
|
|
181
|
+
self.sell(bar.symbol, pos)
|
|
182
|
+
''',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
'name': 'momentum_20d',
|
|
186
|
+
'hamuna_body': '''
|
|
187
|
+
def handlebar(C):
|
|
188
|
+
# strategy_cli.fundamental.data 已被 hamuna_quant_cli 独立化 (Round 14, 2026-08-18)
|
|
189
|
+
sym = C.stockcode
|
|
190
|
+
bars = m.get_market_data_ex([sym], "1d", str(int(C.cur_date) - 25), str(C.cur_date))
|
|
191
|
+
if not bars.get(sym) or len(bars[sym]) < 21:
|
|
192
|
+
return
|
|
193
|
+
closes = [b["close"] for b in bars[sym]]
|
|
194
|
+
mom = (closes[-1] / closes[-21] - 1.0) if closes[-21] > 0 else 0.0
|
|
195
|
+
pos = C.position_holding.get(sym, 0) if hasattr(C, "position_holding") else 0
|
|
196
|
+
price = closes[-1]
|
|
197
|
+
if mom > 0.05 and pos == 0:
|
|
198
|
+
qty = int(C.capital / price / 100) * 100
|
|
199
|
+
if qty > 0:
|
|
200
|
+
C.passorder(0, 1101, "test", sym, 5, 0, price, qty, "", 0, "", C)
|
|
201
|
+
elif mom < -0.05 and pos > 0:
|
|
202
|
+
C.passorder(1, 1101, "test", sym, 5, 0, price, pos, "", 0, "", C)
|
|
203
|
+
''',
|
|
204
|
+
'akquant_body': '''
|
|
205
|
+
class StrategyImpl(Strategy):
|
|
206
|
+
warmup_period = 21
|
|
207
|
+
def on_bar(self, bar):
|
|
208
|
+
closes = self.get_history(21, bar.symbol, "close")
|
|
209
|
+
if len(closes) < 21:
|
|
210
|
+
return
|
|
211
|
+
mom = closes[-1] / closes[-21] - 1.0
|
|
212
|
+
pos = self.get_position(bar.symbol)
|
|
213
|
+
if mom > 0.05 and pos == 0:
|
|
214
|
+
self.buy(bar.symbol, 100)
|
|
215
|
+
elif mom < -0.05 and pos > 0:
|
|
216
|
+
self.sell(bar.symbol, pos)
|
|
217
|
+
''',
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
'name': 'mean_reversion_z',
|
|
221
|
+
'hamuna_body': '''
|
|
222
|
+
def handlebar(C):
|
|
223
|
+
# strategy_cli.fundamental.data 已被 hamuna_quant_cli 独立化 (Round 14, 2026-08-18)
|
|
224
|
+
sym = C.stockcode
|
|
225
|
+
bars = m.get_market_data_ex([sym], "1d", str(int(C.cur_date) - 25), str(C.cur_date))
|
|
226
|
+
if not bars.get(sym) or len(bars[sym]) < 21:
|
|
227
|
+
return
|
|
228
|
+
closes = np.array([b["close"] for b in bars[sym]])
|
|
229
|
+
mean = float(closes.mean())
|
|
230
|
+
std = float(closes.std()) + 1e-9
|
|
231
|
+
z = (closes[-1] - mean) / std
|
|
232
|
+
pos = C.position_holding.get(sym, 0) if hasattr(C, "position_holding") else 0
|
|
233
|
+
price = closes[-1]
|
|
234
|
+
if z < -2.0 and pos == 0:
|
|
235
|
+
qty = int(C.capital / price / 100) * 100
|
|
236
|
+
if qty > 0:
|
|
237
|
+
C.passorder(0, 1101, "test", sym, 5, 0, price, qty, "", 0, "", C)
|
|
238
|
+
elif z > 0 and pos > 0:
|
|
239
|
+
C.passorder(1, 1101, "test", sym, 5, 0, price, pos, "", 0, "", C)
|
|
240
|
+
''',
|
|
241
|
+
'akquant_body': '''
|
|
242
|
+
class StrategyImpl(Strategy):
|
|
243
|
+
warmup_period = 21
|
|
244
|
+
def on_bar(self, bar):
|
|
245
|
+
closes = self.get_history(21, bar.symbol, "close")
|
|
246
|
+
if len(closes) < 21:
|
|
247
|
+
return
|
|
248
|
+
arr = np.array(closes)
|
|
249
|
+
mean = float(arr.mean())
|
|
250
|
+
std = float(arr.std()) + 1e-9
|
|
251
|
+
z = (closes[-1] - mean) / std
|
|
252
|
+
pos = self.get_position(bar.symbol)
|
|
253
|
+
if z < -2.0 and pos == 0:
|
|
254
|
+
self.buy(bar.symbol, 100)
|
|
255
|
+
elif z > 0 and pos > 0:
|
|
256
|
+
self.sell(bar.symbol, pos)
|
|
257
|
+
''',
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
'name': 'random_with_seed',
|
|
261
|
+
'hamuna_body': '''
|
|
262
|
+
import random
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def init(C):
|
|
266
|
+
random.seed(42)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def handlebar(C):
|
|
270
|
+
# strategy_cli.fundamental.data 已被 hamuna_quant_cli 独立化 (Round 14, 2026-08-18)
|
|
271
|
+
sym = C.stockcode
|
|
272
|
+
bars = m.get_market_data_ex([sym], "1d", str(C.cur_date), str(C.cur_date))
|
|
273
|
+
if not bars.get(sym):
|
|
274
|
+
return
|
|
275
|
+
price = bars[sym][0]["close"]
|
|
276
|
+
pos = C.position_holding.get(sym, 0) if hasattr(C, "position_holding") else 0
|
|
277
|
+
if random.random() > 0.5 and pos == 0:
|
|
278
|
+
qty = int(C.capital / price / 100) * 100
|
|
279
|
+
if qty > 0:
|
|
280
|
+
C.passorder(0, 1101, "test", sym, 5, 0, price, qty, "", 0, "", C)
|
|
281
|
+
elif random.random() < 0.5 and pos > 0:
|
|
282
|
+
C.passorder(1, 1101, "test", sym, 5, 0, price, pos, "", 0, "", C)
|
|
283
|
+
''',
|
|
284
|
+
'akquant_body': '''
|
|
285
|
+
import random
|
|
286
|
+
class StrategyImpl(Strategy):
|
|
287
|
+
warmup_period = 1
|
|
288
|
+
def on_bar(self, bar):
|
|
289
|
+
if not hasattr(self, "_seeded"):
|
|
290
|
+
random.seed(42)
|
|
291
|
+
self._seeded = True
|
|
292
|
+
pos = self.get_position(bar.symbol)
|
|
293
|
+
if random.random() > 0.5 and pos == 0:
|
|
294
|
+
self.buy(bar.symbol, 100)
|
|
295
|
+
elif random.random() < 0.5 and pos > 0:
|
|
296
|
+
self.sell(bar.symbol, pos)
|
|
297
|
+
''',
|
|
298
|
+
},
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---- 探针 (Q1/Q2/Q3) ----
|
|
303
|
+
def _probe_price_limit() -> dict:
|
|
304
|
+
"""Q1 探针: 涨跌停原生处理?
|
|
305
|
+
|
|
306
|
+
跑 3 只票 (主板 / 创业板 / 科创板) + 已知涨停日 limit-buy.
|
|
307
|
+
返回 {'akquant_rejects': bool, 'result_count': int, 'notes': str}.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
import akquant
|
|
311
|
+
except ImportError:
|
|
312
|
+
return {'akquant_rejects': None, 'result_count': 0, 'notes': 'akquant 未装'}
|
|
313
|
+
try:
|
|
314
|
+
import numpy as np
|
|
315
|
+
from akquant import Strategy, run_backtest
|
|
316
|
+
except ImportError:
|
|
317
|
+
return {'akquant_rejects': None, 'result_count': 0, 'notes': 'numpy/akquant 缺'}
|
|
318
|
+
|
|
319
|
+
class _LimitBuy(Strategy):
|
|
320
|
+
warmup_period = 1
|
|
321
|
+
def on_bar(self, bar):
|
|
322
|
+
if self.get_position(bar.symbol) == 0:
|
|
323
|
+
# 涨停价 (>close*1.10) 限价单, 应被拒
|
|
324
|
+
self.buy(bar.symbol, 100, price=bar.close * 1.20)
|
|
325
|
+
|
|
326
|
+
n = 60
|
|
327
|
+
dates = pd.date_range('2024-01-01', periods=n, freq='B')
|
|
328
|
+
df = pd.DataFrame({
|
|
329
|
+
'date': dates,
|
|
330
|
+
'open': 10.0, 'high': 10.0, 'low': 10.0, 'close': 10.0,
|
|
331
|
+
'volume': 1000, 'symbol': '600000.SH',
|
|
332
|
+
})
|
|
333
|
+
try:
|
|
334
|
+
r = run_backtest(strategy=_LimitBuy, data=df, symbols='600000.SH',
|
|
335
|
+
initial_cash=100_000.0, t_plus_one=True, show_progress=False)
|
|
336
|
+
except Exception as e: # noqa: BLE001
|
|
337
|
+
return {'akquant_rejects': None, 'result_count': 0, 'notes': f'run 失败: {e}'}
|
|
338
|
+
|
|
339
|
+
# 检查 orders_df: 拒单 status='rejected' (如果 akquant 给)
|
|
340
|
+
orders = getattr(r, 'orders_df', None)
|
|
341
|
+
if orders is None or orders.empty:
|
|
342
|
+
return {'akquant_rejects': None, 'result_count': 0,
|
|
343
|
+
'notes': 'akquant 未暴露 orders_df; Q1 待 Phase B v2'}
|
|
344
|
+
rejected = 0
|
|
345
|
+
if 'status' in orders.columns:
|
|
346
|
+
rejected = int((orders['status'] == 'rejected').sum())
|
|
347
|
+
return {'akquant_rejects': bool(rejected > 0),
|
|
348
|
+
'result_count': len(orders),
|
|
349
|
+
'notes': f'orders={len(orders)}, rejected={rejected}'}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _probe_multi_symbol() -> dict:
|
|
353
|
+
"""Q2 探针: 多标的同时 feed?"""
|
|
354
|
+
try:
|
|
355
|
+
from akquant import Strategy, run_backtest
|
|
356
|
+
except ImportError:
|
|
357
|
+
return {'multi_symbol_ok': None, 'trades_per_symbol': {}, 'notes': 'akquant 未装'}
|
|
358
|
+
|
|
359
|
+
class _BuyHold(Strategy):
|
|
360
|
+
warmup_period = 1
|
|
361
|
+
def on_bar(self, bar):
|
|
362
|
+
if self.get_position(bar.symbol) == 0:
|
|
363
|
+
self.buy(bar.symbol, 100)
|
|
364
|
+
|
|
365
|
+
n = 60
|
|
366
|
+
dates = pd.date_range('2024-01-01', periods=n, freq='B')
|
|
367
|
+
frames = []
|
|
368
|
+
for sym in ('600000.SH', '600036.SH', '000001.SZ'):
|
|
369
|
+
frames.append(pd.DataFrame({
|
|
370
|
+
'date': dates,
|
|
371
|
+
'open': 10.0, 'high': 10.0, 'low': 10.0, 'close': 10.0,
|
|
372
|
+
'volume': 1000, 'symbol': sym,
|
|
373
|
+
}))
|
|
374
|
+
df = pd.concat(frames, ignore_index=True)
|
|
375
|
+
try:
|
|
376
|
+
r = run_backtest(strategy=_BuyHold, data=df,
|
|
377
|
+
symbols=['600000.SH', '600036.SH', '000001.SZ'],
|
|
378
|
+
initial_cash=300_000.0, t_plus_one=True, show_progress=False)
|
|
379
|
+
except Exception as e: # noqa: BLE001
|
|
380
|
+
return {'multi_symbol_ok': False, 'trades_per_symbol': {},
|
|
381
|
+
'notes': f'akquant 不支持 multi-symbol: {e}'}
|
|
382
|
+
trades = getattr(r, 'trades_df', None)
|
|
383
|
+
n_unique = int(trades['symbol'].nunique()) if trades is not None and not trades.empty else 0
|
|
384
|
+
return {'multi_symbol_ok': n_unique >= 3, 'trades_per_symbol': n_unique,
|
|
385
|
+
'notes': f'trades.symbol.unique={n_unique}'}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _probe_volume_limit() -> dict:
|
|
389
|
+
"""Q3 探针: volume_limit_pct 默认值行为."""
|
|
390
|
+
try:
|
|
391
|
+
from akquant import Strategy, run_backtest
|
|
392
|
+
except ImportError:
|
|
393
|
+
return {'default_pct': None, 'notes': 'akquant 未装'}
|
|
394
|
+
|
|
395
|
+
class _BigOrder(Strategy):
|
|
396
|
+
warmup_period = 1
|
|
397
|
+
def on_bar(self, bar):
|
|
398
|
+
if self.get_position(bar.symbol) == 0:
|
|
399
|
+
self.buy(bar.symbol, bar.volume) # 试图吃光成交量
|
|
400
|
+
|
|
401
|
+
n = 30
|
|
402
|
+
dates = pd.date_range('2024-01-01', periods=n, freq='B')
|
|
403
|
+
df = pd.DataFrame({
|
|
404
|
+
'date': dates,
|
|
405
|
+
'open': 10.0, 'high': 10.0, 'low': 10.0, 'close': 10.0,
|
|
406
|
+
'volume': 100, 'symbol': '600000.SH', # volume 极小, 必触发截断
|
|
407
|
+
})
|
|
408
|
+
try:
|
|
409
|
+
# 默认 (akquant 内部默认 0.25)
|
|
410
|
+
r = run_backtest(strategy=_BigOrder, data=df, symbols='600000.SH',
|
|
411
|
+
initial_cash=100_000.0, t_plus_one=True, show_progress=False)
|
|
412
|
+
except Exception as e: # noqa: BLE001
|
|
413
|
+
return {'default_pct': None, 'notes': f'run 失败: {e}'}
|
|
414
|
+
trades = getattr(r, 'trades_df', None)
|
|
415
|
+
return {'default_pct': 0.25, # akquant 0.3.x 默认值, 文档化
|
|
416
|
+
'trades_filled': len(trades) if trades is not None else 0,
|
|
417
|
+
'notes': 'akquant 默认 0.25 (Phase B 默认走它)'}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ---- main: 跑 parity + 落 JSON ----
|
|
421
|
+
def run_parity_test(strategies: list[str] | None = None,
|
|
422
|
+
universe: list[str] | None = None,
|
|
423
|
+
start: str = '20230101', end: str = '20251231') -> dict:
|
|
424
|
+
"""跑 N strategy × 2 engine → 落 tolerance JSON.
|
|
425
|
+
|
|
426
|
+
注: hamuna QTS synthetic 已被 Q1-ADR-0040 标注为不可比 (合成 panel vs 真回测,
|
|
427
|
+
metrics 必然分叉). 本 parity test 主要跑 akquant 在不同 strategy 间的稳定性
|
|
428
|
+
(即 akquant_buy_and_hold vs akquant_ma_cross_5_20: 同 data, 不同 strategy 的
|
|
429
|
+
metrics 应符合"无信号 vs 有信号"的预期分布).
|
|
430
|
+
|
|
431
|
+
真正跨 engine parity 要等 Phase C 评估 akquant 是否替代 QTS synthetic.
|
|
432
|
+
"""
|
|
433
|
+
strategies = strategies or [s['name'] for s in STRATEGY_DEFS]
|
|
434
|
+
universe = universe or ['600000.SH']
|
|
435
|
+
|
|
436
|
+
runs: list[dict] = []
|
|
437
|
+
for strat_def in STRATEGY_DEFS:
|
|
438
|
+
if strat_def['name'] not in strategies:
|
|
439
|
+
continue
|
|
440
|
+
for engine in ('akquant',): # Phase B 仅 akquant 内部 cross-strategy
|
|
441
|
+
t0 = time.time()
|
|
442
|
+
try:
|
|
443
|
+
if engine == 'akquant':
|
|
444
|
+
r = _run_akquant_strategy(strat_def, start, end, universe)
|
|
445
|
+
else:
|
|
446
|
+
r = None
|
|
447
|
+
elapsed = time.time() - t0
|
|
448
|
+
runs.append({'strategy': strat_def['name'], 'engine': engine,
|
|
449
|
+
'metrics': r.get('metrics') if r else None,
|
|
450
|
+
'trades_count': len(r.get('trades', [])) if r else 0,
|
|
451
|
+
'elapsed_sec': round(elapsed, 3),
|
|
452
|
+
'error': None})
|
|
453
|
+
except Exception as e: # noqa: BLE001
|
|
454
|
+
elapsed = time.time() - t0
|
|
455
|
+
runs.append({'strategy': strat_def['name'], 'engine': engine,
|
|
456
|
+
'metrics': None, 'trades_count': 0,
|
|
457
|
+
'elapsed_sec': round(elapsed, 3),
|
|
458
|
+
'error': f'{type(e).__name__}: {e}'})
|
|
459
|
+
|
|
460
|
+
# tolerance: 单 engine 跨 strategy → cross-strategy sanity check (Phase B 简化)
|
|
461
|
+
metrics_keys = next((r['metrics'] for r in runs if r.get('metrics')), {}).keys()
|
|
462
|
+
tol: dict[str, dict[str, float]] = {}
|
|
463
|
+
for m in metrics_keys:
|
|
464
|
+
vals = [r['metrics'].get(m) for r in runs if r.get('metrics')]
|
|
465
|
+
vals = [v for v in vals if v is not None and not (isinstance(v, float) and math.isnan(v))]
|
|
466
|
+
if len(vals) >= 2:
|
|
467
|
+
arr = np.array([abs(v) for v in vals])
|
|
468
|
+
tol[m] = {
|
|
469
|
+
'p50': float(np.percentile(arr, 50)),
|
|
470
|
+
'p95': float(np.percentile(arr, 95)),
|
|
471
|
+
'n': len(vals),
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
out = {
|
|
475
|
+
'ts': datetime.now().isoformat(timespec='seconds'),
|
|
476
|
+
'universe': universe,
|
|
477
|
+
'period': {'start': start, 'end': end},
|
|
478
|
+
'strategies': strategies,
|
|
479
|
+
'runs': runs,
|
|
480
|
+
'_PARITY_TOLERANCE': tol,
|
|
481
|
+
'probes': {
|
|
482
|
+
'Q1_price_limit': _probe_price_limit(),
|
|
483
|
+
'Q2_multi_symbol': _probe_multi_symbol(),
|
|
484
|
+
'Q3_volume_limit': _probe_volume_limit(),
|
|
485
|
+
},
|
|
486
|
+
}
|
|
487
|
+
out_path = OUT_DIR / f'parity_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
|
488
|
+
out_path.write_text(json.dumps(out, indent=2, ensure_ascii=False, default=str),
|
|
489
|
+
encoding='utf-8')
|
|
490
|
+
print(f'→ {out_path}', file=sys.stderr)
|
|
491
|
+
return out
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _run_akquant_strategy(strat_def: dict, start: str, end: str,
|
|
495
|
+
universe: list[str]) -> dict:
|
|
496
|
+
"""跑单个 strategy × akquant."""
|
|
497
|
+
from ..akquant_runner import run_akquant_backtest # B7
|
|
498
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
499
|
+
strat_path = Path(tmp) / f"{strat_def['name']}.py"
|
|
500
|
+
strat_path.write_text(AKQUANT_STRATEGY_TEMPLATE.format(
|
|
501
|
+
name=strat_def['name'], akquant_body=strat_def['akquant_body']), encoding='utf-8')
|
|
502
|
+
cfg = {
|
|
503
|
+
'backtest_start': start,
|
|
504
|
+
'backtest_end': end,
|
|
505
|
+
'pool': {'a': {'codes': universe}},
|
|
506
|
+
'init_capital': 1_000_000.0,
|
|
507
|
+
}
|
|
508
|
+
return run_akquant_backtest(strat_path, cfg)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def main() -> None:
|
|
512
|
+
print(f'akquant parity @ {datetime.now().isoformat(timespec="seconds")}')
|
|
513
|
+
print(f' data: prebuilt 600000.SH 2023-2025 (需 prebuilt dataset 已构建)')
|
|
514
|
+
print(f' strategies: {[s["name"] for s in STRATEGY_DEFS]}')
|
|
515
|
+
out = run_parity_test()
|
|
516
|
+
print(f'\nsummary:')
|
|
517
|
+
for r in out['runs']:
|
|
518
|
+
status = 'OK' if not r['error'] else f'FAIL ({r["error"][:60]})'
|
|
519
|
+
n = r['trades_count']
|
|
520
|
+
print(f' {r["strategy"]:24s} {r["engine"]:10s} {status:20s} trades={n:3d} ({r["elapsed_sec"]}s)')
|
|
521
|
+
print(f'\nprobes:')
|
|
522
|
+
for q, v in out['probes'].items():
|
|
523
|
+
print(f' {q}: {v}')
|
|
524
|
+
print(f'\nPARITY_TOLERANCE (cross-strategy): {len(out["_PARITY_TOLERANCE"])} keys')
|
|
525
|
+
for k, v in list(out['_PARITY_TOLERANCE'].items())[:5]:
|
|
526
|
+
print(f' {k}: p50={v["p50"]:.4f}, p95={v["p95"]:.4f}, n={v["n"]}')
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
if __name__ == '__main__':
|
|
530
|
+
main()
|