hamuna-quant-cli 0.1.2__tar.gz → 0.1.7__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.2 → hamuna_quant_cli-0.1.7}/PKG-INFO +1 -1
  2. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/__init__.py +1 -1
  3. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/__main__.py +75 -66
  4. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/akquant_runner.py +5 -3
  5. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/qmt_translator.py +3 -2
  6. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/server_client.py +26 -72
  7. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
  8. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/SOURCES.txt +1 -2
  9. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/pyproject.toml +1 -1
  10. hamuna_quant_cli-0.1.2/tests/test_skeleton.py +0 -154
  11. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/MANIFEST.in +0 -0
  12. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/README.md +0 -0
  13. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/README.md +0 -0
  14. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/_market_fallback.py +0 -0
  15. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/_metrics_15.py +0 -0
  16. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
  17. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
  18. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/akquant_schema_adapter.py +0 -0
  19. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/base_strategy.py +0 -0
  20. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
  21. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/live/__init__.py +0 -0
  22. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/live/loader.py +0 -0
  23. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/live/qmt_broker.py +0 -0
  24. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/live/qmt_market.py +0 -0
  25. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/live/runner.py +0 -0
  26. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
  27. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
  28. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/__init__.py +0 -0
  29. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/backtest.py +0 -0
  30. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/cache.py +0 -0
  31. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/discipline.py +0 -0
  32. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/http_client.py +0 -0
  33. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/runtime/s3client.py +0 -0
  34. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli/scripts/server.json +0 -0
  35. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
  36. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
  37. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/requires.txt +0 -0
  38. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
  39. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/setup.cfg +0 -0
  40. {hamuna_quant_cli-0.1.2 → hamuna_quant_cli-0.1.7}/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.2
3
+ Version: 0.1.7
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,4 +12,4 @@ server (13-key metrics schema). 全部走 akquant 0.3.x 引擎.
12
12
  """
13
13
  from __future__ import annotations
14
14
 
15
- __version__ = "0.1.2"
15
+ __version__ = "0.1.7"
@@ -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
 
@@ -30,6 +30,37 @@ def _load_config(path: str) -> dict:
30
30
  return json.loads(Path(path).read_text(encoding="utf-8"))
31
31
 
32
32
 
33
+ def _load_params_arg(arg: str) -> dict | None:
34
+ """Round 18.48+: --params 既支持 inline JSON (以 '{' 开头) 又支持文件路径.
35
+
36
+ 历史 CLI / 回测脚本传文件路径 (`hamuna_quant_cli run --params ./params.json`).
37
+ desktop Rust 调 live run 时通过 `read_to_string` 把 `<name>.params.json` 读成
38
+ String 后 inline 传过来 (`--params '<json>'`), 旧 cli 把 inline 当文件路径
39
+ 直接 `Path.read_text()` → FileNotFoundError (本轮 bug 报告).
40
+
41
+ 协议: 以 '{' (允许前导空白) 开头 → inline JSON parse; 否则 → 文件路径 read.
42
+ Caller 决定 None vs raise: cmd_commit 容忍 None + 走 schema warning,
43
+ cmd_live_run 期望非空否则 raise FileNotFoundError.
44
+ """
45
+ if not arg:
46
+ return None
47
+ stripped = arg.lstrip()
48
+ if stripped.startswith("{"):
49
+ import json as _json
50
+ try:
51
+ return _json.loads(arg)
52
+ except _json.JSONDecodeError as e:
53
+ raise ValueError(f"--params inline JSON 解析失败: {e}") from e
54
+ p = Path(arg)
55
+ if not p.exists():
56
+ raise FileNotFoundError(f"--params 不存在: {arg}")
57
+ import json as _json
58
+ try:
59
+ return _json.loads(p.read_text(encoding="utf-8"))
60
+ except _json.JSONDecodeError as e:
61
+ raise ValueError(f"--params 解析失败: {arg} ({e})") from e
62
+
63
+
33
64
  def _parse_symbols(s: str) -> list[str]:
34
65
  """CSV symbols → 裸码 list (A 股 dataset stockCode 不带 .SH/.SZ/.BJ 后缀)."""
35
66
  out: list[str] = []
@@ -72,18 +103,17 @@ def cmd_run(args) -> int:
72
103
  # --params (params.json 固定 schema) → strategy_params 段注入 cfg,
73
104
  # akquant_runner 展开注入 engine kwargs. 与 `live run --params` 同源:
74
105
  # 同一份 params.json 驱动回测 + 实盘, 参数约定不再分裂 (Round 18.13).
106
+ # Round 18.48+: 既支持文件路径也支持 inline JSON (见 _load_params_arg).
75
107
  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
108
  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)
109
+ params_doc = _load_params_arg(args.params) or {}
110
+ except FileNotFoundError as e:
111
+ print(f"--params 不存在: {e}", file=sys.stderr)
112
+ return 2
113
+ except ValueError as e:
114
+ print(f"--params 解析失败: {e}", file=sys.stderr)
85
115
  return 2
86
- sp = (params_doc or {}).get("strategy_params")
116
+ sp = params_doc.get("strategy_params")
87
117
  if isinstance(sp, dict):
88
118
  cfg["strategy_params"] = sp
89
119
  else:
@@ -91,7 +121,7 @@ def cmd_run(args) -> int:
91
121
  # Round 18.14: risk_config 段 → cfg['risk_config'] → runner 透传
92
122
  # run_backtest(risk_config={"max_position_pct": 0.1}) 原生消费 (账户级风控,
93
123
  # 引擎 hasattr 校验 + 未知字段仅 warning). skill 桌面编辑的实盘风控真正生效.
94
- rc = (params_doc or {}).get("risk_config")
124
+ rc = params_doc.get("risk_config")
95
125
  if isinstance(rc, dict):
96
126
  # 与既有 cfg['risk_config'] 合并 (显式 cfg 优先)
97
127
  merged = dict(cfg.get("risk_config") or {})
@@ -288,14 +318,20 @@ def cmd_qmt_translate(args) -> int:
288
318
  print(f"QMT body saved → {out}", file=sys.stderr)
289
319
  import json
290
320
  print(json.dumps({"qmt_body_path": str(out), "note":
291
- "本地 stub; cloud export 端点会重新渲染 + 签 cert + 嵌入 embedded API key"},
321
+ "本地 stub — Round 18.42 起 cloud 不再渲染 .qmt.py, 实盘由 desktop 落盘原始 .py → bridge_server 执行"},
292
322
  ensure_ascii=False))
293
323
  return 0
294
324
 
295
325
 
296
326
  def cmd_commit(args) -> int:
297
- """commiter 5 步打包上传 (qmt-translate + bundle + PUT result + POST code + POST export)."""
298
- from .qmt_translator import write_qmt_body
327
+ """commiter 3 步打包上传 (bundle + PUT result + POST code).
328
+
329
+ Round 18.42: POST /strategies/:id/export 端点已废, 不再生成 .qmt.py shell —
330
+ server 仅存策略源码. QMT 实盘由 desktop deploy_strategy 落盘原始 .py →
331
+ bridge_server 扫描执行; hamuna-quant-cli 下单走 bridge_server HTTP.
332
+ bundle = 4 文件 (strategy + result + config + params). multipart params 字段
333
+ = 整个 params.json 内容 (ADR-0025, desktop 实盘配置源).
334
+ """
299
335
  from . import runtime
300
336
  import json
301
337
  import tarfile
@@ -314,35 +350,26 @@ def cmd_commit(args) -> int:
314
350
  if cfg_path and not cfg_path.exists():
315
351
  print(f"config.json 不存在: {cfg_path}", file=sys.stderr)
316
352
  return 2
317
- spec_path = Path(args.spec) if args.spec else None
318
- if spec_path and not spec_path.exists():
319
- print(f"spec_strategy.json 不存在: {spec_path} (--spec 可选)", file=sys.stderr)
320
- spec_path = None
353
+ params_path = Path(args.params) if args.params else None
354
+ if params_path and not params_path.exists():
355
+ print(f"params.json 不存在: {params_path} (--params 可选)", file=sys.stderr)
356
+ return 2
321
357
 
322
358
  bundle_out = Path(args.bundle_out) if args.bundle_out else Path(f"runs/{sid}/bundle.tar.gz")
323
359
  bundle_out.parent.mkdir(parents=True, exist_ok=True)
324
360
 
325
- print("=== Step 1/5: akquant → QMT body 翻译 (本地 stub) ===", file=sys.stderr)
326
- try:
327
- qmt_body_path = write_qmt_body(strategy_path, cfg_path, spec_path)
328
- except NotImplementedError as e:
329
- print(f" ✗ 不支持: {e}", file=sys.stderr)
330
- return 3
331
- print(f" ✓ qmt_body = {qmt_body_path}", file=sys.stderr)
332
-
333
- print("=== Step 2/5: 打包 bundle (strategy + qmt_body + metrics + spec) ===",
361
+ print("=== Step 1/3: 打包 bundle (strategy + result + config + params) ===",
334
362
  file=sys.stderr)
335
363
  with tarfile.open(bundle_out, "w:gz") as tar:
336
364
  tar.add(strategy_path, arcname=strategy_path.name)
337
- tar.add(qmt_body_path, arcname=qmt_body_path.name)
338
365
  tar.add(result_path, arcname=result_path.name)
339
- if spec_path:
340
- tar.add(spec_path, arcname=spec_path.name)
341
366
  if cfg_path:
342
367
  tar.add(cfg_path, arcname=cfg_path.name)
368
+ if params_path:
369
+ tar.add(params_path, arcname=params_path.name)
343
370
  print(f" ✓ bundle = {bundle_out} ({bundle_out.stat().st_size} bytes)", file=sys.stderr)
344
371
 
345
- print("=== Step 3/5: PUT /strategies/:id/result (metrics) ===", file=sys.stderr)
372
+ print("=== Step 2/3: PUT /strategies/:id/result (metrics) ===", file=sys.stderr)
346
373
  try:
347
374
  result = json.loads(result_path.read_text(encoding="utf-8"))
348
375
  except json.JSONDecodeError as e:
@@ -355,47 +382,27 @@ def cmd_commit(args) -> int:
355
382
  return 4
356
383
  print(f" ✓ metrics uploaded (resp keys: {sorted(resp.keys())[:5]})", file=sys.stderr)
357
384
 
358
- print("=== Step 4/5: POST /strategies/:id/code (multipart body + params) ===",
385
+ print("=== Step 3/3: POST /strategies/:id/code (multipart body + params) ===",
359
386
  file=sys.stderr)
360
- params: dict = {}
361
- if spec_path:
362
- params["spec_strategy"] = json.loads(spec_path.read_text(encoding="utf-8"))
363
- if cfg_path:
364
- params["config"] = json.loads(cfg_path.read_text(encoding="utf-8"))
387
+ params: dict | None = None
388
+ if params_path:
389
+ params = json.loads(params_path.read_text(encoding="utf-8"))
365
390
  try:
366
- resp_code = runtime.server_client.upload_strategy_code(sid, str(qmt_body_path),
367
- params=params or None)
391
+ resp_code = runtime.server_client.upload_strategy_code(sid, str(strategy_path),
392
+ params=params)
368
393
  except runtime.server_client.ServerError as e:
369
394
  print(f" ✗ 上传源码失败: {e}", file=sys.stderr)
370
395
  return 4
371
396
  print(f" ✓ body uploaded (body_hash={resp_code.get('body_hash', '?')[:16]})",
372
397
  file=sys.stderr)
373
398
 
374
- export_path = None
375
- if not args.skip_export:
376
- print("=== Step 5/5: POST /strategies/:id/export (拉 .qmt.py shell) ===",
377
- file=sys.stderr)
378
- export_out = Path(args.export_out) if args.export_out else Path(
379
- f"runs/{sid}/{sid}.qmt.py"
380
- )
381
- export_out.parent.mkdir(parents=True, exist_ok=True)
382
- try:
383
- resp_exp = runtime.server_client.export_strategy(sid, str(export_out))
384
- except runtime.server_client.ServerError as e:
385
- print(f" ✗ QMT export 失败: {e}", file=sys.stderr)
386
- return 4
387
- export_path = str(export_out)
388
- print(f" ✓ shell saved → {export_path} "
389
- f"({resp_exp.get('shell_bytes', '?')} bytes)", file=sys.stderr)
390
- else:
391
- print("=== Step 5/5: SKIPPED (--skip-export) ===", file=sys.stderr)
399
+ # Round 18.42: step 4 (POST /strategies/:id/export) 已废. QMT shell 不再
400
+ # server 渲染, 无 export_path 产出. 实盘落盘由 desktop 负责.
392
401
 
393
402
  print('', file=sys.stderr)
394
403
  print(json.dumps({
395
404
  "strategy_id": sid,
396
405
  "bundle_path": str(bundle_out),
397
- "qmt_body_path": str(qmt_body_path),
398
- "export_path": export_path,
399
406
  "verdict": "OK",
400
407
  }, ensure_ascii=False, indent=2))
401
408
  return 0
@@ -837,9 +844,13 @@ def cmd_live_run(args) -> int:
837
844
  print(f"[error] strategy.py 加载失败: {e}", file=sys.stderr)
838
845
  return 3
839
846
  try:
840
- import json as _json
841
847
  symbols = [s.strip() for s in args.symbols.split(",")] if args.symbols else None
842
- params = _json.loads(Path(args.params).read_text(encoding="utf-8")) if args.params else None
848
+ # Round 18.48+: --params 既支持 inline JSON 也支持文件路径 (desktop rust read_to_string 传 inline)
849
+ try:
850
+ params = _load_params_arg(args.params) if args.params else None
851
+ except (FileNotFoundError, ValueError) as e:
852
+ print(f"[error] --params: {e}", file=sys.stderr)
853
+ return 2
843
854
  # 只取 strategy_params 段 (schema 顶层: backtest / strategy_params / risk_config / meta)
844
855
  sp = (params or {}).get("strategy_params")
845
856
  run_live(
@@ -928,16 +939,14 @@ def build_parser() -> argparse.ArgumentParser:
928
939
  qt.add_argument("--output", help="QMT body 落盘路径")
929
940
  qt.set_defaults(func=cmd_qmt_translate)
930
941
 
931
- cm = sub.add_parser("commit", help="commiter 5 步打包上传")
942
+ cm = sub.add_parser("commit", help="commiter 3 步打包上传 (bundle + PUT result + POST code)")
932
943
  cm.add_argument("strategy_id", help="server 端 strategy_id")
933
944
  cm.add_argument("--strategy", help="akquant strategy .py 路径 (默认 <id>.py)")
934
945
  cm.add_argument("--result", required=True, help="metrics result.json 路径")
935
946
  cm.add_argument("--config", help="config.json 路径")
936
- cm.add_argument("--spec", help="spec_strategy.json 路径")
947
+ cm.add_argument("--params", default=None,
948
+ help="params.json 路径 (固定 schema; 整个内容作 multipart params 字段, desktop 实盘配置源)")
937
949
  cm.add_argument("--bundle-out", help="bundle tar.gz 落盘路径")
938
- cm.add_argument("--skip-export", action="store_true",
939
- help="跳过 step 5 (POST /strategies/:id/export)")
940
- cm.add_argument("--export-out", help="step 5 输出 .qmt.py 路径")
941
950
  cm.set_defaults(func=cmd_commit)
942
951
 
943
952
  d = sub.add_parser("dataset", help="构建/列出 prebuilt 数据集")
@@ -323,9 +323,11 @@ def run_akquant_backtest(strategy_path: str, cfg: dict) -> dict:
323
323
  for k, v in validated.items():
324
324
  if k not in accepted_fields:
325
325
  continue
326
- # 不覆盖 runner 已注入的 universe/exclude_symbols (它们来自 cfg, 与
327
- # params.json 的 backtest 段同源; validate 返回的默认 [] 会误覆盖).
328
- if k in kwargs:
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):
329
331
  continue
330
332
  kwargs[k] = v
331
333
 
@@ -3,8 +3,9 @@
3
3
  设计意图 (per `skills/hamuna-strategy-v2/references/qmt-export.md` §转换):
4
4
  - body 用 QMT 原生签名 (`def init(C)` + `def handlebar(C)` + `passorder`/`C.get_market_data_ex`)
5
5
  - CLI 端 driver / Context 适配 (v2 时代由 akquant 适配) — 双跑靠 "body 零翻译 + shim 状态代理"
6
- - 本地 translator **仅产出 audit/对照版 QMT body**, 真值由 server 端
7
- `POST /api/v1/strategies/:id/export` 重新渲染 shell + 嵌入 embedded API key + 签 cert
6
+ - 本地 translator **仅产出 audit/对照版 QMT body**. Round 18.42 起 server 端
7
+ `/strategies/:id/export` 端点已删除 — 实盘由 desktop `deploy_strategy` 落盘
8
+ 原始 .py → bridge_server 扫描执行, 下单由 hamuna-quant-cli 调 bridge_server.
8
9
 
9
10
  **支持的 API 映射** (8 类, 够覆盖 BuyHold / DualMA / LowVolTopK / CrossSection 主流形态):
10
11
  - `self.subscribe(sym)` → `C.subscribe(sym)`
@@ -1,13 +1,15 @@
1
1
  """strategy_cli.runtime.server_client (hamuna-strategy-v2): CLI → server 上传 (backtest result).
2
2
 
3
3
  v2 精简底座: 仅保留 _api_call + upload_backtest_result + create_strategy;
4
- 扩展部分 (commiter 5 步打包上传需要):
4
+ 扩展部分 (commiter 3 步打包上传需要):
5
5
  - upload_strategy_code POST /strategies/{id}/code (multipart body + params)
6
- - export_strategy POST /strategies/{id}/export (拉 server 端 .qmt.py shell)
7
6
 
8
- 不进 server_client 的 (不在 skill 端做): pyarmor / embedded API key / cert / QMT
9
- shell 渲染 — 这些都跑在 server 端 (ADR-0023 + ADR-0025), cloud 是 single source
10
- of truth. 本地仅做 multipart 拼 + 提交, 不碰加密 / 不读 cert.
7
+ 不进 server_client 的 (不在 skill 端做): pyarmor / QMT shell 渲染 — 这些都跑在
8
+ server 端 (ADR-0025), cloud 是 single source of truth. 本地仅做 multipart 拼 +
9
+ 提交, 不碰加密 / 不读 shell. Round 18.40: 无 embedded API key / 无 cert 签 —
10
+ 策略体明文上传 + 服务器 inline 渲染 (base64 in template). Round 18.42 起: server
11
+ 不再生成 QMT shell (.qmt.py), desktop 落盘原始 .py → bridge_server 执行 →
12
+ hamuna-quant-cli 调 bridge_server 下单; commiter 4 步 → 3 步 (去掉 export).
11
13
 
12
14
  底层鉴权 / envelope 解构 / NaN 清洗与 v1 完全一致 (wholesale copy → 极简裁剪, 见 v1
13
15
  server_client.py git blame); 服务端契约 backend/internal/model/model.go::BacktestResult
@@ -83,9 +85,10 @@ def upload_backtest_result(strategy_id: str, result: dict) -> dict:
83
85
  def create_strategy(name: str, code: str = '', params: dict | None = None) -> dict:
84
86
  """POST /api/v1/strategies → 新建 strategy (供 _upload_after_run --name auto-create 用).
85
87
 
86
- name 必填 (id 一致性锚点, 与 v1 server_client 同); code 可选 (后端落库 +
87
- /source envelope 加密用, v2 --upload --code 路径才填); params 默认 {} (作
88
- BacktestResult.params 落库). 返 server DataValue (含 _id / id / name).
88
+ name 必填 (id 一致性锚点, 与 v1 server_client 同); code 可选 (Round 18.41+
89
+ 后端落 body 字段 UTF-8 verbatim, 无 envelope, v2 --upload --code 路径才填);
90
+ params 默认 {} (作 BacktestResult.params 落库). 返 server DataValue
91
+ (含 _id / id / name).
89
92
  """
90
93
  body: dict = {'name': name, 'code': code}
91
94
  if params is not None:
@@ -100,19 +103,21 @@ def upload_strategy_code(
100
103
  ) -> dict:
101
104
  """POST /api/v1/strategies/{id}/code (multipart) → 上传策略源码 + params.
102
105
 
103
- ADR-0023: 后端存 code_obfuscated = body bytes (GBK 编码); cert + subscription gate
104
- 是真保护, 不在 skill 端做加密/pyminifier.
105
- ADR-0025: multipart 含 `params` 字段 (JSON 字符串), server 落库覆盖 strategies.params
106
- (CONFIG 块渲染源).
106
+ Round 18.43+: 明文上传 UTF-8 verbatim, server 存到 `body` 字段. Round 18.42
107
+ 的 GBK 编码约定已废 (用户 grill: 「明文上传」字面理解应是 UTF-8, 不应在
108
+ client/server 间夹 GBK 转码; 「明文」指不加密, 编码另说). 老 doc (Round
109
+ 18.42 之前上传) 走 CodeObfuscated GBK fallback, server 读时 GBK→UTF-8 解码.
110
+ ADR-0025: multipart 含 `params` 字段 (JSON 字符串), server 落库覆盖
111
+ strategies.params (CONFIG 块渲染源).
107
112
 
108
- body bytes = UTF-8 源 → GBK (per `backend/internal/handler/qmt_export.go::storeBody`
109
- 注释 "Strategy files are GBK-encoded, server reads GBK first fall back UTF-8");
110
- 无法 GBK 编码的字符 → errors='replace' (不阻断上传).
113
+ body bytes = UTF-8 源 verbatim (per `backend/internal/handler/qmt_export.go::storeBody`
114
+ Round 18.43+: "客户端直传 UTF-8 (code.encode('utf-8')), server passthrough
115
+ 存到 `body` 字段; 不再做 GBK 转码").
111
116
 
112
117
  返 {body_hash, ok} (per handler::UploadStrategyCode 响应 shape).
113
118
  """
114
119
  code = Path(strategy_path).read_text(encoding='utf-8')
115
- gbk = code.encode('gbk', errors='replace')
120
+ utf8 = code.encode('utf-8')
116
121
  boundary = f'----hamuna-{uuid.uuid4().hex}'
117
122
  filename = Path(strategy_path).name
118
123
  ctype = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
@@ -121,7 +126,7 @@ def upload_strategy_code(
121
126
  (f'--{boundary}\r\n'
122
127
  f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
123
128
  f'Content-Type: {ctype}\r\n\r\n').encode('utf-8'),
124
- gbk,
129
+ utf8,
125
130
  ]
126
131
  if params is not None:
127
132
  # multipart 表单字段: params = JSON 字符串 (per ADR-0025 + handler::storeBody)
@@ -196,61 +201,10 @@ def s3_credentials(dataset_id: str, key: str) -> dict:
196
201
  )
197
202
 
198
203
 
199
- def export_strategy(strategy_id: str, output_path: str | None = None) -> dict:
200
- """POST /api/v1/strategies/{id}/export → 拉 cloud 渲染的 .qmt.py shell.
201
-
202
- ADR-0023: cloud 是 single source of truth — shell 渲染 (CONFIG 块 + embedded
203
- API key + cert 签) 全在 server 端. 本函数仅下载 + 落盘, 不读 shell 内容.
204
-
205
- 返 dict: {shell_path, shell_bytes} (shell_path = output_path 或 None).
206
- """
207
- token = _load_token()
208
- url = f'{_resolve_server()}/api/v1/strategies/{strategy_id}/export'
209
- req = Request(
210
- url, data=b'', method='POST',
211
- headers={
212
- 'Authorization': f'Bearer {token}',
213
- 'Accept': 'application/octet-stream, application/json',
214
- },
215
- )
216
- try:
217
- with urlopen(req, timeout=60) as resp:
218
- # server 既可返 raw .qmt.py 也可返 JSON envelope — 读 Content-Type 判
219
- ctype = resp.headers.get('Content-Type', '')
220
- raw = resp.read()
221
- if 'json' in ctype.lower():
222
- envelope = json.loads(raw.decode('utf-8'))
223
- if isinstance(envelope, dict) and 'DataValue' in envelope:
224
- if not envelope.get('Result', False):
225
- raise ServerError(
226
- f'QMT export 业务失败: Error={envelope.get("Error")} '
227
- f'Message={envelope.get("Message")}'
228
- )
229
- # DataValue 可能是 base64 编码的 shell 字节
230
- import base64
231
- shell_bytes = base64.b64decode(envelope['DataValue'])
232
- else:
233
- shell_bytes = raw
234
- else:
235
- shell_bytes = raw
236
- except HTTPError as e:
237
- resp_body = e.read().decode('utf-8', errors='replace')[:500]
238
- if e.code == 401:
239
- raise ServerError(
240
- f'QMT export 失败: HTTP 401 — API key 无效或已失效'
241
- ) from e
242
- if e.code == 402:
243
- raise ServerError(
244
- f'QMT export 失败: HTTP 402 — 订阅未激活 (subscription gate)'
245
- ) from e
246
- raise ServerError(
247
- f'QMT export 失败: HTTP {e.code} {e.reason}; body={resp_body}'
248
- ) from e
249
-
250
- if output_path:
251
- Path(output_path).write_bytes(shell_bytes)
252
- return {'shell_path': str(output_path), 'shell_bytes': len(shell_bytes)}
253
- return {'shell_path': None, 'shell_bytes': len(shell_bytes)}
204
+ # Round 18.42: export_strategy 删 — /strategies/:id/export 端点已废.
205
+ # QMT shell 渲染整体从 server 撤出: desktop deploy_strategy 落盘原始 .py →
206
+ # bridge_server 扫描执行; 实盘下单走 hamuna-quant-cli → bridge_server HTTP.
207
+ # 如需 audit 渲染, 走 skill 镜像步骤(本仓库不维护 client).
254
208
 
255
209
 
256
210
  def _sanitize_for_json(obj):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hamuna-quant-cli
3
- Version: 0.1.2
3
+ Version: 0.1.7
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
@@ -34,5 +34,4 @@ hamuna_quant_cli/runtime/http_client.py
34
34
  hamuna_quant_cli/runtime/s3client.py
35
35
  hamuna_quant_cli/runtime/server_client.py
36
36
  hamuna_quant_cli/scripts/server.json
37
- tests/test_live_safety.py
38
- tests/test_skeleton.py
37
+ tests/test_live_safety.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hamuna-quant-cli"
7
- version = "0.1.2"
7
+ version = "0.1.7"
8
8
  description = "Hamuna A 股回测 + 实盘统一 CLI (akquant 0.3.x). 替代 v1 自建 driver + QMT-style 策略规范."
9
9
  readme = "hamuna_quant_cli/README.md"
10
10
  requires-python = ">=3.10"
@@ -1,154 +0,0 @@
1
- """ADR-0029 §10 验证清单 — Phase 2.3 自检。"""
2
- from __future__ import annotations
3
-
4
- import sys
5
- import numpy as np
6
-
7
- sys.path.insert(0, 'D:/Coding/hamuna-strategy-platform')
8
-
9
- from strategy_cli.runtime.context import Context
10
- from strategy_cli.runtime.driver import run_backtest
11
- from strategy_cli.runtime.score import winsorize, zscore, composite_score
12
- from strategy_cli.fundamental.data import pool, derived
13
-
14
-
15
- # ---- §10 验证 1: init() 幂等 ----
16
- _BASE_CFG = {
17
- 'stock': None, 'period': '1d', 'account_type': 'STOCK',
18
- 'init_capital': 1_000_000, 'backtest_start': '20240101', 'backtest_end': '20240131',
19
- 'pool': {'a_share': {'enabled': True, 'sub_universe': 'hs300'},
20
- 'convertible_bond': {'enabled': False}, 'etf': {'enabled': False}},
21
- 'rebalance_freq': 'weekly', 'rebalance_dow': 4, 'rebalance_dom': -1,
22
- 'filters': [], 'factors': [], 'scoring': {'top_n': 5},
23
- 'cost': {'commission_bps': 30, 'stamp_tax_bps': 100, 'min_commission': 5,
24
- 'slippage_bps': 5, 'volume_cap_pct': 0.10},
25
- 'risk': {'single_holding_cap_pct': 0.20, 'sector_cap_pct': 0.30,
26
- 'stop_loss_pct': -0.08, 'max_drawdown_pct': -0.20},
27
- }
28
-
29
- _BODY_PATH = 'D:/Coding/hamuna-strategy-platform/examples/strategies/pe_pb_mom_top10.py'
30
-
31
-
32
- def _load_body(config: dict):
33
- """通过 driver 加载 body, 注入 CONFIG。"""
34
- import importlib.util
35
- import sys as _sys
36
- spec = importlib.util.spec_from_file_location('_hamuna_strat', _BODY_PATH)
37
- if spec is None or spec.loader is None:
38
- raise ImportError(f'cannot load {_BODY_PATH}')
39
- mod = importlib.util.module_from_spec(spec)
40
- _sys.modules['_hamuna_strat'] = mod
41
- spec.loader.exec_module(mod)
42
- mod.__dict__['CONFIG'] = config
43
- return mod
44
-
45
-
46
- def test_init_idempotent():
47
- cfg = dict(_BASE_CFG)
48
- C1 = Context(period='1d', start='20240101', end='20240131')
49
- C1._trading_days = ['20240102', '20240103']
50
- body = _load_body(cfg)
51
- body.init(C1)
52
- snap1 = dict(C1.snapshot)
53
-
54
- C2 = Context(period='1d', start='20240101', end='20240131')
55
- C2._trading_days = ['20240102', '20240103']
56
- body.init(C2)
57
- snap2 = dict(C2.snapshot)
58
-
59
- assert snap1 == snap2, 'init() 应幂等'
60
- assert len(C1.pool_a) == 20, f'池大小异常: {len(C1.pool_a)}'
61
- print(' [OK] init() 幂等 + 池大小正确')
62
-
63
-
64
- # ---- §10 验证 2: 非 rebalance bar 不下单 ----
65
- def test_handlebar_no_order_on_non_rebalance():
66
- cfg = dict(_BASE_CFG)
67
- cfg['rebalance_freq'] = 'monthly'
68
- C = Context(period='1d', start='20240101', end='20240131')
69
- C._trading_days = ['20240102', '20240103', '20240131']
70
- body = _load_body(cfg)
71
- body.init(C)
72
- C.cur_bar_idx = 0
73
- C.cur_date = '20240102'
74
- pending_before = len(getattr(C, '_pending_orders', []))
75
- body.handlebar(C)
76
- pending_after = len(getattr(C, '_pending_orders', []))
77
- assert pending_after == pending_before, '非 rebalance bar 不应产生 pending_orders'
78
- print(' [OK] 非 rebalance bar 不下单')
79
-
80
-
81
- # ---- §10 验证 3: winsorize + zscore 不抛 ----
82
- def test_score_no_crash_on_all_nan():
83
- arr = np.array([np.nan, np.nan, np.nan])
84
- out = winsorize(arr)
85
- assert np.isnan(out).all()
86
- z = zscore(arr)
87
- assert (z == 0).all(), f'全 NaN zscore 应为 0, got {z}'
88
- factors = [{'name': 'x', 'direction': 'low', 'weight': 1.0}]
89
- score = composite_score({'x': arr}, factors)
90
- assert (score == 0).all()
91
- print(' [OK] 全 NaN winsorize/zscore 不抛')
92
-
93
-
94
- # ---- §10 验证 4: _normalize_weights 上限生效 ----
95
- def test_normalize_cap():
96
- """权重归一化 + cap 截断 + 二次归一化; 测试每阶段值。"""
97
- from examples.strategies.pe_pb_mom_top10 import _normalize_weights
98
- top = [('A', 0.5), ('B', 0.3), ('C', 0.2)]
99
- # cap=0.4: A 截断到 0.4; sum(capped)=0.9; 二次归一化 0.4/0.9=0.444, 0.3/0.9=0.333
100
- w = _normalize_weights(top, cap_pct=0.4)
101
- assert abs(w['A'] - 0.444) < 1e-3, w
102
- assert abs(sum(w.values()) - 1.0) < 1e-9, sum(w.values())
103
- # cap=0.2: 全部被截断, A=B=C=1/3
104
- w = _normalize_weights(top, cap_pct=0.2)
105
- assert abs(w['A'] - 1/3) < 1e-9, w
106
- assert abs(sum(w.values()) - 1.0) < 1e-9, sum(w.values())
107
- print(f' [OK] cap 生效 (cap=0.4): A={w["A"]:.3f} B={w["B"]:.3f} C={w["C"]:.3f}')
108
-
109
-
110
- # ---- §10 验证 5: suspended 守门 ----
111
- def test_suspended_skip():
112
- cfg = dict(_BASE_CFG)
113
- C = Context(period='1d', start='20240101', end='20240131')
114
- C._trading_days = ['20240102', '20240131']
115
- body = _load_body(cfg)
116
- body.init(C)
117
- C.suspended = True
118
- C.cur_bar_idx = 1
119
- C.cur_date = '20240131'
120
- pending_before = len(getattr(C, '_pending_orders', []))
121
- body.handlebar(C)
122
- pending_after = len(getattr(C, '_pending_orders', []))
123
- assert pending_after == pending_before, 'suspended=True 时 handlebar 必须 return'
124
- print(' [OK] suspended=True handlebar 守门')
125
-
126
-
127
- # ---- 端到端: 跑一次完整回测 ----
128
- def test_e2e_run():
129
- cfg = dict(_BASE_CFG)
130
- cfg['filters'] = [
131
- {'field': 'derived.pe_dynamic', 'op': 'gt', 'value': 0},
132
- {'field': 'derived.pe_dynamic', 'op': 'lt', 'value': 50},
133
- ]
134
- cfg['factors'] = [
135
- {'name': 'pe', 'field': 'derived.pe_dynamic', 'direction': 'low', 'weight': 0.4},
136
- {'name': 'pb', 'field': 'derived.pb_ratio', 'direction': 'low', 'weight': 0.3},
137
- {'name': 'mom', 'field': 'market.close_20d_change', 'direction': 'high', 'weight': 0.3},
138
- ]
139
- result = run_backtest(_BODY_PATH, cfg)
140
- print(f' [OK] e2e 跑通: trading_days={result["trading_days"]}, final_nav={result["final_nav"]:.0f}')
141
- assert result['trading_days'] > 0
142
-
143
-
144
- if __name__ == '__main__':
145
- print('ADR-0029 §10 验证清单')
146
- print('-' * 40)
147
- test_init_idempotent()
148
- test_handlebar_no_order_on_non_rebalance()
149
- test_score_no_crash_on_all_nan()
150
- test_normalize_cap()
151
- test_suspended_skip()
152
- test_e2e_run()
153
- print('-' * 40)
154
- print('全部通过 [OK]')