hamuna-quant-cli 0.1.1__tar.gz → 0.1.6__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.
Files changed (40) hide show
  1. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/PKG-INFO +1 -1
  2. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/__init__.py +1 -3
  3. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/__main__.py +73 -56
  4. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/akquant_data_adapter.py +13 -9
  5. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/akquant_runner.py +171 -22
  6. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/akquant_schema_adapter.py +117 -8
  7. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/live/qmt_broker.py +100 -40
  8. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/live/qmt_market.py +1 -1
  9. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/live/runner.py +120 -10
  10. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/prebuilt_downloader.py +31 -10
  11. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/prebuilt_resolver.py +66 -27
  12. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/qmt_translator.py +3 -2
  13. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/backtest.py +2 -2
  14. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/discipline.py +320 -2
  15. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/server_client.py +26 -72
  16. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
  17. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/SOURCES.txt +1 -2
  18. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/pyproject.toml +1 -1
  19. hamuna_quant_cli-0.1.1/tests/test_skeleton.py +0 -154
  20. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/MANIFEST.in +0 -0
  21. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/README.md +0 -0
  22. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/README.md +0 -0
  23. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/_market_fallback.py +0 -0
  24. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/_metrics_15.py +0 -0
  25. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
  26. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/base_strategy.py +0 -0
  27. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
  28. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/live/__init__.py +0 -0
  29. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/live/loader.py +0 -0
  30. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/__init__.py +0 -0
  31. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/cache.py +0 -0
  32. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/http_client.py +0 -0
  33. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/runtime/s3client.py +0 -0
  34. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli/scripts/server.json +0 -0
  35. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
  36. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
  37. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/requires.txt +0 -0
  38. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
  39. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/setup.cfg +0 -0
  40. {hamuna_quant_cli-0.1.1 → hamuna_quant_cli-0.1.6}/tests/test_live_safety.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hamuna-quant-cli
3
- Version: 0.1.1
3
+ Version: 0.1.6
4
4
  Summary: Hamuna A 股回测 + 实盘统一 CLI (akquant 0.3.x). 替代 v1 自建 driver + QMT-style 策略规范.
5
5
  Author-email: Hamuna Team <team@hamuna.example>
6
6
  License: MIT
@@ -12,6 +12,4 @@ server (13-key metrics schema). 全部走 akquant 0.3.x 引擎.
12
12
  """
13
13
  from __future__ import annotations
14
14
 
15
- # 版本号由 scripts/bump_version.py 每次 build 自动更新 (基于 git 提交数).
16
- # 格式: 0.1.0.dev<git提交数> (PEP 440). 手动改这里会被下一次 build 覆盖.
17
- __version__ = "0.1.1"
15
+ __version__ = "0.1.6"
@@ -7,7 +7,7 @@ Usage:
7
7
  hamuna_quant_cli create --name ... --config ...
8
8
  hamuna_quant_cli parity [--strategies ...]
9
9
  hamuna_quant_cli qmt-translate <strategy.py> <cfg.json>
10
- hamuna_quant_cli commit <strategy_id> --strategy ... --result ...
10
+ hamuna_quant_cli commit <strategy_id> --strategy ... --result ... --config ... --params ...
11
11
  hamuna_quant_cli dataset {list|fetch|manifest}
12
12
  hamuna_quant_cli live run <strategy.py> --mode paper --broker qmt ...
13
13
 
@@ -69,6 +69,35 @@ def cmd_run(args) -> int:
69
69
  f"用 --skip-discipline 显式跳过", file=sys.stderr)
70
70
  return 3
71
71
 
72
+ # --params (params.json 固定 schema) → strategy_params 段注入 cfg,
73
+ # akquant_runner 展开注入 engine kwargs. 与 `live run --params` 同源:
74
+ # 同一份 params.json 驱动回测 + 实盘, 参数约定不再分裂 (Round 18.13).
75
+ if getattr(args, "params", None):
76
+ params_path = Path(args.params)
77
+ if not params_path.exists():
78
+ print(f"--params 不存在: {args.params}", file=sys.stderr)
79
+ return 2
80
+ import json as _json
81
+ try:
82
+ params_doc = _json.loads(params_path.read_text(encoding="utf-8"))
83
+ except _json.JSONDecodeError as e:
84
+ print(f"--params 解析失败: {args.params} ({e})", file=sys.stderr)
85
+ return 2
86
+ sp = (params_doc or {}).get("strategy_params")
87
+ if isinstance(sp, dict):
88
+ cfg["strategy_params"] = sp
89
+ else:
90
+ print("[warn] --params 无 strategy_params 段, 忽略 (仅接受固定 schema)", file=sys.stderr)
91
+ # Round 18.14: risk_config 段 → cfg['risk_config'] → runner 透传
92
+ # run_backtest(risk_config={"max_position_pct": 0.1}) 原生消费 (账户级风控,
93
+ # 引擎 hasattr 校验 + 未知字段仅 warning). skill 桌面编辑的实盘风控真正生效.
94
+ rc = (params_doc or {}).get("risk_config")
95
+ if isinstance(rc, dict):
96
+ # 与既有 cfg['risk_config'] 合并 (显式 cfg 优先)
97
+ merged = dict(cfg.get("risk_config") or {})
98
+ merged.update(rc)
99
+ cfg["risk_config"] = merged
100
+
72
101
  if getattr(args, "dataset", None):
73
102
  manifest_path = Path(args.dataset)
74
103
  if not manifest_path.exists():
@@ -82,7 +111,16 @@ def cmd_run(args) -> int:
82
111
  return 2
83
112
  cfg["_dataset_manifest"] = manifest
84
113
 
85
- result = runtime.backtest.run(strategy_path, cfg)
114
+ try:
115
+ result = runtime.backtest.run(strategy_path, cfg)
116
+ except Exception as e: # noqa: BLE001 — 错误分级: StrategyParamsError → exit 3; 其余 runner 错 → exit 4
117
+ from .akquant_runner import StrategyParamsError
118
+ if isinstance(e, StrategyParamsError):
119
+ # Round 18.13: --params strategy_params 预校验失败 (validate_strategy_params) 走 exit 3,
120
+ # 与纪律档位一致 — 不带全 Traceback, 一行明确 msg (用户改 params.json).
121
+ print(f"策略参数校验失败: {e}", file=sys.stderr)
122
+ return 3
123
+ raise
86
124
 
87
125
  if args.output:
88
126
  import json
@@ -250,14 +288,20 @@ def cmd_qmt_translate(args) -> int:
250
288
  print(f"QMT body saved → {out}", file=sys.stderr)
251
289
  import json
252
290
  print(json.dumps({"qmt_body_path": str(out), "note":
253
- "本地 stub; cloud export 端点会重新渲染 + 签 cert + 嵌入 embedded API key"},
291
+ "本地 stub — Round 18.42 起 cloud 不再渲染 .qmt.py, 实盘由 desktop 落盘原始 .py → bridge_server 执行"},
254
292
  ensure_ascii=False))
255
293
  return 0
256
294
 
257
295
 
258
296
  def cmd_commit(args) -> int:
259
- """commiter 5 步打包上传 (qmt-translate + bundle + PUT result + POST code + POST export)."""
260
- from .qmt_translator import write_qmt_body
297
+ """commiter 3 步打包上传 (bundle + PUT result + POST code).
298
+
299
+ Round 18.42: POST /strategies/:id/export 端点已废, 不再生成 .qmt.py shell —
300
+ server 仅存策略源码. QMT 实盘由 desktop deploy_strategy 落盘原始 .py →
301
+ bridge_server 扫描执行; hamuna-quant-cli 下单走 bridge_server HTTP.
302
+ bundle = 4 文件 (strategy + result + config + params). multipart params 字段
303
+ = 整个 params.json 内容 (ADR-0025, desktop 实盘配置源).
304
+ """
261
305
  from . import runtime
262
306
  import json
263
307
  import tarfile
@@ -276,35 +320,26 @@ def cmd_commit(args) -> int:
276
320
  if cfg_path and not cfg_path.exists():
277
321
  print(f"config.json 不存在: {cfg_path}", file=sys.stderr)
278
322
  return 2
279
- spec_path = Path(args.spec) if args.spec else None
280
- if spec_path and not spec_path.exists():
281
- print(f"spec_strategy.json 不存在: {spec_path} (--spec 可选)", file=sys.stderr)
282
- spec_path = None
323
+ params_path = Path(args.params) if args.params else None
324
+ if params_path and not params_path.exists():
325
+ print(f"params.json 不存在: {params_path} (--params 可选)", file=sys.stderr)
326
+ return 2
283
327
 
284
328
  bundle_out = Path(args.bundle_out) if args.bundle_out else Path(f"runs/{sid}/bundle.tar.gz")
285
329
  bundle_out.parent.mkdir(parents=True, exist_ok=True)
286
330
 
287
- print("=== Step 1/5: akquant → QMT body 翻译 (本地 stub) ===", file=sys.stderr)
288
- try:
289
- qmt_body_path = write_qmt_body(strategy_path, cfg_path, spec_path)
290
- except NotImplementedError as e:
291
- print(f" ✗ 不支持: {e}", file=sys.stderr)
292
- return 3
293
- print(f" ✓ qmt_body = {qmt_body_path}", file=sys.stderr)
294
-
295
- print("=== Step 2/5: 打包 bundle (strategy + qmt_body + metrics + spec) ===",
331
+ print("=== Step 1/3: 打包 bundle (strategy + result + config + params) ===",
296
332
  file=sys.stderr)
297
333
  with tarfile.open(bundle_out, "w:gz") as tar:
298
334
  tar.add(strategy_path, arcname=strategy_path.name)
299
- tar.add(qmt_body_path, arcname=qmt_body_path.name)
300
335
  tar.add(result_path, arcname=result_path.name)
301
- if spec_path:
302
- tar.add(spec_path, arcname=spec_path.name)
303
336
  if cfg_path:
304
337
  tar.add(cfg_path, arcname=cfg_path.name)
338
+ if params_path:
339
+ tar.add(params_path, arcname=params_path.name)
305
340
  print(f" ✓ bundle = {bundle_out} ({bundle_out.stat().st_size} bytes)", file=sys.stderr)
306
341
 
307
- print("=== Step 3/5: PUT /strategies/:id/result (metrics) ===", file=sys.stderr)
342
+ print("=== Step 2/3: PUT /strategies/:id/result (metrics) ===", file=sys.stderr)
308
343
  try:
309
344
  result = json.loads(result_path.read_text(encoding="utf-8"))
310
345
  except json.JSONDecodeError as e:
@@ -317,47 +352,27 @@ def cmd_commit(args) -> int:
317
352
  return 4
318
353
  print(f" ✓ metrics uploaded (resp keys: {sorted(resp.keys())[:5]})", file=sys.stderr)
319
354
 
320
- print("=== Step 4/5: POST /strategies/:id/code (multipart body + params) ===",
355
+ print("=== Step 3/3: POST /strategies/:id/code (multipart body + params) ===",
321
356
  file=sys.stderr)
322
- params: dict = {}
323
- if spec_path:
324
- params["spec_strategy"] = json.loads(spec_path.read_text(encoding="utf-8"))
325
- if cfg_path:
326
- params["config"] = json.loads(cfg_path.read_text(encoding="utf-8"))
357
+ params: dict | None = None
358
+ if params_path:
359
+ params = json.loads(params_path.read_text(encoding="utf-8"))
327
360
  try:
328
- resp_code = runtime.server_client.upload_strategy_code(sid, str(qmt_body_path),
329
- params=params or None)
361
+ resp_code = runtime.server_client.upload_strategy_code(sid, str(strategy_path),
362
+ params=params)
330
363
  except runtime.server_client.ServerError as e:
331
364
  print(f" ✗ 上传源码失败: {e}", file=sys.stderr)
332
365
  return 4
333
366
  print(f" ✓ body uploaded (body_hash={resp_code.get('body_hash', '?')[:16]})",
334
367
  file=sys.stderr)
335
368
 
336
- export_path = None
337
- if not args.skip_export:
338
- print("=== Step 5/5: POST /strategies/:id/export (拉 .qmt.py shell) ===",
339
- file=sys.stderr)
340
- export_out = Path(args.export_out) if args.export_out else Path(
341
- f"runs/{sid}/{sid}.qmt.py"
342
- )
343
- export_out.parent.mkdir(parents=True, exist_ok=True)
344
- try:
345
- resp_exp = runtime.server_client.export_strategy(sid, str(export_out))
346
- except runtime.server_client.ServerError as e:
347
- print(f" ✗ QMT export 失败: {e}", file=sys.stderr)
348
- return 4
349
- export_path = str(export_out)
350
- print(f" ✓ shell saved → {export_path} "
351
- f"({resp_exp.get('shell_bytes', '?')} bytes)", file=sys.stderr)
352
- else:
353
- print("=== Step 5/5: SKIPPED (--skip-export) ===", file=sys.stderr)
369
+ # Round 18.42: step 4 (POST /strategies/:id/export) 已废. QMT shell 不再
370
+ # server 渲染, 无 export_path 产出. 实盘落盘由 desktop 负责.
354
371
 
355
372
  print('', file=sys.stderr)
356
373
  print(json.dumps({
357
374
  "strategy_id": sid,
358
375
  "bundle_path": str(bundle_out),
359
- "qmt_body_path": str(qmt_body_path),
360
- "export_path": export_path,
361
376
  "verdict": "OK",
362
377
  }, ensure_ascii=False, indent=2))
363
378
  return 0
@@ -799,8 +814,9 @@ def cmd_live_run(args) -> int:
799
814
  print(f"[error] strategy.py 加载失败: {e}", file=sys.stderr)
800
815
  return 3
801
816
  try:
817
+ import json as _json
802
818
  symbols = [s.strip() for s in args.symbols.split(",")] if args.symbols else None
803
- params = json.loads(Path(args.params).read_text(encoding="utf-8")) if args.params else None
819
+ params = _json.loads(Path(args.params).read_text(encoding="utf-8")) if args.params else None
804
820
  # 只取 strategy_params 段 (schema 顶层: backtest / strategy_params / risk_config / meta)
805
821
  sp = (params or {}).get("strategy_params")
806
822
  run_live(
@@ -842,6 +858,9 @@ def build_parser() -> argparse.ArgumentParser:
842
858
  r.add_argument("--config", required=True, help="CONFIG JSON 路径")
843
859
  r.add_argument("--output", help="result 落盘 JSON 路径 (默认 stdout)")
844
860
  r.add_argument("--dataset", help="固化 manifest JSON 路径")
861
+ r.add_argument("--params", default=None,
862
+ help="params.json 路径 (固定 schema; strategy_params 段作策略构造参数, "
863
+ "与 `live run --params` 同源 — 回测/实盘参数一致)")
845
864
  r.add_argument("--skip-discipline", action="store_true",
846
865
  help="跳过 akquant API 静态审查 (qa / 旧策略兼容)")
847
866
  r.add_argument("--upload", action="store_true",
@@ -886,16 +905,14 @@ def build_parser() -> argparse.ArgumentParser:
886
905
  qt.add_argument("--output", help="QMT body 落盘路径")
887
906
  qt.set_defaults(func=cmd_qmt_translate)
888
907
 
889
- cm = sub.add_parser("commit", help="commiter 5 步打包上传")
908
+ cm = sub.add_parser("commit", help="commiter 3 步打包上传 (bundle + PUT result + POST code)")
890
909
  cm.add_argument("strategy_id", help="server 端 strategy_id")
891
910
  cm.add_argument("--strategy", help="akquant strategy .py 路径 (默认 <id>.py)")
892
911
  cm.add_argument("--result", required=True, help="metrics result.json 路径")
893
912
  cm.add_argument("--config", help="config.json 路径")
894
- cm.add_argument("--spec", help="spec_strategy.json 路径")
913
+ cm.add_argument("--params", default=None,
914
+ help="params.json 路径 (固定 schema; 整个内容作 multipart params 字段, desktop 实盘配置源)")
895
915
  cm.add_argument("--bundle-out", help="bundle tar.gz 落盘路径")
896
- cm.add_argument("--skip-export", action="store_true",
897
- help="跳过 step 5 (POST /strategies/:id/export)")
898
- cm.add_argument("--export-out", help="step 5 输出 .qmt.py 路径")
899
916
  cm.set_defaults(func=cmd_commit)
900
917
 
901
918
  d = sub.add_parser("dataset", help="构建/列出 prebuilt 数据集")
@@ -84,14 +84,14 @@ def load_prebuilt_to_akquant(universe: Iterable[str], start: str, end: str,
84
84
 
85
85
  universe: 标的代码 list, e.g. ['600000.SH', '000001.SZ']
86
86
  start/end: 'YYYYMMDD' 字符串
87
- period: '1d' (Phase B 仅日线)
87
+ period: '1d' | '5m' (2026-08-27 打通 5m; 需本地 5m bundle, 见 prefet resolver).
88
88
 
89
89
  数据源: prebuilt_resolver.resolve (pyarrow filter pushdown, fast-path).
90
- prebuilt 找不到 → raise FileNotFoundError + 引导用户跑 `dataset fetch`.
90
+ prebuilt 找不到 → raise FileNotFoundError + 引导用户跑 `dataset fetch --period 5m`.
91
91
  """
92
- if period != '1d':
92
+ if period not in ('1d', '5m'):
93
93
  raise NotImplementedError(
94
- f'akquant_data_adapter 仅支持 1d (Phase B), period={period} 待 akquant Phase C')
94
+ f'akquant_data_adapter 仅支持 1d/5m, period={period} 待 akquant Phase C')
95
95
  codes = list(universe)
96
96
  if not codes:
97
97
  raise ValueError('universe 不能为空')
@@ -124,9 +124,15 @@ def load_prebuilt_to_akquant(universe: Iterable[str], start: str, end: str,
124
124
  f'prebuilt 缺 OHLCV 列 {missing} (R2: schema 不兼容). 实际列: {list(df.columns)[:10]}...')
125
125
  for c in _DROP_COLS:
126
126
  df = df.drop(columns=[c], errors='ignore')
127
- # time 字段兼容 8 位 (YYYYMMDD, 个股) / 14 位 (YYYYMMDDHHMMSS, ETF/可转债) / ISO (YYYY-MM-DD)
128
- _date_str = df['date'].astype(str).str[:10].str.replace('-', '', regex=False)
129
- df['date'] = pd.to_datetime(_date_str, format='%Y%m%d')
127
+ # date 列: resolver 已归一 datetime64 (日线 = 当日 00:00, 5m = 已带时分秒).
128
+ # 若仍是字符串 (老 single 路径), 按 period 粒度解析:
129
+ # '5m' 保留时分秒 (YYYYMMDDHHMMSS), '1d' 取 8 位日期.
130
+ if not pd.api.types.is_datetime64_any_dtype(df['date']):
131
+ _date_str = df['date'].astype(str).str[:14].str.replace('-', '', regex=False)
132
+ if period == '5m':
133
+ df['date'] = pd.to_datetime(_date_str, format='%Y%m%d%H%M%S', errors='coerce')
134
+ else:
135
+ df['date'] = pd.to_datetime(_date_str.str[:8], format='%Y%m%d', errors='coerce')
130
136
  df = df[['date', 'open', 'high', 'low', 'close', 'volume', 'symbol']]
131
137
  return df.reset_index(drop=True)
132
138
 
@@ -188,8 +194,6 @@ def _try_prebuilt_parquet(universe: Iterable[str], start: str, end: str,
188
194
 
189
195
  返回 None 表示无 prebuilt 命中 (调用方 fallback: 报错 + 引导下载).
190
196
  """
191
- if period != '1d':
192
- return None
193
197
  from .prebuilt_resolver import resolve # B5 搬入同包
194
198
  df, _src = resolve(list(universe), start, end, period=period)
195
199
  return df
@@ -36,7 +36,79 @@ from pathlib import Path
36
36
  from typing import Any
37
37
 
38
38
 
39
+ class StrategyParamsError(RuntimeError):
40
+ """--params strategy_params 预校验失败 (validate_strategy_params).
41
+
42
+ cmd_run 用此类型区分 exit 3 (参数错, 改 params.json) 与 runner 其它 RuntimeError (exit 4).
43
+ Round 18.13.
44
+ """
45
+
46
+
39
47
  # ---- strategy loader (沿 driver._load_strategy 模式, 但导 Strategy 类不导 handlebar) ----
48
+ def _with_backtest_timer_rebalance(cls: type) -> type:
49
+ """回测端周五 schedule + on_timer 注入 — 与实盘 replay 同触发路径.
50
+
51
+ Round 18.32: 之前回测横截面策略走 `on_cross_section`, 实盘 replay 走
52
+ `on_timer` (runner 外部日历注入 schedule) — 两端路径不同, 可比性差 (实盘丢
53
+ 07-03 批 / 因子一次性 vs 逐 bar 演进). 本 wrapper 让**回测也走 on_timer**:
54
+
55
+ - `on_start` 时 `_trading_days` 未注入 (engine.py:3107 on_start 在 :3502 注入
56
+ 之前), `self.trading_days` 恒空 — 所以**延迟到首个 on_bar** 再注册.
57
+ - 首个 bar 时从 `self.trading_days` 筛周五, 逐周注册 `schedule(周五14:55, 'rebalance')`
58
+ → 引擎按 bar 边界触发 `on_timer('rebalance')`, ctx.current_time 即该周五.
59
+ - 策略已定义 on_timer 处理 'rebalance' → 与实盘完全同路径; 未定义 → base no-op, 无害.
60
+ - 只包有 on_timer 的策略 (无 on_timer 时 on_cross_section 仍是唯一回测调仓钩子,
61
+ 注入 schedule 白费且可能引发无预期 on_timer 调用).
62
+
63
+ ponytail: 与 live 端 `_with_live_history_depth` 互补 — live 用外部日历注入
64
+ (replay bars / bridge 历史), 回测用引擎 trading_days. 两端最终都注册
65
+ `schedule(周五14:55, 'rebalance')` → `on_timer`, 真正同机制.
66
+ """
67
+ import datetime as _dt
68
+
69
+ if cls is None or "on_timer" not in getattr(cls, "__dict__", {}):
70
+ return cls
71
+ from akquant.strategy_scheduler import schedule as _schedule
72
+
73
+ class _BacktestTimerRebalance(cls):
74
+ _bt_timer_registered = False
75
+
76
+ def on_bar(self, bar) -> Any:
77
+ ret_val = super().on_bar(bar)
78
+ if not type(self)._bt_timer_registered:
79
+ type(self)._bt_timer_registered = True
80
+ try:
81
+ fridays = [ts for ts in self.trading_days if ts.weekday() == 4]
82
+ for ts in fridays:
83
+ _schedule(self, ts.replace(hour=14, minute=55), "rebalance")
84
+ print(
85
+ f"[info] 回测周五 schedule+on_timer 注入: {len(fridays)} 个 "
86
+ f"({[ts.strftime('%m-%d') for ts in fridays]})",
87
+ file=sys.stderr,
88
+ )
89
+ except Exception as e: # noqa: BLE001 — 注入失败不崩, 回测走原路径
90
+ print(
91
+ f"[warn] 回测周五 schedule 注入失败: {e}",
92
+ file=sys.stderr,
93
+ )
94
+ return ret_val
95
+
96
+ def on_cross_section(self, trading_date, timestamp) -> Any:
97
+ """吞掉 on_cross_section — schedule 定时器已接管调仓 (Round 18.32).
98
+
99
+ 策略类同时有 on_cross_section (回测旧路径) 和 on_timer (实盘新路径) 时,
100
+ 同一个周五**两个钩子都会触发** → 重复下单 (实测: 07-06 批 20→22 trades,
101
+ 000006/000035 各重复买一次). 本 wrapper 把 on_cross_section 变 no-op,
102
+ 让回测统一走 schedule+on_timer, 与实盘完全同路径. 若策略没有
103
+ on_cross_section, 本方法不干扰 (未重写 → 走父类 no-op, 同样无害).
104
+ """
105
+ return None
106
+
107
+ _BacktestTimerRebalance.__name__ = cls.__name__
108
+ _BacktestTimerRebalance.__qualname__ = cls.__qualname__
109
+ return _BacktestTimerRebalance
110
+
111
+
40
112
  def _load_akquant_strategy(strategy_path: str) -> Any:
41
113
  """加载 strategy .py, 提取第一个 akquant.Strategy 子类.
42
114
 
@@ -101,13 +173,15 @@ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
101
173
  pass
102
174
 
103
175
  # data — Q1 兜底默认开 (cfg['price_limit_clamp']=False 可关)
176
+ # period: cfg['period'] 支持 '1d'|'5m' (2026-08-27) — 5m 走 __bundle__*_5m
104
177
  use_clamp = bool(cfg.get('price_limit_clamp', True))
178
+ _period = cfg.get('period', '1d')
105
179
  if use_clamp:
106
180
  from .akquant_data_adapter import load_prebuilt_to_akquant_with_limits # B4
107
181
  df = load_prebuilt_to_akquant_with_limits(
108
- universe, cfg['backtest_start'], cfg['backtest_end'])
182
+ universe, cfg['backtest_start'], cfg['backtest_end'], period=_period)
109
183
  else:
110
- df = load_prebuilt_to_akquant(universe, cfg['backtest_start'], cfg['backtest_end'])
184
+ df = load_prebuilt_to_akquant(universe, cfg['backtest_start'], cfg['backtest_end'], period=_period)
111
185
  if df.empty:
112
186
  raise FileNotFoundError(
113
187
  f'未取到 bar 数据 (universe={universe}, {cfg["backtest_start"]}-{cfg["backtest_end"]})')
@@ -119,6 +193,9 @@ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
119
193
 
120
194
  # strategy class
121
195
  strat_cls = _load_akquant_strategy(strategy_path)
196
+ # Round 18.32: 回测也走 schedule+on_timer (与实盘 replay 同触发路径) — 在 class
197
+ # 层面 wrap, 必须在此处 (kwargs['strategy'] 在下方构建, wrap 需在构建前生效).
198
+ strat_cls = _with_backtest_timer_rebalance(strat_cls)
122
199
 
123
200
  # ===== 0.3 架构变更 (2026-08-18 简化): compute_factors / filter_symbols 接入引擎 =====
124
201
  # 流程: 实例化 → compute_factors (直接传 prebuilt df, 引擎不包装 BacktestContext) →
@@ -211,16 +288,48 @@ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
211
288
  # 只有策略声明该字段才注入 — 未声明注入 = engine strict 模式 unknown key 崩溃.
212
289
  # ponytail: 回测侧 exclude_symbols 仅来自 cfg; 实盘 QMT「排除已持仓」需策略在
213
290
  # on_bar 里自己调 broker query_positions() (runner 不透传持仓, 那是动态状态).
291
+ # ===== strategy 调参展开注入 (0.3.x 原生 kwargs 路径, Round 18.13) =====
292
+ # 之前把 universe/exclude_symbols 打包成 kwargs['strategy_params'] dict — 但
293
+ # akquant.run_backtest 签名原生**没有** strategy_params (只有 **kwargs + strict_strategy_params=True),
294
+ # engine 把 kwargs 里未知 key 拼进 strategy(**kwargs) 构造. 现在把参数**展开**进 kwargs:
295
+ # - universe/exclude_symbols: cfg 归一化后直接展开 (engine 经 **kwargs → strategy(**merged) 注入)
296
+ # - strategy_params 段: cmd_run --params 读 params.json 的 strategy_params (Round 18.13),
297
+ # 与 live run --params 同源, 回测/实盘参数完全一致
298
+ # engine strict_strategy_params=True 会对未声明字段抛 TypeError (老 __init__ 风格已有 _strategy_param_fields 豁免).
214
299
  accepted_fields = _strategy_param_fields(strat_cls)
215
- strategy_params: dict[str, Any] = {}
216
300
  if 'universe' in accepted_fields:
217
- strategy_params['universe'] = universe
301
+ kwargs['universe'] = universe
218
302
  if 'exclude_symbols' in accepted_fields:
219
303
  exclude_codes = cfg.get('exclude_symbols') or []
220
- strategy_params['exclude_symbols'] = [
304
+ kwargs['exclude_symbols'] = [
221
305
  normalize_symbol(s) for s in exclude_codes]
222
- if strategy_params:
223
- kwargs['strategy_params'] = strategy_params
306
+ # params.json strategy_params 段 → 展开注入 (仅声明字段; 未声明 → strict 模式 engine 拒收)
307
+ user_params = (cfg.get('strategy_params') or {}).get('strategy_params') \
308
+ if isinstance(cfg.get('strategy_params'), dict) and \
309
+ isinstance((cfg.get('strategy_params') or {}).get('strategy_params'), dict) \
310
+ else (cfg.get('strategy_params') if isinstance(cfg.get('strategy_params'), dict) else None)
311
+ user_params = user_params if isinstance(user_params, dict) else None
312
+ if user_params:
313
+ # 先 validate_strategy_params 预校验 (与 live run --params 同语义) — 未声明字段/
314
+ # 越界值在回测启动前抛明确错误, 而不是 engine 深处 TypeError.
315
+ try:
316
+ from akquant import validate_strategy_params
317
+ validated = validate_strategy_params(strat_cls, user_params)
318
+ except Exception as e:
319
+ raise StrategyParamsError(
320
+ f"params.json strategy_params 校验失败 (与 IntParam/ListParam 字段不符): {e}"
321
+ ) from e
322
+ if validated:
323
+ for k, v in validated.items():
324
+ if k not in accepted_fields:
325
+ continue
326
+ # 不覆盖 runner 已注入的 universe/exclude_symbols **非空值** (它们来自 cfg,
327
+ # 与 params.json 的 backtest 段同源; validate 返回的默认 [] 会误覆盖).
328
+ # 空 list 时允许 params.json 覆盖 — 否则 --params strategy_params.exclude_symbols
329
+ # 永远被 cfg 空注入跳过, 隔离参数静默失效 (Round 18.35 真回测踩坑).
330
+ if kwargs.get(k):
331
+ continue
332
+ kwargs[k] = v
224
333
 
225
334
  # ===== 0.3.x 加速原语 (自动启用, strategy 不用改) =====
226
335
  # 1) history_depth — 推断: scan __init__ 默认值 + warmup_period + cfg['history_depth']
@@ -509,9 +618,10 @@ class MACross(Strategy):
509
618
  print(f'OK: akquant_runner 端到端通 (15 metrics, trades={len(r["trades"])}, eq_points={n_eq})')
510
619
 
511
620
  # Q1 regression: 0.3.x 新 API — 收 `universe` ParamModel 字段的策略, runner 必须
512
- # 把 universe 注入 strategy_params (否则 on_start 空订阅 → 0 trades, 高胜率
513
- # 策略实际踩过). 0.3.x 严格拒收老 __init__ 形参, 所以检测 → 注入路径改了.
514
- # monkeypatch akquant.run_backtest 捕获 kwargs, 断言 strategy_params 在.
621
+ # 把 universe 注入 (否则 on_start 空订阅 → 0 trades, 高胜率策略实际踩过).
622
+ # Round 18.13: 注入从 strategy_params dict 改为**展开进 kwargs 顶层** —
623
+ # run_backtest 签名无 strategy_params (只有 **kwargs + strict_strategy_params),
624
+ # engine 把未知 key 拼进 strategy(**kwargs). 断言 universe 直接展开.
515
625
  import akquant as _akq
516
626
  captured: dict = {}
517
627
  _orig_run = _akq.run_backtest
@@ -524,9 +634,9 @@ class MACross(Strategy):
524
634
  _akq.run_backtest = _capture
525
635
  try:
526
636
  _ = run_akquant_backtest(strat_path, cfg) # MACross 不收 universe
527
- # MACross 不收 universe → 不注入 strategy_params (0.2.x 旧路径已被 0.3 移除)
528
- assert 'strategy_params' not in captured, (
529
- f'MACross 不应注入 strategy_params: {captured.get("strategy_params")}')
637
+ # MACross 不收 universe → kwargs 顶层不应有 universe / strategy_params
638
+ assert 'universe' not in captured and 'strategy_params' not in captured, (
639
+ f'MACross 不应注入 universe: {captured.get("universe")!r}')
530
640
  finally:
531
641
  _akq.run_backtest = _orig_run
532
642
  captured.clear()
@@ -543,8 +653,8 @@ class UsesUniverse(Strategy):
543
653
  pass
544
654
  ''', encoding='utf-8')
545
655
  _ = run_akquant_backtest(strat3, cfg) # UsesUniverse 收 universe
546
- assert captured.get('strategy_params') == {'universe': ['600000.SH']}, (
547
- f'UsesUniverse (0.3 ParamModel) 注入错: {captured.get("strategy_params")}')
656
+ assert captured.get('universe') == ['600000.SH'], (
657
+ f'UsesUniverse (0.3 ParamModel) 展开注入错: {captured.get("universe")!r}')
548
658
  finally:
549
659
  _akq.run_backtest = _orig_run
550
660
  # 0.2.x 旧 __init__ 风格 → 检测返 False, runner 跳过注入 (0.3 strict 拒收).
@@ -555,7 +665,7 @@ class UsesUniverse(Strategy):
555
665
  'MACross (无 universe 字段) 应 _strategy_accepts_universe_param=False')
556
666
  assert _strategy_accepts_universe_param(_uses_cls), (
557
667
  'UsesUniverse (ListParam) 应 _strategy_accepts_universe_param=True')
558
- print(f'OK: universe 注入路径生效 (0.3 ParamModel → strategy_params; 旧 __init__ 风格跳过)')
668
+ print(f'OK: universe 注入路径生效 (0.3 ParamModel → kwargs 展开; 旧 __init__ 风格跳过)')
559
669
 
560
670
  # ===== exclude_symbols 注入 (2026-08-25, 用户实战踩坑): =====
561
671
  # 仅当策略声明 `exclude_symbols = ListParam(...)` 才注入; 未声明注入 =
@@ -576,10 +686,10 @@ class UsesExclude(Strategy):
576
686
  try:
577
687
  cfg_ex = {**cfg, 'exclude_symbols': ['600036']}
578
688
  _ = run_akquant_backtest(strat4, cfg_ex)
579
- assert captured.get('strategy_params') == {
580
- 'universe': ['600000.SH'],
581
- 'exclude_symbols': ['600036.SH'],
582
- }, f'UsesExclude 注入错 (应归一化为带后缀): {captured.get("strategy_params")}'
689
+ assert captured.get('universe') == ['600000.SH'] and \
690
+ captured.get('exclude_symbols') == ['600036.SH'], (
691
+ f'UsesExclude 展开注入错 (应归一化带后缀 + 顶层 key): '
692
+ f'u={captured.get("universe")!r} ex={captured.get("exclude_symbols")!r}')
583
693
  finally:
584
694
  _akq.run_backtest = _orig_run
585
695
  # MACross (无 ParamModel) 不应注入 exclude_symbols — strict 模式会崩
@@ -587,8 +697,8 @@ class UsesExclude(Strategy):
587
697
  _akq.run_backtest = _capture
588
698
  try:
589
699
  _ = run_akquant_backtest(strat_path, cfg_ex) # MACross 走 0.2 __init__ 风格
590
- assert 'strategy_params' not in captured, (
591
- f'MACross 不应注入 exclude_symbols: {captured.get("strategy_params")}')
700
+ assert 'exclude_symbols' not in captured and 'strategy_params' not in captured, (
701
+ f'MACross 不应注入 exclude_symbols: {captured.get("exclude_symbols")!r}')
592
702
  finally:
593
703
  _akq.run_backtest = _orig_run
594
704
  _uses_excl = _load_akquant_strategy(strat4)
@@ -598,6 +708,45 @@ class UsesExclude(Strategy):
598
708
  'MACross (无 exclude_symbols 字段) 应 False')
599
709
  print('OK: exclude_symbols 注入 (仅声明注入 + 归一化带后缀 + 未声明跳过)')
600
710
 
711
+ # ===== Round 18.13: params.json strategy_params 展开注入 (回测 --params) =====
712
+ # cmd_run --params 读 params.json 的 strategy_params 段注入 cfg['strategy_params'],
713
+ # runner 展开进 kwargs 顶层 (红线: 必须 validate_strategy_params 预校验).
714
+ strat5 = Path(tmp) / 'params_poly.py'
715
+ strat5.write_text('''
716
+ from akquant import Strategy, IntParam, ListParam
717
+
718
+ class ParamsPoly(Strategy):
719
+ fast = IntParam(10, ge=2, le=200)
720
+ slow = IntParam(20, ge=3, le=500)
721
+ universe: list = ListParam(default=[])
722
+ def on_bar(self, bar):
723
+ pass
724
+ ''', encoding='utf-8')
725
+ captured.clear()
726
+ _akq.run_backtest = _capture
727
+ try:
728
+ cfg_p = {**cfg, 'strategy_params': {'fast': 5, 'slow': 33}}
729
+ _ = run_akquant_backtest(strat5, cfg_p)
730
+ assert captured.get('fast') == 5 and captured.get('slow') == 33, (
731
+ f'params strategy_params 应展开注入 kwargs 顶层: '
732
+ f'fast={captured.get("fast")!r} slow={captured.get("slow")!r}')
733
+ assert captured.get('universe') == ['600000.SH'], (
734
+ f'universe 应继续展开注入: {captured.get("universe")!r}')
735
+ finally:
736
+ _akq.run_backtest = _orig_run
737
+ # 未知字段 → StrategyParamsError 启动前抛 (cmd_run 转 exit 3), 不是 engine 深处 TypeError
738
+ captured.clear()
739
+ _akq.run_backtest = _capture
740
+ try:
741
+ try:
742
+ _ = run_akquant_backtest(strat5, {**cfg_p, 'strategy_params': {'bogus': 1}})
743
+ raise AssertionError('未知字段应被 validate_strategy_params 拦')
744
+ except StrategyParamsError as e:
745
+ assert '校验失败' in str(e), f'应报校验失败: {e}'
746
+ finally:
747
+ _akq.run_backtest = _orig_run
748
+ print('OK: params strategy_params 展开注入 (validate 预校验 + 未知字段拦在启动前)')
749
+
601
750
  # ===== Round 1: history_depth 推断 + 0.3 kwarg 探测 ——
602
751
  # 1) history_depth 推断: 用 MACross(fast=3, slow=5, ...), 预期 max(3,5)+10=15
603
752
  # (注意: 这里 fast=3 / slow=5 是 selfcheck 里写死的, 不是 user 默认)