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.
@@ -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()