hamuna-quant-cli 0.1.11__tar.gz → 0.1.13__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 (41) hide show
  1. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/PKG-INFO +1 -1
  2. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/__init__.py +1 -1
  3. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/__main__.py +6 -60
  4. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/akquant_runner.py +13 -53
  5. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/akquant_schema_adapter.py +113 -17
  6. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/live/qmt_broker.py +100 -0
  7. hamuna_quant_cli-0.1.13/hamuna_quant_cli/live/qmt_market.py +820 -0
  8. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/live/runner.py +153 -77
  9. hamuna_quant_cli-0.1.13/hamuna_quant_cli/live/server_market_client.py +311 -0
  10. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/http_client.py +32 -0
  11. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/server_client.py +4 -9
  12. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
  13. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/SOURCES.txt +1 -0
  14. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/pyproject.toml +1 -1
  15. hamuna_quant_cli-0.1.11/hamuna_quant_cli/live/qmt_market.py +0 -461
  16. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/MANIFEST.in +0 -0
  17. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/README.md +0 -0
  18. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/README.md +0 -0
  19. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/_market_fallback.py +0 -0
  20. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/_metrics_15.py +0 -0
  21. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
  22. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
  23. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/base_strategy.py +0 -0
  24. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
  25. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/live/__init__.py +0 -0
  26. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/live/loader.py +0 -0
  27. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
  28. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
  29. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/qmt_translator.py +0 -0
  30. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/__init__.py +0 -0
  31. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/backtest.py +0 -0
  32. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/cache.py +0 -0
  33. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/discipline.py +0 -0
  34. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/runtime/s3client.py +0 -0
  35. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli/scripts/server.json +0 -0
  36. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
  37. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
  38. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/requires.txt +0 -0
  39. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
  40. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/setup.cfg +0 -0
  41. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.13}/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.11
3
+ Version: 0.1.13
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.11"
15
+ __version__ = "0.1.13"
@@ -332,9 +332,8 @@ def cmd_commit(args) -> int:
332
332
  bundle = 4 文件 (strategy + result + config + params). multipart params 字段
333
333
  = 整个 params.json 内容 (ADR-0025, desktop 实盘配置源).
334
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 二次校验防绕过.
335
+ Round 18.63: replay gate (P0-1) 移除 (用户决策) — 上传不再要求 replay_result,
336
+ `--replay-result` / `--skip-replay-gate` 参数已删.
338
337
 
339
338
  Round 18.62 P1-1: 新增 step 1.4 version bump — `GET /strategies/{id}` 拿
340
339
  `latest_version` (无 → "0.0.0"); 默认 patch 自动 bump (`0.0.0 → 0.0.1`),
@@ -345,7 +344,6 @@ def cmd_commit(args) -> int:
345
344
  from .runtime.server_client import ServerError
346
345
  import io
347
346
  import json
348
- import os
349
347
  import re
350
348
  import tarfile
351
349
 
@@ -367,16 +365,6 @@ def cmd_commit(args) -> int:
367
365
  if params_path and not params_path.exists():
368
366
  print(f"params.json 不存在: {params_path} (--params 可选)", file=sys.stderr)
369
367
  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
368
 
381
369
  # ----- Round 18.62 P1-1 step 1.4: version bump -----
382
370
  # cli 先 GET /strategies/{id} 拿 latest_version (无 → "0.0.0"); 默认 patch 自动 bump,
@@ -407,7 +395,7 @@ def cmd_commit(args) -> int:
407
395
  bundle_out = Path(args.bundle_out) if args.bundle_out else Path(f"runs/{sid}/bundle.tar.gz")
408
396
  bundle_out.parent.mkdir(parents=True, exist_ok=True)
409
397
 
410
- print("=== Step 1/3: 打包 bundle (strategy + result + config + params + replay + version) ===",
398
+ print("=== Step 1/3: 打包 bundle (strategy + result + config + params + version) ===",
411
399
  file=sys.stderr)
412
400
  with tarfile.open(bundle_out, "w:gz") as tar:
413
401
  tar.add(strategy_path, arcname=strategy_path.name)
@@ -416,9 +404,6 @@ def cmd_commit(args) -> int:
416
404
  tar.add(cfg_path, arcname=cfg_path.name)
417
405
  if params_path:
418
406
  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
407
  # Round 18.62 P1-1: version.json 内存写, audit 用 (server 不读, 仅本地回溯).
423
408
  ver_bytes = json.dumps({"version": new_version}, ensure_ascii=False).encode("utf-8")
424
409
  ver_info = tarfile.TarInfo(name="version.json")
@@ -426,40 +411,7 @@ def cmd_commit(args) -> int:
426
411
  tar.addfile(ver_info, io.BytesIO(ver_bytes))
427
412
  print(f" ✓ bundle = {bundle_out} ({bundle_out.stat().st_size} bytes)", file=sys.stderr)
428
413
 
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) ===",
414
+ print("=== Step 2/3: PUT /strategies/:id/result (metrics + version) ===",
463
415
  file=sys.stderr)
464
416
  try:
465
417
  result = json.loads(result_path.read_text(encoding="utf-8"))
@@ -468,7 +420,7 @@ def cmd_commit(args) -> int:
468
420
  return 2
469
421
  try:
470
422
  resp = runtime.server_client.upload_backtest_result(
471
- sid, result, replay_metrics=replay_metrics, version=new_version,
423
+ sid, result, version=new_version,
472
424
  )
473
425
  except runtime.server_client.ServerError as e:
474
426
  print(f" ✗ 上传 metrics 失败: {e}", file=sys.stderr)
@@ -646,7 +598,7 @@ def _bridge_kline(server: str, symbols: list[str], days: int) -> list[dict]:
646
598
  for sym in symbols:
647
599
  url = (
648
600
  f"{server}/data/history?security={urllib.parse.quote(sym)}"
649
- f"&period=1d&count={days}&fq=None"
601
+ f"&period=1d&count={days}&fq=None&subscribe=false"
650
602
  )
651
603
  try:
652
604
  with urllib.request.urlopen(url, timeout=8) as resp:
@@ -1036,12 +988,6 @@ def build_parser() -> argparse.ArgumentParser:
1036
988
  cm.add_argument("strategy_id", help="server 端 strategy_id")
1037
989
  cm.add_argument("--strategy", help="akquant strategy .py 路径 (默认 <id>.py)")
1038
990
  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
991
  # Round 18.62 P1-1: version bump. 默认 patch 自动 (从 server latest_version +0.0.1),
1046
992
  # 显式 --major / --minor opt-in. 互斥.
1047
993
  cm.add_argument("--major", action="store_true",
@@ -46,63 +46,23 @@ class StrategyParamsError(RuntimeError):
46
46
 
47
47
  # ---- strategy loader (沿 driver._load_strategy 模式, 但导 Strategy 类不导 handlebar) ----
48
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
49
+ """回测端 on_cross_section 调仓路径 — 与实盘 schedule_daily+on_timer 配对.
50
+
51
+ Round 24 真相修正 (前 Round 18.32 wrapper 声称 schedule+on_timer 注入, 但
52
+ `akquant.collect_cross_section_timer_entries` 只查 strategy 上
53
+ `_trading_day_cross_section_timestamps` 字段, 完全不消费 `strategy_scheduler`
54
+ 里的 _schedule 注册 — 实测 0 trade). 真触发路径只有 on_cross_section.
68
55
 
69
- if cls is None or "on_timer" not in getattr(cls, "__dict__", {}):
56
+ 本 wrapper 改为: 不再吞 on_cross_section, 让策略层的 on_cross_section 自由
57
+ 处理调仓 (用户策略层用 weekday/dedup 判定周频, Round 24 DIR-003 同型).
58
+ 策略层若无 on_cross_section, 不 wrap (直接返回 cls, 让 engine 走 on_cross_section
59
+ 默认 no-op, 不影响).
60
+ """
61
+ if cls is None or "on_cross_section" not in getattr(cls, "__dict__", {}):
70
62
  return cls
71
- from akquant.strategy_scheduler import schedule as _schedule
72
63
 
73
64
  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
65
+ pass
106
66
 
107
67
  _BacktestTimerRebalance.__name__ = cls.__name__
108
68
  _BacktestTimerRebalance.__qualname__ = cls.__qualname__
@@ -8,12 +8,13 @@
8
8
  一致 (B 重写不动数值层). 迁移原因: v2 skill 未来独立分发, 不依赖仓根.
9
9
 
10
10
  字段映射 (15 metrics):
11
- total_return ← r.metrics.total_return_pct / 100
12
- annual_return ← r.metrics.annual_return_pct / 100 (akquant 直接给)
11
+ total_return ← r.metrics.total_return_pct / 100 (% → ratio)
12
+ annual_return ← r.metrics.annualized_return (0.3.52, 小数)
13
+ 或 r.metrics.annual_return_pct / 100 (0.3.41 legacy, %)
13
14
  sharpe ← r.metrics.sharpe_ratio
14
15
  max_drawdown ← r.metrics.max_drawdown_pct / 100
15
16
  volatility ← 自算 (np.std(daily_ret) * sqrt(252), 沿 metrics.py:63)
16
- win_rate ← r.metrics.win_rate
17
+ win_rate ← r.metrics.win_rate / 100 (% → 0~1; 0 平仓 → None)
17
18
  profit_loss_ratio ← 自算 from trades_df (FIFO pair, 沿 metrics.py:104)
18
19
  avg_holding_period ← 自算 calendar days (沿 metrics.py:155)
19
20
  benchmark_total_return ← NaN (akquant 单策略无自动基准, 见 bench_20260813_201349.json)
@@ -24,6 +25,11 @@
24
25
  profit_factor ← 自算 from trades_df (沿 metrics.py:192)
25
26
  annual_volatility ← alias of volatility
26
27
 
28
+ > ⚠ 0.3.41 → 0.3.52 键名漂移 (2026-09-02 真回测校准):
29
+ > `annual_return_pct` → `annualized_return` (且单位从 % 变为小数);
30
+ > `final_value` → `end_market_value`; `win_rate` 两版都是 %, 需 /100.
31
+ > 读取统一走 `_metric(obj, _METRIC_ALIASES[i])`, 两版兼容.
32
+
27
33
  equity_curve: r.equity_curve() Series → [{date:'YYYYMMDD', nav: float}, ...]
28
34
  首日补 initial_capital (driver.py:103-106 同款)
29
35
 
@@ -37,7 +43,8 @@ monthly_bars / monthly_metrics: 自算 (沿 metrics.py:242+)
37
43
 
38
44
  initial_capital / final_capital / avg_holding_period / suggestions:
39
45
  - initial_capital = cfg['init_capital']
40
- - final_capital = r.metrics.final_value (若 akquant 给) 或 equity_curve 末点
46
+ - final_capital = r.metrics.end_market_value (0.3.52) / final_value (0.3.41),
47
+ 或 equity_curve 末点 (fallback)
41
48
  - avg_holding_period = metrics 内同名字段
42
49
  - suggestions = [] (Phase B 最小化; driver._suggestions 不复用)
43
50
 
@@ -97,6 +104,36 @@ def _safe_get(obj: Any, key: str, default: float = float('nan')) -> float:
97
104
  return f
98
105
 
99
106
 
107
+ # akquant 0.3.41 → 0.3.52 键名漂移 (2026-09-02 真回测校准):
108
+ # 0.3.41 用 `annual_return_pct` (%, /100) / `final_value`;
109
+ # 0.3.52 改为 `annualized_return` (小数, 直接取) / `end_market_value`.
110
+ # `win_rate` 两个版本都是 % (有平仓时 70.0 = 70%), Hamuna 契约要 0~1 → /100.
111
+ _METRIC_ALIASES: list[tuple[tuple[str, float], ...]] = [
112
+ # (候选键名, 换算 scale) — 键存在即取, scale 对齐 Hamuna ratio 契约
113
+ (('total_return_pct', 1.0 / 100.0),), # % → ratio
114
+ (('annualized_return', 1.0), ('annual_return_pct', 1.0 / 100.0)), # 0.3.52 小数 | 0.3.41 %
115
+ (('sharpe_ratio', 1.0),),
116
+ (('max_drawdown_pct', 1.0 / 100.0),), # % → ratio
117
+ (('win_rate', 1.0 / 100.0),), # % → ratio (0~1)
118
+ (('end_market_value', 1.0), ('final_value', 1.0)), # 0.3.52 | 0.3.41
119
+ ]
120
+
121
+
122
+ def _metric(obj: Any, aliases: tuple[tuple[str, float], ...],
123
+ default: float = float('nan')) -> float:
124
+ """按候选键名读取 metric, 兼容版本键名漂移.
125
+
126
+ aliases: ((键名, scale), ...) 按优先序; 取第一个在 obj 上真实存在的键,
127
+ 返回值 = 原值 × scale. 全部缺失 → default (NaN). 与 _safe_get 的
128
+ "missing 与 0.0 不混淆" 语义一致 (float 转换失败/NaN/Inf 也返回 default).
129
+ """
130
+ for key, scale in aliases:
131
+ v = _safe_get(obj, key, default=float('nan'))
132
+ if not math.isnan(v):
133
+ return v * scale
134
+ return default
135
+
136
+
100
137
  def _nan() -> float:
101
138
  return float('nan')
102
139
 
@@ -206,13 +243,19 @@ def to_hamuna_result(akquant_r: Any, cfg: dict) -> dict:
206
243
  equity_curve = _to_daily(eq_curve, initial_capital)
207
244
  nav_values = [p['nav'] for p in equity_curve]
208
245
 
209
- # 15 metrics
246
+ # 15 metrics — 键名走 `_metric` 别名 (0.3.41/0.3.52 兼容, 见 _METRIC_ALIASES)
210
247
  m = getattr(akquant_r, 'metrics', None)
211
- total_return = _safe_get(m, 'total_return_pct', 0.0) / 100.0 if m else _nan()
212
- annual_return = _safe_get(m, 'annual_return_pct', _nan()) / 100.0 if m else _nan()
213
- sharpe = _safe_get(m, 'sharpe_ratio', _nan()) if m else _nan()
214
- mdd = _safe_get(m, 'max_drawdown_pct', _nan()) / 100.0 if m else _nan()
215
- win_rate = _safe_get(m, 'win_rate', _nan()) if m else _nan()
248
+ total_return = _metric(m, _METRIC_ALIASES[0], 0.0) if m else _nan()
249
+ annual_return = _metric(m, _METRIC_ALIASES[1]) if m else _nan()
250
+ sharpe = _metric(m, _METRIC_ALIASES[2]) if m else _nan()
251
+ mdd = _metric(m, _METRIC_ALIASES[3]) if m else _nan()
252
+ win_rate = _metric(m, _METRIC_ALIASES[4]) if m else _nan()
253
+ # 0 closed pair (trades_df 空) 时 AKQuant metrics.win_rate 为 0.0, 但 Hamuna 契约
254
+ # 要 None (backtest-result.md: 0 trades → win_rate=None). trades_df 才是 closed
255
+ # pair 源 (executions_df 含 buy-only 流水, 不能用来判"有无已平仓交易").
256
+ closed_pair_empty = trades_df is None or trades_df.empty
257
+ if closed_pair_empty:
258
+ win_rate = _nan()
216
259
  # 自算
217
260
  volatility = _volatility(nav_values) if nav_values else 0.0
218
261
  sortino = _sortino(nav_values) if nav_values else 0.0
@@ -247,8 +290,8 @@ def to_hamuna_result(akquant_r: Any, cfg: dict) -> dict:
247
290
  monthly_bars = _monthly_bars(equity_curve)
248
291
  monthly_metrics = _monthly_metrics(equity_curve)
249
292
 
250
- # final_capital
251
- final_capital = _safe_get(m, 'final_value', float('nan')) if m else _nan()
293
+ # final_capital: end_market_value (0.3.52) / final_value (0.3.41) → fallback equity 末点
294
+ final_capital = _metric(m, _METRIC_ALIASES[5], float('nan')) if m else _nan()
252
295
  if math.isnan(final_capital) and equity_curve:
253
296
  final_capital = equity_curve[-1]['nav']
254
297
  final_capital = None if isinstance(final_capital, float) and math.isnan(final_capital) else final_capital
@@ -434,14 +477,29 @@ def _json_safe(o: Any) -> Any:
434
477
  场景: akquant orders_df 等原始曲面的 limit_price/stop_price 常见 NaN
435
478
  (市价单/未触发单). 直接进 result 会让后端 json.loads 崩.
436
479
  Round 18.16 真数据验证抓到的真实泄漏 (Round 3 NaN 待办真载体).
480
+ Round 18.62+ 实测补漏: DataFrame.to_dict 会把 DatetimeIndex/时间列转成
481
+ pandas.Timestamp / datetime — json.dumps 抛 TypeError, 一并转 ISO 字符串.
437
482
  """
438
- import math
483
+ import datetime as _dt
439
484
  if isinstance(o, float):
440
485
  return None if (math.isnan(o) or math.isinf(o)) else o
441
486
  if isinstance(o, dict):
442
487
  return {k: _json_safe(v) for k, v in o.items()}
443
488
  if isinstance(o, list):
444
489
  return [_json_safe(x) for x in o]
490
+ # pandas.Timestamp 是 datetime 子类 → 先判 Timestamp 再判 datetime, 顺序无所谓
491
+ if isinstance(o, _dt.datetime):
492
+ return o.isoformat()
493
+ if isinstance(o, _dt.date):
494
+ return o.isoformat()
495
+ if isinstance(o, _dt.timedelta):
496
+ # orders_df.duration = exit - entry (timedelta) — JSON 无原生类型, 转秒
497
+ return o.total_seconds()
498
+ if hasattr(o, 'isoformat'): # 兜底: np.datetime64 等
499
+ try:
500
+ return o.isoformat()
501
+ except Exception: # noqa: BLE001
502
+ pass
445
503
  return o
446
504
 
447
505
 
@@ -475,12 +533,19 @@ def _selfcheck() -> None:
475
533
  "设置 HAMUNA_SELFTEST=1 显式启用."
476
534
  )
477
535
  class _MockMetrics:
536
+ """0.3.52 真实键 (2026-09-02 实测校准):
537
+ - total_return_pct = % (5.0 表示 5%) → ratio 0.05
538
+ - annualized_return = 小数 (0.12 表示 12%), 无 annual_return_pct
539
+ - max_drawdown_pct = % (8.0 表示 8%) → ratio 0.08
540
+ - win_rate = % (60.0 表示 60%) → ratio 0.6; 无 closed pair 时为 0.0
541
+ - end_market_value (金额), 无 final_value
542
+ """
478
543
  total_return_pct = 5.0
479
- annual_return_pct = 12.0
544
+ annualized_return = 0.12
480
545
  sharpe_ratio = 1.2
481
546
  max_drawdown_pct = 8.0
482
- win_rate = 0.6
483
- final_value = 1_050_000.0
547
+ win_rate = 60.0
548
+ end_market_value = 1_050_000.0
484
549
 
485
550
  class _MockEquityCurve:
486
551
  def __init__(self):
@@ -509,6 +574,9 @@ def _selfcheck() -> None:
509
574
  # NaN 泄漏回归: orders_df 的 limit_price 常态 NaN (市价单), 必须被 _json_safe 清掉
510
575
  orders_df = [{'symbol': '600000.SH', 'limit_price': float('nan'),
511
576
  'stop_price': float('nan'), 'filled': 100}]
577
+ # Timestamp 泄漏回归 (Round 18.62+ 实测): exposure_df 的 date 列 to_dict
578
+ # 后是 pandas.Timestamp, json.dumps 会 TypeError — 必须转 ISO 字符串.
579
+ exposure_df = [{'date': pd.Timestamp('2024-01-05'), 'gross': 1.2}]
512
580
 
513
581
  cfg = {
514
582
  'backtest_start': '20240101',
@@ -521,6 +589,14 @@ def _selfcheck() -> None:
521
589
  # 15 metrics
522
590
  assert set(result['metrics'].keys()) == HAMUNA_METRICS_15, (
523
591
  f'metrics key 缺/多: {set(result["metrics"].keys()) ^ HAMUNA_METRICS_15}')
592
+ # 单位转换断言 (0.3.52 键名/单位校准, 2026-09-02):
593
+ # total_return_pct=5.0(%) → 0.05; annualized_return=0.12(小数) → 0.12;
594
+ # max_drawdown_pct=8.0(%) → 0.08; win_rate=60.0(%) → 0.6.
595
+ mt = result['metrics']
596
+ assert abs(mt['total_return'] - 0.05) < 1e-9, mt['total_return']
597
+ assert abs(mt['annual_return'] - 0.12) < 1e-9, mt['annual_return']
598
+ assert abs(mt['max_drawdown'] - 0.08) < 1e-9, mt['max_drawdown']
599
+ assert abs(mt['win_rate'] - 0.6) < 1e-9, mt['win_rate']
524
600
  # 13 顶层 key (driver.py:113-128: metrics/equity_curve/benchmark_curve/trades/universe/
525
601
  # period/params/monthly_bars/monthly_metrics/initial_capital/final_capital/avg_holding_period/suggestions)
526
602
  expected_top = {'metrics', 'equity_curve', 'benchmark_curve', 'trades', 'universe',
@@ -543,10 +619,30 @@ def _selfcheck() -> None:
543
619
  odf = result['params']['_akquant_extra']['orders_df']
544
620
  assert odf[0]['limit_price'] is None, f'limit_price 应 None, got {odf[0]["limit_price"]!r}'
545
621
  assert odf[0]['stop_price'] is None
622
+ # Timestamp 泄漏回归 (Round 18.62+): 时间值必须转 ISO 字符串, 否则 json.dumps TypeError
623
+ edf = result['params']['_akquant_extra']['exposure_df']
624
+ assert edf[0]['date'] == '2024-01-05T00:00:00', f'Timestamp 应转 ISO, got {edf[0]["date"]!r}'
546
625
  import json
547
- json.dumps(result) # strict JSON 序列化 — NaN 会 raise ValueError
626
+ json.dumps(result) # strict JSON 序列化 — NaN 会 raise ValueError, Timestamp 会 TypeError
548
627
  print('OK: akquant_schema_adapter 15 metrics + 13 顶层 key + 7 trades fields + NaN 清理 全对齐')
549
628
 
629
+ # 0.3.41 legacy 键兼容 (annual_return_pct=12.0 % → 0.12; final_value=1050000):
630
+ class _LegacyMetrics:
631
+ total_return_pct = 5.0
632
+ annual_return_pct = 12.0
633
+ sharpe_ratio = 1.2
634
+ max_drawdown_pct = 8.0
635
+ win_rate = 60.0
636
+ final_value = 1_050_000.0
637
+
638
+ class _LegacyResult(_MockResult):
639
+ metrics = _LegacyMetrics()
640
+
641
+ legacy = to_hamuna_result(_LegacyResult(), cfg)
642
+ assert abs(legacy['metrics']['annual_return'] - 0.12) < 1e-9, legacy['metrics']['annual_return']
643
+ assert legacy['final_capital'] == 1_050_000.0, legacy['final_capital']
644
+ print('OK: 0.3.41 legacy 键 (annual_return_pct / final_value) 兼容')
645
+
550
646
 
551
647
  if __name__ == '__main__':
552
648
  _selfcheck()
@@ -66,6 +66,7 @@ def _import_akquant_gateway():
66
66
  # (transient failure 自动重试; 4xx/业务错误不重试)
67
67
  # ============================================================
68
68
  import json
69
+ import time
69
70
  from typing import Any
70
71
 
71
72
  import requests
@@ -383,6 +384,19 @@ def _make_trader_gateway():
383
384
  if order_id:
384
385
  self.record_broker_order(order_id, req.client_order_id)
385
386
  # 即便 order_id 空 (bridge 的 submit_unknown), 也返回它拿到的 ref, 让上层知道.
387
+ if not order_id:
388
+ # Round 26 修复: bridge passorder_return=0 时 order_id/order_ref 都空,
389
+ # 但 bridge 已经把 qmt_user_order_id 写到了 QMT m_strRemark (Round 27 后
390
+ # bridge 会主动 poll 找真 order_id 并写到响应里). 此处 fallback:
391
+ # 1) qmt_user_order_id 优先 (bridge 内的稳定 key, 跨重启也保留)
392
+ # 2) passorder_return 兜底 (xtquant passorder 返回值, 0/正整数都允许)
393
+ # 任一非空都 record_broker_order, 让 akquant _sync_order_id_mapping
394
+ # (line 2072 of akquant/_runner.py) 不会因 broker_order_id 空而跳过.
395
+ fallback_id = (str(data.get("qmt_user_order_id") or "")
396
+ or str(data.get("passorder_return") or ""))
397
+ if fallback_id and fallback_id not in ("0", "0.0"):
398
+ self.record_broker_order(fallback_id, req.client_order_id)
399
+ return fallback_id
386
400
  return order_id or str(data.get("passorder_return") or "")
387
401
 
388
402
  # ----- 撤单 -----
@@ -511,6 +525,92 @@ def _make_trader_gateway():
511
525
  except BrokerHTTPError:
512
526
  return False
513
527
 
528
+ # ----- Round 26 补齐: akquant Protocol sync_* 方法 -----
529
+ # akquant BrokerRecovery (live/_runner.py:1800) 每 30s 调 sync_open_orders /
530
+ # sync_today_trades, 期望返回 UnifiedOrderSnapshot / UnifiedTrade 列表.
531
+ # qmt 路径默认 TraderGatewayBase.sync_* 返 [], 导致 on_order/on_trade 永远
532
+ # 收不到回报 (Round 26 0 trade 主因之一). 这里 override 走 query_* + 反查表.
533
+ #
534
+ # Round 28+ — bridge ensure_cache 同步阻塞老问题 cascade:
535
+ # bridge _QMT_EXECUTOR 4 线程被 download_history_data 占满 → /orders HTTP hang
536
+ # → sync_open_orders 每 30s 触发 query_order 累积 5s×N → on_order 永远收不到.
537
+ # Fix: TTL local cache (30s 内复用, 不重复 HTTP) + HTTP 失败返 last known good
538
+ # (不抛, 降级). 配合 bridge ensure_cache 异步化 (Round 28+ 改动 #1).
539
+ _sync_cache_ttl: float = 30.0
540
+ _sync_orders_cache: tuple = (0.0, []) # (monotonic_ts, result_list)
541
+ _sync_trades_cache: tuple = (0.0, [])
542
+
543
+ def sync_open_orders(self): # type: ignore[override]
544
+ """调 query_order per known broker_order_id, 聚合未完成订单.
545
+
546
+ Round 28+: TTL cache + 失败降级返 last known good.
547
+
548
+ 已知 broker_order_id 来源:
549
+ 1) self._client_id_by_broker (record_broker_order 写入, TraderGatewayBase)
550
+ 2) self._broker_to_client_order_ids (akquant 内部维护)
551
+ 任一找不到的 fallback 调 query_order(client_order_id=client_oid).
552
+ """
553
+ if not self._connected:
554
+ return []
555
+ # Round 28+: TTL cache — 30s 内复用避免 bridge hang 时重复 HTTP
556
+ now = time.monotonic()
557
+ ts, cached = self._sync_orders_cache
558
+ if cached and (now - ts) < self._sync_cache_ttl:
559
+ return cached
560
+ ak = _import_akquant_gateway()
561
+ UnifiedOrderSnapshot = ak["UnifiedOrderSnapshot"]
562
+ known_ids: set[str] = set()
563
+ client_map = getattr(self, "_client_id_by_broker", {})
564
+ known_ids.update(str(k) for k in client_map.keys() if k)
565
+ snapshots: list = []
566
+ seen: set[str] = set()
567
+ any_success = False
568
+ for bid in known_ids:
569
+ if not bid or bid in seen:
570
+ continue
571
+ seen.add(bid)
572
+ try:
573
+ snap = self.query_order(bid)
574
+ if snap is not None:
575
+ snapshots.append(snap)
576
+ any_success = True
577
+ except Exception as e: # noqa: BLE001
578
+ print(f"[qmt-broker] sync_open_orders {bid} error: "
579
+ f"{type(e).__name__}: {e}", flush=True)
580
+ # Round 28+: 全失败且有 cache → 降级返 cache (桥 hang 时不抛, 保 fire 不死)
581
+ if not any_success and cached:
582
+ print(f"[qmt-broker] sync_open_orders 全失败, 降级返 cache "
583
+ f"({len(cached)} 条, age={now - ts:.1f}s)", flush=True)
584
+ return cached
585
+ # 成功 → 更新 cache
586
+ self._sync_orders_cache = (now, snapshots)
587
+ return snapshots
588
+
589
+ def sync_today_trades(self): # type: ignore[override]
590
+ """调 query_trades (无 since) 拉当日所有成交, 30s 周期.
591
+
592
+ Round 28+: TTL cache + 失败降级.
593
+ """
594
+ if not self._connected:
595
+ return []
596
+ now = time.monotonic()
597
+ ts, cached = self._sync_trades_cache
598
+ if cached and (now - ts) < self._sync_cache_ttl:
599
+ return cached
600
+ try:
601
+ result = self.query_trades(since=None)
602
+ self._sync_trades_cache = (now, list(result))
603
+ return result
604
+ except Exception as e: # noqa: BLE001
605
+ if cached:
606
+ print(f"[qmt-broker] sync_today_trades HTTP 失败, 降级返 cache "
607
+ f"({len(cached)} 条, age={now - ts:.1f}s): "
608
+ f"{type(e).__name__}: {e}", flush=True)
609
+ return cached
610
+ print(f"[qmt-broker] sync_today_trades error: "
611
+ f"{type(e).__name__}: {e}", flush=True)
612
+ return []
613
+
514
614
  return QmtTraderGateway
515
615
 
516
616