hamuna-quant-cli 0.1.8__tar.gz → 0.1.10__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.8 → hamuna_quant_cli-0.1.10}/PKG-INFO +1 -1
  2. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/__init__.py +1 -1
  3. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/__main__.py +59 -2
  4. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/live/runner.py +64 -2
  5. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/runtime/server_client.py +7 -2
  6. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
  7. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/pyproject.toml +1 -1
  8. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/MANIFEST.in +0 -0
  9. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/README.md +0 -0
  10. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/README.md +0 -0
  11. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/_market_fallback.py +0 -0
  12. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/_metrics_15.py +0 -0
  13. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
  14. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
  15. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/akquant_runner.py +0 -0
  16. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/akquant_schema_adapter.py +0 -0
  17. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/base_strategy.py +0 -0
  18. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
  19. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/live/__init__.py +0 -0
  20. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/live/loader.py +0 -0
  21. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/live/qmt_broker.py +0 -0
  22. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/live/qmt_market.py +0 -0
  23. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
  24. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
  25. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/qmt_translator.py +0 -0
  26. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/runtime/__init__.py +0 -0
  27. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/runtime/backtest.py +0 -0
  28. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/runtime/cache.py +0 -0
  29. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/runtime/discipline.py +0 -0
  30. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/runtime/http_client.py +0 -0
  31. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/runtime/s3client.py +0 -0
  32. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli/scripts/server.json +0 -0
  33. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli.egg-info/SOURCES.txt +0 -0
  34. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
  35. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
  36. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli.egg-info/requires.txt +0 -0
  37. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
  38. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/setup.cfg +0 -0
  39. {hamuna_quant_cli-0.1.8 → hamuna_quant_cli-0.1.10}/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.8
3
+ Version: 0.1.10
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.7"
15
+ __version__ = "0.1.10"
@@ -331,9 +331,14 @@ 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 二次校验防绕过.
334
338
  """
335
339
  from . import runtime
336
340
  import json
341
+ import os
337
342
  import tarfile
338
343
 
339
344
  sid = args.strategy_id
@@ -354,6 +359,16 @@ def cmd_commit(args) -> int:
354
359
  if params_path and not params_path.exists():
355
360
  print(f"params.json 不存在: {params_path} (--params 可选)", file=sys.stderr)
356
361
  return 2
362
+ # Round 18.62 P0-1: replay_result.json 必填 (除非 --skip-replay-gate qa 通道).
363
+ replay_path = Path(args.replay_result) if args.replay_result else None
364
+ if not args.skip_replay_gate:
365
+ if replay_path is None:
366
+ print("ERROR: P0-1: 必须传 --replay-result, 先跑 live run --broker replay 拿 metrics "
367
+ "(或 --skip-replay-gate qa 通道)", file=sys.stderr)
368
+ return 2
369
+ if not replay_path.exists():
370
+ print(f"replay_result.json 不存在: {args.replay_result}", file=sys.stderr)
371
+ return 2
357
372
 
358
373
  bundle_out = Path(args.bundle_out) if args.bundle_out else Path(f"runs/{sid}/bundle.tar.gz")
359
374
  bundle_out.parent.mkdir(parents=True, exist_ok=True)
@@ -367,16 +382,52 @@ def cmd_commit(args) -> int:
367
382
  tar.add(cfg_path, arcname=cfg_path.name)
368
383
  if params_path:
369
384
  tar.add(params_path, arcname=params_path.name)
385
+ # Round 18.62 P0-1: replay_result.json 也打进去, 方便回溯 / 审计.
386
+ if replay_path:
387
+ tar.add(replay_path, arcname=replay_path.name)
370
388
  print(f" ✓ bundle = {bundle_out} ({bundle_out.stat().st_size} bytes)", file=sys.stderr)
371
389
 
372
- print("=== Step 2/3: PUT /strategies/:id/result (metrics) ===", file=sys.stderr)
390
+ # ----- Round 18.62 P0-1 step 1.5: replay diff gate -----
391
+ # 必在 step 2 (PUT) 之前 — gate fail 后 step 2-3 都不跑, 不留半上传状态.
392
+ replay_metrics: dict | None = None
393
+ if not args.skip_replay_gate:
394
+ try:
395
+ bt = json.loads(result_path.read_text(encoding="utf-8"))
396
+ rp = json.loads(replay_path.read_text(encoding="utf-8")) # type: ignore[union-attr]
397
+ except json.JSONDecodeError as e:
398
+ print(f" ✗ result/replay JSON 解析失败: {e}", file=sys.stderr)
399
+ return 2
400
+ bt_sharpe = float(bt.get("metrics", {}).get("sharpe", 0.0))
401
+ rp_sharpe = float(rp.get("metrics", {}).get("sharpe", 0.0))
402
+ diff_pct = abs(rp_sharpe - bt_sharpe) / max(abs(bt_sharpe), 0.01)
403
+ try:
404
+ threshold = float(os.environ.get("HAMUNA_REPLAY_GATE_PCT", "0.20"))
405
+ except ValueError:
406
+ threshold = 0.20
407
+ if diff_pct > threshold:
408
+ print(f"ERROR: replay gate diff={diff_pct:.2%} > {threshold:.0%} "
409
+ f"(backtest.sharpe={bt_sharpe:.4f} vs replay.sharpe={rp_sharpe:.4f})",
410
+ file=sys.stderr)
411
+ return 5
412
+ replay_metrics = {
413
+ "sharpe": rp_sharpe,
414
+ "total_return": float(rp.get("metrics", {}).get("total_return", 0.0)),
415
+ "win_rate": float(rp.get("metrics", {}).get("win_rate", 0.0)),
416
+ "trades": len(rp.get("trades", [])),
417
+ "diff_pct": diff_pct,
418
+ "run_at": (rp.get("period") or {}).get("end"),
419
+ }
420
+ print(f" ✓ replay gate pass (diff={diff_pct:.2%} <= {threshold:.0%}, "
421
+ f"replay.sharpe={rp_sharpe:.4f})", file=sys.stderr)
422
+
423
+ print("=== Step 2/3: PUT /strategies/:id/result (metrics + replay_metrics) ===", file=sys.stderr)
373
424
  try:
374
425
  result = json.loads(result_path.read_text(encoding="utf-8"))
375
426
  except json.JSONDecodeError as e:
376
427
  print(f" ✗ result.json 解析失败: {e}", file=sys.stderr)
377
428
  return 2
378
429
  try:
379
- resp = runtime.server_client.upload_backtest_result(sid, result)
430
+ resp = runtime.server_client.upload_backtest_result(sid, result, replay_metrics=replay_metrics)
380
431
  except runtime.server_client.ServerError as e:
381
432
  print(f" ✗ 上传 metrics 失败: {e}", file=sys.stderr)
382
433
  return 4
@@ -943,6 +994,12 @@ def build_parser() -> argparse.ArgumentParser:
943
994
  cm.add_argument("strategy_id", help="server 端 strategy_id")
944
995
  cm.add_argument("--strategy", help="akquant strategy .py 路径 (默认 <id>.py)")
945
996
  cm.add_argument("--result", required=True, help="metrics result.json 路径")
997
+ # Round 18.62 P0-1: replay gate. --replay-result 必填 (除非 --skip-replay-gate).
998
+ cm.add_argument("--replay-result", default=None,
999
+ help="replay_result.json 路径 (P0-1 必填; 先跑 live run --broker replay 拿 metrics, "
1000
+ "或 --skip-replay-gate 跳过)")
1001
+ cm.add_argument("--skip-replay-gate", action="store_true",
1002
+ help="[qa 临时] 跳过 replay diff gate (非 dev 默认)")
946
1003
  cm.add_argument("--config", help="config.json 路径")
947
1004
  cm.add_argument("--params", default=None,
948
1005
  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,13 +72,18 @@ 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) -> dict:
76
+ """PUT /api/v1/strategies/{id}/result → 上传 BacktestResult-shape dict + 可选 replay_metrics.
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 (老调用方不破).
80
83
  """
81
84
  payload = _sanitize_for_json(result)
85
+ if replay_metrics is not None:
86
+ payload = {"result": payload, "replay_metrics": _sanitize_for_json(replay_metrics)}
82
87
  return _api_call('PUT', f'/api/v1/strategies/{strategy_id}/result', payload)
83
88
 
84
89
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hamuna-quant-cli
3
- Version: 0.1.8
3
+ Version: 0.1.10
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.8"
7
+ version = "0.1.10"
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"