hamuna-quant-cli 0.1.9__tar.gz → 0.1.11__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 (39) hide show
  1. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/PKG-INFO +1 -1
  2. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/__init__.py +1 -1
  3. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/__main__.py +108 -3
  4. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/live/runner.py +64 -2
  5. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/runtime/server_client.py +27 -3
  6. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
  7. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/pyproject.toml +1 -1
  8. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/MANIFEST.in +0 -0
  9. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/README.md +0 -0
  10. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/README.md +0 -0
  11. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/_market_fallback.py +0 -0
  12. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/_metrics_15.py +0 -0
  13. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
  14. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
  15. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/akquant_runner.py +0 -0
  16. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/akquant_schema_adapter.py +0 -0
  17. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/base_strategy.py +0 -0
  18. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
  19. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/live/__init__.py +0 -0
  20. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/live/loader.py +0 -0
  21. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/live/qmt_broker.py +0 -0
  22. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/live/qmt_market.py +0 -0
  23. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
  24. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
  25. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/qmt_translator.py +0 -0
  26. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/runtime/__init__.py +0 -0
  27. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/runtime/backtest.py +0 -0
  28. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/runtime/cache.py +0 -0
  29. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/runtime/discipline.py +0 -0
  30. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/runtime/http_client.py +0 -0
  31. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/runtime/s3client.py +0 -0
  32. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli/scripts/server.json +0 -0
  33. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli.egg-info/SOURCES.txt +0 -0
  34. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
  35. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
  36. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli.egg-info/requires.txt +0 -0
  37. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
  38. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/setup.cfg +0 -0
  39. {hamuna_quant_cli-0.1.9 → hamuna_quant_cli-0.1.11}/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.9
3
+ Version: 0.1.11
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.9"
15
+ __version__ = "0.1.11"
@@ -331,9 +331,22 @@ def cmd_commit(args) -> int:
331
331
  bridge_server 扫描执行; hamuna-quant-cli 下单走 bridge_server HTTP.
332
332
  bundle = 4 文件 (strategy + result + config + params). multipart params 字段
333
333
  = 整个 params.json 内容 (ADR-0025, desktop 实盘配置源).
334
+
335
+ Round 18.62 P0-1: 新增 step 1.5 replay diff gate — `--replay-result` 必填,
336
+ 与 `--result` 算 sharpe diff, 超阈值 (HAMUNA_REPLAY_GATE_PCT 缺省 0.20) → exit 5.
337
+ server `UpdateStrategyResult` handler 二次校验防绕过.
338
+
339
+ Round 18.62 P1-1: 新增 step 1.4 version bump — `GET /strategies/{id}` 拿
340
+ `latest_version` (无 → "0.0.0"); 默认 patch 自动 bump (`0.0.0 → 0.0.1`),
341
+ `--major` → `X+1.0.0`, `--minor` → `X.Y+1.0`. `--major + --minor` 互斥.
342
+ server handler 强校验 semver 格式 + 严格递增; PUT body 必含 `version`.
334
343
  """
335
344
  from . import runtime
345
+ from .runtime.server_client import ServerError
346
+ import io
336
347
  import json
348
+ import os
349
+ import re
337
350
  import tarfile
338
351
 
339
352
  sid = args.strategy_id
@@ -354,11 +367,47 @@ def cmd_commit(args) -> int:
354
367
  if params_path and not params_path.exists():
355
368
  print(f"params.json 不存在: {params_path} (--params 可选)", file=sys.stderr)
356
369
  return 2
370
+ # Round 18.62 P0-1: replay_result.json 必填 (除非 --skip-replay-gate qa 通道).
371
+ replay_path = Path(args.replay_result) if args.replay_result else None
372
+ if not args.skip_replay_gate:
373
+ if replay_path is None:
374
+ print("ERROR: P0-1: 必须传 --replay-result, 先跑 live run --broker replay 拿 metrics "
375
+ "(或 --skip-replay-gate qa 通道)", file=sys.stderr)
376
+ return 2
377
+ if not replay_path.exists():
378
+ print(f"replay_result.json 不存在: {args.replay_result}", file=sys.stderr)
379
+ return 2
380
+
381
+ # ----- Round 18.62 P1-1 step 1.4: version bump -----
382
+ # cli 先 GET /strategies/{id} 拿 latest_version (无 → "0.0.0"); 默认 patch 自动 bump,
383
+ # --major → X+1.0.0, --minor → X.Y+1.0. 互斥校验防呆. server 二次校验 (semver 格式 +
384
+ # 严格递增) 防绕过. PUT body 必含 version (P1-1 server 强校验).
385
+ if args.major and args.minor:
386
+ print("ERROR: --major 与 --minor 互斥, 选一个或不传 (默认 patch)", file=sys.stderr)
387
+ return 2
388
+ try:
389
+ strat = runtime.server_client.get_strategy(sid)
390
+ latest = (strat or {}).get("latest_version") or "0.0.0"
391
+ except ServerError:
392
+ latest = "0.0.0" # 404 / 401 等都 fallback, 新策略首次 commit
393
+ if not re.fullmatch(r"\d+\.\d+\.\d+", latest):
394
+ print(f"ERROR: server latest_version '{latest}' 不是合法 semver (X.Y.Z), "
395
+ f"请手动清理 strategies.latest_version 后重试", file=sys.stderr)
396
+ return 2
397
+ major, minor, patch = (int(x) for x in latest.split("."))
398
+ if args.major:
399
+ new_version = f"{major + 1}.0.0"
400
+ elif args.minor:
401
+ new_version = f"{major}.{minor + 1}.0"
402
+ else:
403
+ new_version = f"{major}.{minor}.{patch + 1}"
404
+ print(f"=== Step 1.4/3: P1-1 version bump ({latest} → {new_version}) ===",
405
+ file=sys.stderr)
357
406
 
358
407
  bundle_out = Path(args.bundle_out) if args.bundle_out else Path(f"runs/{sid}/bundle.tar.gz")
359
408
  bundle_out.parent.mkdir(parents=True, exist_ok=True)
360
409
 
361
- print("=== Step 1/3: 打包 bundle (strategy + result + config + params) ===",
410
+ print("=== Step 1/3: 打包 bundle (strategy + result + config + params + replay + version) ===",
362
411
  file=sys.stderr)
363
412
  with tarfile.open(bundle_out, "w:gz") as tar:
364
413
  tar.add(strategy_path, arcname=strategy_path.name)
@@ -367,16 +416,60 @@ def cmd_commit(args) -> int:
367
416
  tar.add(cfg_path, arcname=cfg_path.name)
368
417
  if params_path:
369
418
  tar.add(params_path, arcname=params_path.name)
419
+ # Round 18.62 P0-1: replay_result.json 也打进去, 方便回溯 / 审计.
420
+ if replay_path:
421
+ tar.add(replay_path, arcname=replay_path.name)
422
+ # Round 18.62 P1-1: version.json 内存写, audit 用 (server 不读, 仅本地回溯).
423
+ ver_bytes = json.dumps({"version": new_version}, ensure_ascii=False).encode("utf-8")
424
+ ver_info = tarfile.TarInfo(name="version.json")
425
+ ver_info.size = len(ver_bytes)
426
+ tar.addfile(ver_info, io.BytesIO(ver_bytes))
370
427
  print(f" ✓ bundle = {bundle_out} ({bundle_out.stat().st_size} bytes)", file=sys.stderr)
371
428
 
372
- print("=== Step 2/3: PUT /strategies/:id/result (metrics) ===", file=sys.stderr)
429
+ # ----- Round 18.62 P0-1 step 1.5: replay diff gate -----
430
+ # 必在 step 2 (PUT) 之前 — gate fail 后 step 2-3 都不跑, 不留半上传状态.
431
+ replay_metrics: dict | None = None
432
+ if not args.skip_replay_gate:
433
+ try:
434
+ bt = json.loads(result_path.read_text(encoding="utf-8"))
435
+ rp = json.loads(replay_path.read_text(encoding="utf-8")) # type: ignore[union-attr]
436
+ except json.JSONDecodeError as e:
437
+ print(f" ✗ result/replay JSON 解析失败: {e}", file=sys.stderr)
438
+ return 2
439
+ bt_sharpe = float(bt.get("metrics", {}).get("sharpe", 0.0))
440
+ rp_sharpe = float(rp.get("metrics", {}).get("sharpe", 0.0))
441
+ diff_pct = abs(rp_sharpe - bt_sharpe) / max(abs(bt_sharpe), 0.01)
442
+ try:
443
+ threshold = float(os.environ.get("HAMUNA_REPLAY_GATE_PCT", "0.20"))
444
+ except ValueError:
445
+ threshold = 0.20
446
+ if diff_pct > threshold:
447
+ print(f"ERROR: replay gate diff={diff_pct:.2%} > {threshold:.0%} "
448
+ f"(backtest.sharpe={bt_sharpe:.4f} vs replay.sharpe={rp_sharpe:.4f})",
449
+ file=sys.stderr)
450
+ return 5
451
+ replay_metrics = {
452
+ "sharpe": rp_sharpe,
453
+ "total_return": float(rp.get("metrics", {}).get("total_return", 0.0)),
454
+ "win_rate": float(rp.get("metrics", {}).get("win_rate", 0.0)),
455
+ "trades": len(rp.get("trades", [])),
456
+ "diff_pct": diff_pct,
457
+ "run_at": (rp.get("period") or {}).get("end"),
458
+ }
459
+ print(f" ✓ replay gate pass (diff={diff_pct:.2%} <= {threshold:.0%}, "
460
+ f"replay.sharpe={rp_sharpe:.4f})", file=sys.stderr)
461
+
462
+ print("=== Step 2/3: PUT /strategies/:id/result (metrics + replay_metrics + version) ===",
463
+ file=sys.stderr)
373
464
  try:
374
465
  result = json.loads(result_path.read_text(encoding="utf-8"))
375
466
  except json.JSONDecodeError as e:
376
467
  print(f" ✗ result.json 解析失败: {e}", file=sys.stderr)
377
468
  return 2
378
469
  try:
379
- resp = runtime.server_client.upload_backtest_result(sid, result)
470
+ resp = runtime.server_client.upload_backtest_result(
471
+ sid, result, replay_metrics=replay_metrics, version=new_version,
472
+ )
380
473
  except runtime.server_client.ServerError as e:
381
474
  print(f" ✗ 上传 metrics 失败: {e}", file=sys.stderr)
382
475
  return 4
@@ -943,6 +1036,18 @@ def build_parser() -> argparse.ArgumentParser:
943
1036
  cm.add_argument("strategy_id", help="server 端 strategy_id")
944
1037
  cm.add_argument("--strategy", help="akquant strategy .py 路径 (默认 <id>.py)")
945
1038
  cm.add_argument("--result", required=True, help="metrics result.json 路径")
1039
+ # Round 18.62 P0-1: replay gate. --replay-result 必填 (除非 --skip-replay-gate).
1040
+ cm.add_argument("--replay-result", default=None,
1041
+ help="replay_result.json 路径 (P0-1 必填; 先跑 live run --broker replay 拿 metrics, "
1042
+ "或 --skip-replay-gate 跳过)")
1043
+ cm.add_argument("--skip-replay-gate", action="store_true",
1044
+ help="[qa 临时] 跳过 replay diff gate (非 dev 默认)")
1045
+ # Round 18.62 P1-1: version bump. 默认 patch 自动 (从 server latest_version +0.0.1),
1046
+ # 显式 --major / --minor opt-in. 互斥.
1047
+ cm.add_argument("--major", action="store_true",
1048
+ help="[P1-1] major bump (X+1.0.0); 跟 --minor 互斥")
1049
+ cm.add_argument("--minor", action="store_true",
1050
+ help="[P1-1] minor bump (X.Y+1.0); 跟 --major 互斥")
946
1051
  cm.add_argument("--config", help="config.json 路径")
947
1052
  cm.add_argument("--params", default=None,
948
1053
  help="params.json 路径 (固定 schema; 整个内容作 multipart params 字段, desktop 实盘配置源)")
@@ -9,6 +9,7 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  import sys
12
+ import json # Round 18.62 P0-1: replay metrics 落盘
12
13
  from pathlib import Path
13
14
  from typing import Any
14
15
 
@@ -216,19 +217,39 @@ def _build_instruments(symbols: list[str] | None, normalize: bool = False) -> li
216
217
 
217
218
  normalize=True: 裸码 → 带后缀 (replay 数据来自 prebuilt, stockCode 是 '600000.SH'
218
219
  形态, instrument symbol 必须一致才匹配 ReplayMarketGateway 的订阅过滤).
220
+
221
+ Round 18.59: 按 6 位代码前缀推断 AssetType — akquant 0.3.x AssetType 枚举没 ETF 也没 Bond,
222
+ 510/511/512/515/159 (沪/深 ETF) → Fund; 113/127/128 (可转债) → Fund; 其他 6 位 → Stock.
223
+ 之前硬写 AssetType.Stock 让 351 个 ETF 被 broker 内部按 asset_type 分流时丢弃 → universe=[].
224
+ ponytail: akquant 0.4.x 若新增 AssetType.ETF / AssetType.Bond 则此映射需重审.
219
225
  """
220
226
  if not symbols:
221
227
  return None
222
228
  # 延迟 import akquant — 让 loader 在 akquant 未装环境也能跑通
223
229
  from akquant import AssetType, Instrument
224
230
 
231
+ _ETF_PREFIXES = ("510", "511", "512", "515", "159")
232
+ _BOND_PREFIXES = ("113", "127", "128") # 可转债 (沪 113 / 深 127 / 沪 128)
233
+
225
234
  def _sym(s: str) -> str:
226
235
  if normalize:
227
236
  from ..akquant_schema_adapter import normalize_symbol
228
237
  return normalize_symbol(s)
229
238
  return s
230
239
 
231
- return [Instrument(symbol=_sym(s), asset_type=AssetType.Stock) for s in symbols if s.strip()]
240
+ def _asset_type(bare: str) -> Any:
241
+ # _sym 后可能是 6 位裸码或 600000.SH 形态 — 都按前 3 位判
242
+ code6 = bare.split(".")[0][-6:] if "." in bare else bare[-6:]
243
+ if not (len(code6) == 6 and code6.isdigit()):
244
+ return AssetType.Stock # 非法 6 位 fallback, 不阻塞启动
245
+ pfx3 = code6[:3]
246
+ if pfx3.startswith(_ETF_PREFIXES):
247
+ return AssetType.Fund
248
+ if pfx3.startswith(_BOND_PREFIXES):
249
+ return AssetType.Fund
250
+ return AssetType.Stock
251
+
252
+ return [Instrument(symbol=_sym(s), asset_type=_asset_type(_sym(s))) for s in symbols if s.strip()]
232
253
 
233
254
 
234
255
  def _recent_real_bars(symbols: list[str], days: int = 30) -> tuple[Any, str]:
@@ -770,4 +791,45 @@ def run_live(
770
791
  f"market_broker={market_broker or '<single>'} "
771
792
  f"symbols={symbols or '<all>'} duration={duration}", flush=True)
772
793
  print(f"[info] gateway_options={gateway_options}", flush=True)
773
- _akquant_run_live(**kwargs)
794
+ _akquant_run_live(**kwargs)
795
+
796
+ # ===== Round 18.62 P0-1: replay metrics 产出 =====
797
+ # live run 跑完后, 拿同一份 bars + strategy_cls 跑一次 mini-backtest 拿 metrics,
798
+ # 落 runs/{strategy_id}/replay_result.json (跟 cmd_run 同 13-key schema, to_hamuna_result).
799
+ # 用途: commiter cmd_commit --replay-result 拿此文件 vs 回测 result.json 算 sharpe diff.
800
+ # 仅 broker=replay 时跑 (其他 broker 真盘无意义, 也没 bars 数据).
801
+ if broker == "replay" and spec.mode == "class" and spec.strategy_cls is not None:
802
+ try:
803
+ from akquant import run_backtest as _akquant_run_backtest # noqa: F401
804
+ from ..akquant_schema_adapter import to_hamuna_result
805
+ from datetime import datetime as _dt
806
+ # strategy_id 来自 run_live kwargs 顶层 (Round 18.42+ commiter 必传);
807
+ # 若没有 (裸 live run 调) 退到 /tmp/replay_result.json, 不污染 runs/.
808
+ strategy_id = kwargs.get("strategy_id") or "adhoc"
809
+ out_dir = Path("runs") / strategy_id
810
+ out_dir.mkdir(parents=True, exist_ok=True)
811
+ out_path = out_dir / "replay_result.json"
812
+ # 用 replay_bars_df + strategy_cls 跑 mini-backtest. 同一份数据 → 可比.
813
+ mini_kwargs = dict(kwargs)
814
+ mini_kwargs.pop("live", None) # 防混入 live flag
815
+ # to_hamuna_result 需 akquant BacktestResult 对象; mini-backtest 走同一 strategy_cls
816
+ # + 同一份 bars, 拿 metrics 对象 → to_hamuna_result 落 13-key schema.
817
+ bt_result = _akquant_run_backtest(
818
+ strategy_cls=spec.strategy_cls,
819
+ bars=replay_bars_df,
820
+ initial_cash=initial_cash or 100000.0,
821
+ )
822
+ cfg = {"period": {"start": str(replay_bars_df['date'].min())[:10] if hasattr(replay_bars_df, 'columns') else "",
823
+ "end": str(_dt.now().date())}}
824
+ result_dict = to_hamuna_result(bt_result, cfg)
825
+ out_path.write_text(json.dumps(result_dict, indent=2, ensure_ascii=False), encoding="utf-8")
826
+ m = result_dict.get("metrics", {})
827
+ print(f"[info] replay metrics: sharpe={m.get('sharpe'):.3f} "
828
+ f"total_return={m.get('total_return', 0):.2%} trades={len(result_dict.get('trades', []))} "
829
+ f"→ {out_path}", flush=True)
830
+ except ImportError:
831
+ # akquant 未装 / to_hamuna_result 不可用 — 跳过, 不阻塞 live run
832
+ print("[warn] P0-1: akquant / to_hamuna_result 未装, 跳过 replay metrics 产出", flush=True)
833
+ except Exception as e:
834
+ # mini-backtest 失败不致命 — live run 已成功, 只 warn
835
+ print(f"[warn] P0-1: replay metrics 产出失败 ({type(e).__name__}: {e}), 跳过", flush=True)
@@ -72,14 +72,38 @@ def _api_call(method: str, path: str, body: dict | None = None) -> dict:
72
72
  return envelope
73
73
 
74
74
 
75
- def upload_backtest_result(strategy_id: str, result: dict) -> dict:
76
- """PUT /api/v1/strategies/{id}/result → 上传 BacktestResult-shape dict.
75
+ def upload_backtest_result(strategy_id: str, result: dict, replay_metrics: dict | None = None, version: str | None = None) -> dict:
76
+ """PUT /api/v1/strategies/{id}/result → 上传 BacktestResult-shape dict + 可选 replay_metrics + version.
77
77
 
78
78
  兼容 akquant_runner.run_akquant_backtest() 输出 (13 顶层 key + metrics 子 dict 15 key + ...),
79
79
  先做轻清洗: NaN/Inf → None (json 标准不允许), 后传给 server.
80
+
81
+ Round 18.62 P0-1: replay_metrics (Sharpe/TotalReturn/WinRate/Trades/DiffPct/RunAt) —
82
+ 跟 result 平级, server handler 二次校验 diff 防绕过. None 时不传该 key (老调用方不破).
83
+ Round 18.62 P1-1: version (X.Y.Z semver) — cli 默认 patch 自动 bump (cmd_commit step 1.4
84
+ 拿 server latest_version 算新 version), server handler 二次校验 semver 格式 + 严格递增.
85
+ 老调用方 (无 version kwarg) → 不传该 key → server 422 (P1-1 强校验).
80
86
  """
81
87
  payload = _sanitize_for_json(result)
82
- return _api_call('PUT', f'/api/v1/strategies/{strategy_id}/result', payload)
88
+ # P1-1 P0-1.1 ponytail: 无条件 wrap `{"result": ...}` 让 server 解析形状一致
89
+ # (即使 replay_metrics=None / version=None 也要 wrap, 防止 server handler 422 invalid shape).
90
+ # 此改动在 P1-1 同步实施, P0-1 Case F 已知问题修复.
91
+ body: dict = {"result": payload}
92
+ if replay_metrics is not None:
93
+ body["replay_metrics"] = _sanitize_for_json(replay_metrics)
94
+ if version is not None:
95
+ body["version"] = version
96
+ return _api_call('PUT', f'/api/v1/strategies/{strategy_id}/result', body)
97
+
98
+
99
+ def get_strategy(strategy_id: str) -> dict:
100
+ """GET /api/v1/strategies/{id} → server DataValue (含 latest_version / result / versions[]).
101
+
102
+ Round 18.62 P1-1: cli 端 version auto-bump 需要 latest_version. 404 raise ServerError,
103
+ caller (cmd_commit) fallback "0.0.0" + patch bump. 返 dict (含 latest_version 字段);
104
+ 若 server 返回 DataValue 是 dict 直接返, 是 None 也直接返让 caller 用 .get() 容错.
105
+ """
106
+ return _api_call('GET', f'/api/v1/strategies/{strategy_id}')
83
107
 
84
108
 
85
109
  def create_strategy(name: str, code: str = '', params: dict | None = None) -> dict:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hamuna-quant-cli
3
- Version: 0.1.9
3
+ Version: 0.1.11
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hamuna-quant-cli"
7
- version = "0.1.9"
7
+ version = "0.1.11"
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"