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,620 @@
1
+ """ADR-0040 Phase B — akquant runner 单入口 (v2 自有版本, B7).
2
+
3
+ 来源: 原仓根 `hamuna_quant_cli/references/akquant_runner.py`.
4
+ 本文件是 wholesale copy, 算法/CFG 不变. 迁移原因: v2 skill 独立分发.
5
+ 唯一改动: 所有 `from references.X` → `from .X` (同包 import).
6
+
7
+ 公开 API:
8
+ run_akquant_backtest(strategy_path, cfg) -> dict
9
+
10
+ strategy_path: 含 `akquant.Strategy` 子类的 .py 文件路径 (on_bar API, NOT QMT handlebar).
11
+ cfg: 同 `hamuna_quant_cli run` 用的 CONFIG dict (backtest_start/end, pool, init_capital, ...).
12
+
13
+ 返回: hamuna `backtest_result.json` 形状 dict (13 顶层 key, 15 metrics; 见
14
+ strategy_cli/runtime/driver.py:113-128 + references/backtest-result.md).
15
+
16
+ 锁定 akquant 0.3.x schema (bench_20260813_201349.json). 升级前先跑
17
+ references/_test_akquant_parity.py 验证 5 strategy 容差在阈值内.
18
+
19
+ 失败模式:
20
+ FileNotFoundError: 数据集缺失 (universe 在 prebuilt 不存在, 或 start/end 在 window 外)
21
+ ImportError: akquant 未安装 (pip install akquant)
22
+ ValueError: cfg 缺必需 key / akquant.run_backtest 报参数错
23
+
24
+ Ponytail ceiling: 当前只支持日线 + 单标 / 命名池.
25
+ 5m/tick 走 v1 driver 路径 (`strategy_cli run`).
26
+ 多 symbol 走循环跑 (R3 mitigation, 但 runner 入口暂不阻断 — 等 _probe_multi_symbol 测).
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import importlib.util
31
+ import inspect
32
+ import os
33
+ import sys
34
+ import warnings
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+
39
+ # ---- strategy loader (沿 driver._load_strategy 模式, 但导 Strategy 类不导 handlebar) ----
40
+ def _load_akquant_strategy(strategy_path: str) -> Any:
41
+ """加载 strategy .py, 提取第一个 akquant.Strategy 子类.
42
+
43
+ 不复用 driver._load_strategy — 那是 QMT 路径 (mod.init / mod.handlebar / mod.CONFIG),
44
+ akquant 是类化 strategy (class MyStrategy(akquant.Strategy): on_bar(bar)).
45
+
46
+ ad-hoc 加载: spec_from_file_location, sys.path.insert 父目录 (兼容同目录 helper).
47
+ """
48
+ p = Path(strategy_path).resolve()
49
+ parent = str(p.parent)
50
+ if parent not in sys.path:
51
+ sys.path.insert(0, parent)
52
+ spec = importlib.util.spec_from_file_location('_hamuna_akquant_strat', strategy_path)
53
+ if spec is None or spec.loader is None:
54
+ raise ImportError(f'cannot load strategy at {strategy_path}')
55
+ mod = importlib.util.module_from_spec(spec)
56
+ sys.modules['_hamuna_akquant_strat'] = mod
57
+ spec.loader.exec_module(mod)
58
+
59
+ # 找 akquant.Strategy 子类 (用户可能放 class MyStrategy(akquant.Strategy) 或 (Strategy))
60
+ try:
61
+ import akquant
62
+ except ImportError as e:
63
+ raise ImportError(f'akquant 未安装: {e}. pip install akquant>=0.3.41,<0.4') from e
64
+ base_cls = akquant.Strategy
65
+
66
+ found = None
67
+ for name in dir(mod):
68
+ obj = getattr(mod, name)
69
+ if isinstance(obj, type) and issubclass(obj, base_cls) and obj is not base_cls:
70
+ found = obj
71
+ break
72
+ if found is None:
73
+ raise ValueError(
74
+ f'{strategy_path} 未找到 akquant.Strategy 子类 (期望 `class Xxx(akquant.Strategy): on_bar(...)`)')
75
+ return found
76
+
77
+
78
+ # ---- main entry ----
79
+ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
80
+ """跑 akquant 回测, 返回 hamuna `backtest_result.json` 形状 dict.
81
+
82
+ 0.3.x 加速: 见 SKILL.md 顶部 / engine-and-data.md §6 — runner 自动接
83
+ history_depth / start_time / lot_size / commission_policy, 用户 strategy 不用改.
84
+ """
85
+ import akquant
86
+
87
+ # cfg 必需 keys
88
+ for k in ('backtest_start', 'backtest_end'):
89
+ if k not in cfg:
90
+ raise ValueError(f'cfg 缺必需 key: {k}')
91
+
92
+ # universe 展开 (与 schema_adapter._cfg_universe 一致)
93
+ from .akquant_data_adapter import load_prebuilt_to_akquant # B4
94
+ from .akquant_schema_adapter import to_hamuna_result, HAMUNA_METRICS_15, normalize_symbol # B1
95
+ universe = [normalize_symbol(s) for s in _universe_from_cfg(cfg)] # 裸码 → 带后缀 (bundle stockCode 形态)
96
+ if not universe:
97
+ raise ValueError(f'cfg.universe / cfg.pool 为空: {cfg.get("pool")}')
98
+ if len(universe) > 1:
99
+ # R3 mitigation: akquant 多 symbol 实测前先 warn + 跑循环. parity test 会实测.
100
+ # 暂时让 akquant.run_backtest 自己决定 (实测接受 multi-symbol DataFrame).
101
+ pass
102
+
103
+ # data — Q1 兜底默认开 (cfg['price_limit_clamp']=False 可关)
104
+ use_clamp = bool(cfg.get('price_limit_clamp', True))
105
+ if use_clamp:
106
+ from .akquant_data_adapter import load_prebuilt_to_akquant_with_limits # B4
107
+ df = load_prebuilt_to_akquant_with_limits(
108
+ universe, cfg['backtest_start'], cfg['backtest_end'])
109
+ else:
110
+ df = load_prebuilt_to_akquant(universe, cfg['backtest_start'], cfg['backtest_end'])
111
+ if df.empty:
112
+ raise FileNotFoundError(
113
+ f'未取到 bar 数据 (universe={universe}, {cfg["backtest_start"]}-{cfg["backtest_end"]})')
114
+
115
+ # symbol_names: 从 prebuilt bundle 聚合 {stockCode: stockName}, 注入 cfg
116
+ # 让 schema_adapter.trades 填 symbol_name (对齐 hamuna_strategy driver._lookup_symbol_name)
117
+ if not cfg.get('_symbol_names'):
118
+ cfg['_symbol_names'] = _load_symbol_names_from_bundle(df)
119
+
120
+ # strategy class
121
+ strat_cls = _load_akquant_strategy(strategy_path)
122
+
123
+ # ===== 0.3 架构变更 (2026-08-18 简化): compute_factors / filter_symbols 接入引擎 =====
124
+ # 流程: 实例化 → compute_factors (直接传 prebuilt df, 引擎不包装 BacktestContext) →
125
+ # filter_symbols → 修整 df + symbols 喂 akquant.run_backtest.
126
+ # 老策略 (无 compute_factors / filter_symbols) 走默认 no-op, 行为不变 — 有 hasattr 检测.
127
+ # akquant 0.3.x 已一等支持 DataFrame/polars/pyarrow/dict 输入 — 不下沉 sub-adapter.
128
+ import pandas as _pd # noqa: F401
129
+ try:
130
+ strat_inst = strat_cls()
131
+ except Exception as e:
132
+ raise RuntimeError(f"实例化策略 {strat_cls.__name__} 失败: {e}") from e
133
+
134
+ factors: dict[str, _pd.DataFrame] = {}
135
+ if hasattr(strat_inst, "compute_factors"):
136
+ try:
137
+ factors = strat_inst.compute_factors(df) or {}
138
+ except Exception as e:
139
+ raise RuntimeError(
140
+ f"compute_factors 异常: {e} (用户原话: 策略出错直接报错停止, 不走 mock 兜底)"
141
+ ) from e
142
+ if not isinstance(factors, dict):
143
+ raise TypeError(
144
+ f"compute_factors 应返 dict[str, DataFrame], 实得 {type(factors).__name__}"
145
+ )
146
+
147
+ filtered: list[str] = list(universe)
148
+ if hasattr(strat_inst, "filter_symbols"):
149
+ try:
150
+ user_filtered = strat_inst.filter_symbols(factors)
151
+ except Exception as e:
152
+ raise RuntimeError(f"filter_symbols 异常: {e}") from e
153
+ if not isinstance(user_filtered, (list, tuple)):
154
+ raise TypeError(
155
+ f"filter_symbols 应返 list[str], 实得 {type(user_filtered).__name__}"
156
+ )
157
+ user_filtered = [str(s) for s in user_filtered]
158
+ if not user_filtered:
159
+ warnings.warn(f"filter_symbols 返空, 退到 cfg.universe ({len(universe)} syms)")
160
+ else:
161
+ # 校验: filter_symbols 返的 sym 必须 universe 子集
162
+ known = set(universe)
163
+ bad = [s for s in user_filtered if s not in known]
164
+ if bad:
165
+ warnings.warn(
166
+ f"filter_symbols 返了 universe 外的 sym {bad[:5]}{'...' if len(bad) > 5 else ''}, 已剔除"
167
+ )
168
+ user_filtered = [s for s in user_filtered if s in known]
169
+ filtered = user_filtered
170
+ print(
171
+ f"[v2-architecture] filter_symbols: {len(universe)} → {len(filtered)} symbols",
172
+ file=sys.stderr,
173
+ )
174
+
175
+ # 切 df 到 filtered universe (空 df 立即报错, 不让 akquant 引擎跑出空结果)
176
+ if filtered != universe:
177
+ df = df[df["symbol"].isin(filtered)] if "symbol" in df.columns else df
178
+ if df.empty:
179
+ raise ValueError(
180
+ f"filter_symbols 限定后 df 为空 (filtered={filtered[:5]}...)"
181
+ )
182
+ universe = filtered
183
+
184
+ # run_backtest kwargs (锁定 akquant 0.3.x schema; 见 SKILL.md 顶部 0.3 加速原语段)
185
+ initial_cash = float(cfg.get('init_capital', 1_000_000.0))
186
+ # slippage: akquant 0.3 已 deprecate bare float, 强制 dict 形态
187
+ # 接受 cfg['slippage']=float 或 cfg['slippage']={'type':..., 'value':...}
188
+ _raw_slip = cfg.get('slippage', 0.001)
189
+ if isinstance(_raw_slip, dict):
190
+ slippage_kwarg = _raw_slip
191
+ else:
192
+ slippage_kwarg = {'type': 'percent', 'value': float(_raw_slip)}
193
+ kwargs: dict[str, Any] = {
194
+ 'strategy': strat_cls,
195
+ 'data': df,
196
+ 'symbols': universe,
197
+ 'initial_cash': initial_cash,
198
+ 'commission_rate': float(cfg.get('commission_rate', 0.0003)),
199
+ 'stamp_tax_rate': float(cfg.get('stamp_tax_rate', 0.001)),
200
+ 'min_commission': float(cfg.get('min_commission', 5.0)),
201
+ 'slippage': slippage_kwarg,
202
+ 'volume_limit_pct': float(cfg.get('volume_limit_pct', 0.25)),
203
+ 't_plus_one': bool(cfg.get('t_plus_one', True)),
204
+ 'show_progress': False,
205
+ }
206
+ # universe 注入 (Q1: 多标/横截面策略必需):
207
+ # - 0.3.x 新 API: strategy 用 `universe = ListParam(default=[])` 类字段, runner 通过
208
+ # `strategy_params={'universe': universe}` 注入 (引擎走 .params.universe).
209
+ # - 0.2.x 旧 API: strategy `__init__(self, universe=None)`, runner 通过 kwargs 注入.
210
+ # **0.3.x 严格拒收** (raise TypeError), 所以检测到旧风格时只能跳过, 让用户迁移.
211
+ if _strategy_accepts_universe_param(strat_cls):
212
+ kwargs['strategy_params'] = {'universe': universe}
213
+ # 不再 fallback 到 0.2 旧路径 — 见上方注释; 旧 strategy 走 0.3 必须先迁移到 ParamModel.
214
+
215
+ # ===== 0.3.x 加速原语 (自动启用, strategy 不用改) =====
216
+ # 1) history_depth — 推断: scan __init__ 默认值 + warmup_period + cfg['history_depth']
217
+ # 长窗策略 (lookback > 30) 实测 -20~40% elapsed; 短窗无害.
218
+ hd = _infer_history_depth(strat_cls, cfg)
219
+ if hd is not None and hd > 0:
220
+ kwargs['history_depth'] = hd
221
+
222
+ # 2) start_time / end_time — 引擎内切片, 比 Python 端 iloc 省内存且正确处理 warmup.
223
+ # cfg['backtest_start'] 是 'YYYYMMDD' (legacy v1 格式), akquant 0.3 期望 ISO 'YYYY-MM-DD'.
224
+ # 兼容 cfg 显式 start_time/end_time (优先级更高).
225
+ if 'start_time' not in cfg:
226
+ cfg['start_time'] = _ymd_to_iso(cfg['backtest_start'])
227
+ if 'end_time' not in cfg:
228
+ cfg['end_time'] = _ymd_to_iso(cfg['backtest_end'])
229
+ kwargs['start_time'] = cfg['start_time']
230
+ kwargs['end_time'] = cfg['end_time']
231
+
232
+ # 3) lot_size — A 股默认 100 整手. 老 akquant (<0.3.30) 不接受 (会被 TypeError) → 启动期探测.
233
+ # 接受 cfg['lot_size']=int (统一) 或 dict (per-symbol); 默认 100.
234
+ ls = cfg.get('lot_size', 100)
235
+ if _akquant_supports_lot_size() and ls:
236
+ kwargs['lot_size'] = ls
237
+
238
+ # 4) commission_policy — 0.3+ 推荐 dict 形态; 若 cfg 显式给 commission_policy 优先.
239
+ # 否则从 commission_rate 构造成 dict (行为等价但走 0.3+ 内部 commission pipeline).
240
+ # commission_rate 与 commission_policy 二选一 (0.3 strict 模式同时传会报错, 所以
241
+ # 构造 commission_policy 后立刻从 kwargs 里 pop commission_rate).
242
+ if _akquant_supports_commission_policy():
243
+ cp = cfg.get('commission_policy')
244
+ if cp is None:
245
+ cp = {
246
+ 'type': 'percent',
247
+ 'value': float(cfg.get('commission_rate', 0.0003)),
248
+ }
249
+ cfg['commission_policy'] = cp
250
+ kwargs.pop('commission_rate', None)
251
+ kwargs['commission_policy'] = cp
252
+ # 0.2.x 老路径: 只 commission_rate, 不动.
253
+
254
+ # 可选 kwargs (存在性探测后再传; 防老 akquant 跑 0.3 only kwargs 抛 TypeError)
255
+ for optional in ('analyzer_plugins', 'risk_config'):
256
+ if optional in cfg and _akquant_kwarg_supported(optional):
257
+ kwargs[optional] = cfg[optional]
258
+
259
+ akquant_r = akquant.run_backtest(**kwargs)
260
+
261
+ return to_hamuna_result(akquant_r, cfg)
262
+
263
+
264
+ def _universe_from_cfg(cfg: dict) -> list[str]:
265
+ """从 cfg['pool'] 展开标的代码 list. 与 schema_adapter._cfg_universe 同语义."""
266
+ pool = cfg.get('pool') or cfg.get('universe') or []
267
+ if isinstance(pool, list):
268
+ return list(pool)
269
+ out: list[str] = []
270
+ for v in pool.values() if isinstance(pool, dict) else []:
271
+ if isinstance(v, dict):
272
+ codes = v.get('codes') or v.get('sub_universe') or []
273
+ out.extend(codes)
274
+ elif isinstance(v, list):
275
+ out.extend(v)
276
+ return sorted(set(out))
277
+
278
+
279
+ def _load_symbol_names_from_bundle(df: pd.DataFrame) -> dict[str, str]:
280
+ """从 input df 聚合 {symbol: stockName}, 失败/缺列 → {} (trades 端 fallback None).
281
+
282
+ 备选: 直读 prebuilt parquet 的 stockName 列 (data_adapter 已 drop).
283
+ 这里走 df 自身: data_adapter._DROP_COLS 含 stockName, 但 _PRICE_LIMIT 路径
284
+ 仍带 stockName; 若上游已 drop → 空 dict.
285
+ """
286
+ try:
287
+ if 'stockName' not in df.columns:
288
+ return {}
289
+ names = df.dropna(subset=['stockName']).drop_duplicates('symbol').set_index('symbol')['stockName'].to_dict()
290
+ return {k: str(v) for k, v in names.items() if k}
291
+ except Exception: # noqa: BLE001
292
+ return {}
293
+
294
+
295
+ # ---- 0.3.x 加速原语 helper (Round 1) ----
296
+
297
+ # window-like 参数名启发式: __init__ 默认值取 max + warmup_period.
298
+ # 不取 lot_size / universe / period (它们不是窗口).
299
+ _WINDOW_PARAM_HINTS = (
300
+ 'window', 'lookback', 'slow', 'fast', 'long', 'short',
301
+ 'period', 'span', 'length', 'n_', '_n', 'days', 'periods',
302
+ 'ma', 'sma', 'ema', 'rsi', 'atr', 'roc',
303
+ )
304
+
305
+
306
+ def _infer_history_depth(strat_cls: type, cfg: dict) -> int | None:
307
+ """从 strategy.__init__ 默认值 + warmup_period + cfg['history_depth'] 推断 history_depth.
308
+
309
+ 返回 None = 留空, akquant 走引擎默认 (0, 不预分配).
310
+ 返回 int > 0 = 预分配 Rust history buffer, 长窗策略 -20~40% elapsed.
311
+
312
+ 启发式:
313
+ 1) cfg['history_depth'] 显式值 (最高优先级)
314
+ 2) strategy.__init__ 默认值里匹配 window 关键字 + 数值 → 取 max
315
+ 3) 类属性 warmup_period
316
+ 4) 加 10 padding 防 off-by-one (rolling-N 在第 N+1 bar 才完整)
317
+ """
318
+ explicit = cfg.get('history_depth')
319
+ if explicit is not None:
320
+ try:
321
+ return max(0, int(explicit))
322
+ except (TypeError, ValueError):
323
+ pass # 显式值非法 → 走推断
324
+
325
+ try:
326
+ sig = inspect.signature(strat_cls.__init__)
327
+ except (TypeError, ValueError):
328
+ sig = None
329
+
330
+ candidates: list[int] = []
331
+ if sig is not None:
332
+ for pname, param in sig.parameters.items():
333
+ if param.default is inspect.Parameter.empty:
334
+ continue
335
+ v = param.default
336
+ # 跳过 None / bool / 非数值
337
+ if not isinstance(v, (int, float)):
338
+ continue
339
+ if isinstance(v, bool):
340
+ continue
341
+ iv = int(v)
342
+ if iv <= 0:
343
+ continue
344
+ # 启发式: 名字命中 window-like 才采纳 (避免 lot_size=100 / universe_size=500 误判)
345
+ plower = pname.lower()
346
+ if any(h in plower for h in _WINDOW_PARAM_HINTS):
347
+ candidates.append(iv)
348
+
349
+ # 类属性 warmup_period
350
+ wp = getattr(strat_cls, 'warmup_period', None)
351
+ if isinstance(wp, (int, float)) and not isinstance(wp, bool) and wp > 0:
352
+ candidates.append(int(wp))
353
+
354
+ if not candidates:
355
+ return None
356
+ return max(candidates) + 10
357
+
358
+
359
+ def _ymd_to_iso(ymd: str) -> str:
360
+ """'YYYYMMDD' (v1 cfg 格式) → 'YYYY-MM-DD' (akquant 0.3 start_time 格式).
361
+
362
+ 已是 ISO 形态直接透传, 避免破坏既有 cfg['start_time'] 用法.
363
+ """
364
+ s = str(ymd).strip()
365
+ if len(s) == 8 and s.isdigit():
366
+ return f'{s[:4]}-{s[4:6]}-{s[6:8]}'
367
+ return s
368
+
369
+
370
+ # akquant 版本探测: 启动期一次性 inspect 签名, 防 <0.3.30 / 0.3.x 行为差异.
371
+ _AKQUANT_SUPPORT_CACHE: dict[str, bool] = {}
372
+
373
+
374
+ def _akquant_kwarg_supported(name: str) -> bool:
375
+ """查 akquant.run_backtest 是否接受给定 kwarg (避免老版本抛 TypeError)."""
376
+ if name in _AKQUANT_SUPPORT_CACHE:
377
+ return _AKQUANT_SUPPORT_CACHE[name]
378
+ try:
379
+ import akquant
380
+ sig = inspect.signature(akquant.run_backtest)
381
+ ok = name in sig.parameters
382
+ except Exception: # noqa: BLE001
383
+ ok = False
384
+ _AKQUANT_SUPPORT_CACHE[name] = ok
385
+ return ok
386
+
387
+
388
+ def _akquant_supports_lot_size() -> bool:
389
+ return _akquant_kwarg_supported('lot_size')
390
+
391
+
392
+ def _akquant_supports_commission_policy() -> bool:
393
+ return _akquant_kwarg_supported('commission_policy')
394
+
395
+
396
+ def _strategy_accepts_universe_param(strat_cls: type) -> bool:
397
+ """检测 strategy 是否声明 `universe` 为 0.3.x 的 ParamModel 字段.
398
+
399
+ 0.3.x: `universe: list = ListParam(default=[])` 类字段, 通过 strategy_params 注入.
400
+ 0.2.x: `__init__(self, universe=None)` — 0.3.x 严格拒收 (TypeError), 这里返 False 跳过.
401
+
402
+ ponytail: 仅检测 'universe' 字段. 其它字段 (lots/fast/slow 等) 走 ParamModel 自身规则,
403
+ runner 不替用户策略注入; grid/WFO 子命令会单独处理.
404
+ """
405
+ try:
406
+ # pydantic-based ParamModel: 实例化后 inst.params.<name> 存在
407
+ with warnings.catch_warnings():
408
+ warnings.simplefilter('ignore') # 0.3.x 对老 __init__ 形参的 UserWarning
409
+ inst = strat_cls()
410
+ if not hasattr(inst, 'params'):
411
+ return False
412
+ params_obj = getattr(inst, 'params')
413
+ # pydantic v2: model_fields 是 dict[str, FieldInfo]
414
+ fields = getattr(params_obj, 'model_fields', None)
415
+ if fields and 'universe' in fields:
416
+ return True
417
+ # pydantic v1 fallback
418
+ fields_v1 = getattr(params_obj, '__fields__', None)
419
+ if fields_v1 and 'universe' in fields_v1:
420
+ return True
421
+ return False
422
+ except Exception: # noqa: BLE001
423
+ return False
424
+
425
+
426
+ def _selfcheck() -> None:
427
+ """端到端冒烟: 写 tmpfile 含 akquant.Strategy 子类 → 跑最小 backtest.
428
+
429
+ 失败模式:
430
+ akquant 未装 → SKIP.
431
+ 数据缺 (no prebuilt) → SKIP, 不 fail.
432
+ 数据有 → 跑通, 验 15 metrics key + Q1 兜底已启用.
433
+ """
434
+ if os.environ.get('AKQUANT_AVAILABLE', '1') != '1':
435
+ print('SKIP: akquant self-check (env AKQUANT_AVAILABLE=0)')
436
+ return
437
+ try:
438
+ import akquant # noqa: F401
439
+ except ImportError:
440
+ print('SKIP: akquant not installed')
441
+ return
442
+
443
+ import tempfile
444
+ with tempfile.TemporaryDirectory() as tmp:
445
+ old = os.environ.get('HAMUNA_STRATEGIES_ROOT')
446
+ os.environ['HAMUNA_STRATEGIES_ROOT'] = tmp
447
+ try:
448
+ strat_path = Path(tmp) / 'ma_strategy.py'
449
+ strat_path.write_text('''
450
+ from akquant import Strategy
451
+
452
+ class MACross(Strategy):
453
+ warmup_period = 5
454
+ def __init__(self, fast=3, slow=5):
455
+ self.fast = fast
456
+ self.slow = slow
457
+ def on_bar(self, bar):
458
+ closes = self.get_history(self.slow, bar.symbol, "close")
459
+ if len(closes) < self.slow:
460
+ return
461
+ fast_ma = float(sum(closes[-self.fast:]) / self.fast)
462
+ slow_ma = float(sum(closes[-self.slow:]) / self.slow)
463
+ pos = self.get_position(bar.symbol)
464
+ if fast_ma > slow_ma and pos == 0:
465
+ self.buy(bar.symbol, 100)
466
+ elif fast_ma < slow_ma and pos > 0:
467
+ self.sell(bar.symbol, pos)
468
+ ''', encoding='utf-8')
469
+
470
+ cfg = {
471
+ 'backtest_start': '20240701',
472
+ 'backtest_end': '20241231',
473
+ 'pool': {'a': {'codes': ['600000.SH']}},
474
+ 'init_capital': 100_000.0,
475
+ }
476
+ try:
477
+ r = run_akquant_backtest(strat_path, cfg)
478
+ except FileNotFoundError:
479
+ print('SKIP: prebuilt dataset 缺失 (无 600000.SH 数据); runner 入口已通, 装数据后可跑')
480
+ return
481
+ except NotImplementedError as e:
482
+ print(f'SKIP: {e}')
483
+ return
484
+
485
+ from .akquant_schema_adapter import HAMUNA_METRICS_15 # B1
486
+ assert set(r['metrics'].keys()) == HAMUNA_METRICS_15, (
487
+ f'15 metrics key 不齐: 差 {set(r["metrics"].keys()) ^ HAMUNA_METRICS_15}')
488
+ n_eq = len(r['equity_curve'])
489
+ print(f'OK: akquant_runner 端到端通 (15 metrics, trades={len(r["trades"])}, eq_points={n_eq})')
490
+
491
+ # Q1 regression: 0.3.x 新 API — 收 `universe` ParamModel 字段的策略, runner 必须
492
+ # 把 universe 注入 strategy_params (否则 on_start 空订阅 → 0 trades, 高胜率
493
+ # 策略实际踩过). 0.3.x 严格拒收老 __init__ 形参, 所以检测 → 注入路径改了.
494
+ # monkeypatch akquant.run_backtest 捕获 kwargs, 断言 strategy_params 在.
495
+ import akquant as _akq
496
+ captured: dict = {}
497
+ _orig_run = _akq.run_backtest
498
+
499
+ def _capture(**kw):
500
+ captured.update(kw)
501
+ # 真实跑 (返回真 BacktestResult), 让 to_hamuna_result 也走通
502
+ return _orig_run(**kw)
503
+
504
+ _akq.run_backtest = _capture
505
+ try:
506
+ _ = run_akquant_backtest(strat_path, cfg) # MACross 不收 universe
507
+ # MACross 不收 universe → 不注入 strategy_params (0.2.x 旧路径已被 0.3 移除)
508
+ assert 'strategy_params' not in captured, (
509
+ f'MACross 不应注入 strategy_params: {captured.get("strategy_params")}')
510
+ finally:
511
+ _akq.run_backtest = _orig_run
512
+ captured.clear()
513
+ _akq.run_backtest = _capture
514
+ try:
515
+ strat3 = Path(tmp) / 'uses_universe.py'
516
+ strat3.write_text('''
517
+ from akquant import Strategy, ListParam
518
+
519
+ class UsesUniverse(Strategy):
520
+ """0.3.x 新 ParamModel 风格: universe 字段声明为 ListParam."""
521
+ universe: list = ListParam(default=[])
522
+ def on_bar(self, bar):
523
+ pass
524
+ ''', encoding='utf-8')
525
+ _ = run_akquant_backtest(strat3, cfg) # UsesUniverse 收 universe
526
+ assert captured.get('strategy_params') == {'universe': ['600000.SH']}, (
527
+ f'UsesUniverse (0.3 ParamModel) 注入错: {captured.get("strategy_params")}')
528
+ finally:
529
+ _akq.run_backtest = _orig_run
530
+ # 0.2.x 旧 __init__ 风格 → 检测返 False, runner 跳过注入 (0.3 strict 拒收).
531
+ # 加载临时类 (用 _load_akquant_strategy, 沿用 selfcheck 已写好的 tmpfile).
532
+ _macross_cls = _load_akquant_strategy(strat_path)
533
+ _uses_cls = _load_akquant_strategy(strat3)
534
+ assert not _strategy_accepts_universe_param(_macross_cls), (
535
+ 'MACross (无 universe 字段) 应 _strategy_accepts_universe_param=False')
536
+ assert _strategy_accepts_universe_param(_uses_cls), (
537
+ 'UsesUniverse (ListParam) 应 _strategy_accepts_universe_param=True')
538
+ print(f'OK: universe 注入路径生效 (0.3 ParamModel → strategy_params; 旧 __init__ 风格跳过)')
539
+
540
+ # ===== Round 1: history_depth 推断 + 0.3 kwarg 探测 ——
541
+ # 1) history_depth 推断: 用 MACross(fast=3, slow=5, ...), 预期 max(3,5)+10=15
542
+ # (注意: 这里 fast=3 / slow=5 是 selfcheck 里写死的, 不是 user 默认)
543
+ hd = _infer_history_depth(_macross_cls, cfg)
544
+ assert hd == 15, f'history_depth 推断应 15 (max(fast=3, slow=5) + 10), 实际 {hd}'
545
+ # 类属性 warmup_period 单独存在时也应采纳
546
+ class _WarmupOnly(akquant.Strategy):
547
+ warmup_period = 42
548
+ def on_bar(self, bar): pass
549
+ assert _infer_history_depth(_WarmupOnly, cfg) == 52, 'warmup_period 单独推断失败'
550
+ # cfg 显式覆盖
551
+ assert _infer_history_depth(_macross_cls, {**cfg, 'history_depth': 80}) == 80, (
552
+ 'cfg[history_depth] 显式覆盖失败')
553
+ # 非法值 → 走推断
554
+ assert _infer_history_depth(_macross_cls, {**cfg, 'history_depth': 'xxx'}) == 15, (
555
+ '非法 history_depth 应 fallthrough 到推断')
556
+ # 无窗口 / 无 warmup → None (留空, 走引擎默认)
557
+ class _Empty(akquant.Strategy):
558
+ def __init__(self, foo='bar', flag=True):
559
+ self.foo = foo
560
+ def on_bar(self, bar): pass
561
+ assert _infer_history_depth(_Empty, cfg) is None, '无窗口应 None'
562
+ print(f'OK: history_depth 推断 (max+10 padding + warmup + cfg override + fallthrough)')
563
+
564
+ # 2) _ymd_to_iso: legacy YYYYMMDD → ISO, 已 ISO → 透传
565
+ assert _ymd_to_iso('20240701') == '2024-07-01'
566
+ assert _ymd_to_iso('2024-07-01') == '2024-07-01' # 透传
567
+ assert _ymd_to_iso(' 20240701 ') == '2024-07-01' # 容错 trim
568
+ assert _ymd_to_iso('') == ''
569
+ print('OK: _ymd_to_iso 边界 (YYYYMMDD/ISO/trim/empty)')
570
+
571
+ # 3) akquant 版本探测: lot_size / commission_policy 在 0.3.41 存在; <0.3.30 不存在.
572
+ # selfcheck 跑在 0.3.41 环境下, 期望 True; 若用户跑 <0.3.30, 仍可加载 (False 走老路径).
573
+ assert _akquant_supports_lot_size(), (
574
+ f'当前 akquant={akquant.__version__} 不支持 lot_size; 升 >=0.3.41 拿到自动整手约束')
575
+ assert _akquant_supports_commission_policy(), (
576
+ f'当前 akquant={akquant.__version__} 不支持 commission_policy; 升 >=0.3 拿推荐写法')
577
+ assert _akquant_kwarg_supported('history_depth'), 'history_depth 应是 0.3 标配'
578
+ assert not _akquant_kwarg_supported('nonexistent_kwarg_xxx'), '不存在的 kwarg 应 False'
579
+ print(f'OK: akquant {akquant.__version__} 0.3 加速 kwarg 探测 (lot_size/commission_policy/history_depth)')
580
+
581
+ # 4) 端到端: 跑一次真 backtest, 验 kwargs 真传过去且 metrics 不回归.
582
+ # monkeypatch 拿 kwargs, 验 history_depth/start_time/end_time 在 (0.3+).
583
+ captured2: dict = {}
584
+ _orig2 = _akq.run_backtest
585
+ def _cap2(**kw):
586
+ captured2.update(kw)
587
+ return _orig2(**kw)
588
+ _akq.run_backtest = _cap2
589
+ try:
590
+ _ = run_akquant_backtest(strat_path, cfg)
591
+ finally:
592
+ _akq.run_backtest = _orig2
593
+ # history_depth 应被自动算出 (≥15, 视 MACross 默认值)
594
+ assert captured2.get('history_depth', 0) >= 15, (
595
+ f'history_depth 未自动传: {captured2.get("history_depth")}')
596
+ # start_time / end_time 应是 ISO 格式
597
+ assert captured2.get('start_time') == '2024-07-01', (
598
+ f'start_time ISO 转换错: {captured2.get("start_time")}')
599
+ assert captured2.get('end_time') == '2024-12-31', (
600
+ f'end_time ISO 转换错: {captured2.get("end_time")}')
601
+ # commission_policy 应替代 commission_rate
602
+ assert 'commission_policy' in captured2, (
603
+ f'0.3+ commission_policy 未传: keys={list(captured2.keys())[:15]}...')
604
+ assert 'commission_rate' not in captured2, (
605
+ 'commission_policy 与 commission_rate 不应同时传')
606
+ # lot_size 应传 100 (A 股整手)
607
+ assert captured2.get('lot_size') == 100, (
608
+ f'lot_size 应默认 100, 实际 {captured2.get("lot_size")}')
609
+ print(f'OK: 端到端 kwargs 注入 (history_depth={captured2["history_depth"]}, '
610
+ f'start={captured2["start_time"]}, end={captured2["end_time"]}, '
611
+ f'lot_size={captured2["lot_size"]}, commission_policy={captured2["commission_policy"]})')
612
+ finally:
613
+ if old is None:
614
+ os.environ.pop('HAMUNA_STRATEGIES_ROOT', None)
615
+ else:
616
+ os.environ['HAMUNA_STRATEGIES_ROOT'] = old
617
+
618
+
619
+ if __name__ == '__main__':
620
+ _selfcheck()