hamuna-quant-cli 0.1.11__tar.gz → 0.1.12__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.11 → hamuna_quant_cli-0.1.12}/PKG-INFO +1 -1
  2. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/__init__.py +1 -1
  3. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/__main__.py +5 -59
  4. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/akquant_runner.py +13 -53
  5. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/akquant_schema_adapter.py +113 -17
  6. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/live/qmt_broker.py +100 -0
  7. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/live/qmt_market.py +80 -21
  8. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/live/runner.py +142 -77
  9. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/server_client.py +4 -9
  10. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
  11. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/pyproject.toml +1 -1
  12. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/MANIFEST.in +0 -0
  13. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/README.md +0 -0
  14. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/README.md +0 -0
  15. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/_market_fallback.py +0 -0
  16. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/_metrics_15.py +0 -0
  17. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
  18. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
  19. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/base_strategy.py +0 -0
  20. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
  21. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/live/__init__.py +0 -0
  22. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/live/loader.py +0 -0
  23. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
  24. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
  25. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/qmt_translator.py +0 -0
  26. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/__init__.py +0 -0
  27. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/backtest.py +0 -0
  28. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/cache.py +0 -0
  29. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/discipline.py +0 -0
  30. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/http_client.py +0 -0
  31. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/runtime/s3client.py +0 -0
  32. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli/scripts/server.json +0 -0
  33. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/SOURCES.txt +0 -0
  34. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
  35. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
  36. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/requires.txt +0 -0
  37. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
  38. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/setup.cfg +0 -0
  39. {hamuna_quant_cli-0.1.11 → hamuna_quant_cli-0.1.12}/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.12
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.12"
@@ -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)
@@ -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
 
@@ -13,7 +13,7 @@
13
13
 
14
14
  bridge_server 端点:
15
15
  GET /data/snapshot?securities=<sym1>,<sym2>,... → 全推实时 tick (QMT get_full_tick)
16
- GET /data/history?security=<sym>&period=<p>&count=<n>&fq=<fwd/back/None>
16
+ GET /data/history?security=<sym>&period=<p>&count=<n>&fq=<fwd/back/qfq/None>
17
17
  → 历史 K 线 (QMT get_market_data_ex)
18
18
  GET /health → 探活
19
19
 
@@ -75,12 +75,14 @@ def _make_market_gateway() -> Any:
75
75
  poll_interval: float = 30.0, # 默认 30s/帧 (≈2/min) — 用户决策: 不打 QMT 限流; 想更快自己 CLI 透传
76
76
  timeout: float = 5.0,
77
77
  symbols: list[str] | None = None, # 启动时 auto-subscribe (来自 build_qmt_market 的 symbols)
78
+ subscribe: bool = False, # paper=False / broker_live=True — 透传给 /data/history
78
79
  ) -> None:
79
80
  self._feed = feed
80
81
  self._http = _HTTP(base_url, timeout=timeout)
81
82
  self._period = period
82
83
  self._bar_count = bar_count
83
84
  self._poll_interval = max(0.05, float(poll_interval))
85
+ self._subscribe = bool(subscribe)
84
86
  # 启动时 auto-subscribe: build_qmt_market(symbols=...) 传来的就是 akquant run_live
85
87
  # instruments — 立即装订到 _subscribed, 不依赖 akquant forwarder (实测发现 forwarder
86
88
  # 在 functional mode 下安装时机晚于 strategy.subscribe, 错过首次订阅)
@@ -152,7 +154,19 @@ def _make_market_gateway() -> Any:
152
154
  while True:
153
155
  try:
154
156
  bars = self._fetch_bars(sym)
155
- for bar in bars:
157
+ # Round 26 诊断: 区分 warmup bar (历史回填) vs live bar (新生成)
158
+ # 避免 warmup 期间 emit 跟 live 期间 emit 混在一起 (debug 时方便定位).
159
+ print(f"[qmt-market] warmup {sym}: 拉 {len(bars)} 根 "
160
+ f"(period={self._period}, bar_count={self._bar_count})",
161
+ flush=True)
162
+ for i, bar in enumerate(bars):
163
+ if i < 3 or i == len(bars) - 1:
164
+ # 只打印头 3 + 末 1 根, 避免刷屏
165
+ print(f"[qmt-market] warmup {sym} bar[{i}] "
166
+ f"time={bar.get('time')} "
167
+ f"close={bar.get('close')} "
168
+ f"volume={bar.get('volume')}",
169
+ flush=True)
156
170
  self._emit_bar(bar)
157
171
  if bars:
158
172
  self._last_bar_signatures[sym] = _bar_signature(bars[-1])
@@ -189,21 +203,44 @@ def _make_market_gateway() -> Any:
189
203
  time.sleep(sleep_for)
190
204
 
191
205
  def _poll_once(self) -> None:
206
+ """一次 poll 拉 ticks + bars — 各自独立 try/except, 互不阻塞.
207
+
208
+ Round 26 Bug 修: 原版 _fetch_ticks 抛异常 → 整个 _poll_once 退出,
209
+ bar 拉取永远不到; snapshot timeout 一次 → bar 推送永久缺失.
210
+ 改版: ticks / bars 各自 try, 异常只 print + continue, 不中断对方.
211
+ """
192
212
  subs = list(self._subscribed)
193
213
  if not subs:
194
214
  return
195
- ticks = self._fetch_ticks(subs)
196
- for t in ticks:
197
- self._emit_tick(t)
215
+ # ticks: 独立 try — snapshot timeout 不应阻止 bar 拉取
216
+ try:
217
+ ticks = self._fetch_ticks(subs)
218
+ for t in ticks:
219
+ self._emit_tick(t)
220
+ except Exception as e: # noqa: BLE001
221
+ print(f"[qmt-market] ticks poll error: {type(e).__name__}: {e}",
222
+ flush=True)
223
+ # bars: 独立 try — 单只 sym 失败不影响其它
198
224
  for sym in subs:
199
- bars = self._fetch_bars(sym, count=1)
200
- if not bars:
201
- continue
202
- last = bars[-1]
203
- sig = _bar_signature(last)
204
- if self._last_bar_signatures.get(sym) != sig:
205
- self._last_bar_signatures[sym] = sig
206
- self._emit_bar(last)
225
+ try:
226
+ bars = self._fetch_bars(sym, count=1)
227
+ # Round 29+4 诊断: 看 _fetch_bars 实际返几个 bar + 是不是真的空
228
+ if not bars:
229
+ print(f"[qmt-market] bars poll {sym}: 0 bars (QMT 数据未到?)",
230
+ flush=True)
231
+ continue
232
+ last = bars[-1]
233
+ sig = _bar_signature(last)
234
+ prev = self._last_bar_signatures.get(sym)
235
+ if prev != sig:
236
+ self._last_bar_signatures[sym] = sig
237
+ self._emit_bar(last)
238
+ print(f"[qmt-market] bar emit {sym} time={last.get('time')} "
239
+ f"close={last.get('close')} sig_changed={prev is None}",
240
+ flush=True)
241
+ except Exception as e: # noqa: BLE001
242
+ print(f"[qmt-market] bars poll error {sym}: "
243
+ f"{type(e).__name__}: {e}", flush=True)
207
244
 
208
245
  def _fetch_ticks(self, symbols: list[str]) -> list[dict[str, Any]]:
209
246
  """GET /data/snapshot?securities=sym1,sym2 → list of tick dict.
@@ -227,13 +264,19 @@ def _make_market_gateway() -> Any:
227
264
  return ticks
228
265
 
229
266
  def _fetch_bars(self, symbol: str, count: int | None = None) -> list[dict[str, Any]]:
230
- """GET /data/history?security=...&period=1d&count=N → list of bar dict (旧→新)."""
267
+ """GET /data/history?security=...&period=1d&count=N[&subscribe=T/F] → list of bar dict (旧→新).
268
+
269
+ subscribe: paper=False / broker_live=True (round 28+1 决策) — paper
270
+ 一次性拉够 N 根即可, broker_live 必须 True 让 QMT 实时推新 bar,
271
+ 避免 count=-1 + 空时间窗只返本地最新 1 条 snapshot 的问题.
272
+ """
231
273
  qmt_sym = _to_qmt_symbol(symbol)
232
274
  cnt = count if count is not None else self._bar_count
233
- resp = self._http.get("/data/history", {
234
- "security": qmt_sym, "period": self._period,
235
- "count": str(cnt), "fq": "None",
236
- })
275
+ params = {"security": qmt_sym, "period": self._period,
276
+ "count": str(cnt), "fq": "qfq"}
277
+ if self._subscribe:
278
+ params["subscribe"] = "true"
279
+ resp = self._http.get("/data/history", params)
237
280
  value = _unwrap_envelope(resp)
238
281
  return _bars_from_history(value, symbol)
239
282
 
@@ -284,11 +327,18 @@ def _unwrap_envelope(resp: dict[str, Any]) -> Any:
284
327
 
285
328
 
286
329
  def _bar_signature(bar: dict[str, Any]) -> tuple:
287
- """bar OHLCV 摘要 — 推 on_bar 去重用."""
330
+ """bar 摘要 — 推 on_bar 去重用.
331
+
332
+ Round 26 修复: 加 `time` (epoch ms, QMT 全推) 进 tuple. 之前只用
333
+ OHLCV + datetime (YYYYMMDD 字符串), 当 1m/5m 等高频 bar 在同一
334
+ 'date' 字符串内 + 早盘 OHLCV 不变时 (e.g. 日线 fallback), signature
335
+ 永不变化 → 不 emit. 加 time 字段后即使 OHLCV 不变, 只要 bar 是新的
336
+ (time 不同), 就能 detect.
337
+ """
288
338
  return (
339
+ bar.get("time"), # epoch ms (Round 26 加: 高频 bar 时间戳)
289
340
  bar.get("open"), bar.get("high"), bar.get("low"),
290
341
  bar.get("close"), bar.get("volume"), bar.get("amount"),
291
- bar.get("datetime") or bar.get("date") or bar.get("time"),
292
342
  )
293
343
 
294
344
 
@@ -426,6 +476,10 @@ def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwar
426
476
  market_bar_count (default 20) — warmup 拉几根
427
477
  market_poll (default 30.0) — 轮询秒数 (用户决策 2026-08-18: ≥30s)
428
478
  market_timeout (default 5.0) — HTTP 超时
479
+ market_subscribe (default False) — /data/history subscribe= 参数
480
+ (paper=False, broker_live=True)
481
+ 透传方式: --gateway-options
482
+ "market_subscribe=1" (或 True)
429
483
  """
430
484
  from akquant.gateway.protocols import GatewayBundle
431
485
 
@@ -434,19 +488,24 @@ def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwar
434
488
  bar_count = int(kwargs.get("market_bar_count") or kwargs.get("bar_count") or 20)
435
489
  poll = float(kwargs.get("market_poll") or kwargs.get("poll_interval") or 30.0)
436
490
  timeout = float(kwargs.get("market_timeout") or kwargs.get("timeout") or 30.0) # ponytail: 跟 _HTTP.timeout 默认 30s 保持一致 (caller 显式传 timeout=5.0 会盖过这里默认, 但 build_qmt_market 不传 timeout 时必须 30s 才治网络抖动)
491
+ # market_subscribe: CLI 字符串 "1"/"true"/"yes" → True, 其它/缺失 → False
492
+ subscribe_raw = kwargs.get("market_subscribe") or kwargs.get("subscribe") or ""
493
+ subscribe = str(subscribe_raw).strip().lower() in ("1", "true", "yes", "on")
437
494
  QmtMarketGateway = _make_market_gateway()
438
495
  gw = QmtMarketGateway(
439
496
  feed=feed, # akquant DataFeed — 主路径推 tick/bar 用
440
497
  base_url=base_url, period=period, bar_count=bar_count,
441
498
  poll_interval=poll, timeout=timeout,
442
499
  symbols=list(symbols or []), # auto-subscribe (绕开 akquant forwarder 时序 bug)
500
+ subscribe=subscribe,
443
501
  )
444
502
  return GatewayBundle(
445
503
  market_gateway=gw,
446
504
  trader_gateway=None, # market-only builder, 不含 trader
447
505
  trader_capabilities=None,
448
506
  metadata={"broker": "qmt_market", "bridge": "bullettrade_compat",
449
- "period": period, "bar_count": bar_count, "poll_interval": poll},
507
+ "period": period, "bar_count": bar_count, "poll_interval": poll,
508
+ "subscribe": subscribe},
450
509
  )
451
510
 
452
511
 
@@ -39,12 +39,80 @@ def parse_kv_pairs(raw: str | None) -> dict[str, str]:
39
39
  return out
40
40
 
41
41
 
42
+ # Round 19+2 — market_period 自动从策略 params 推断 (CLI 显式 > 策略 params > 默认 1d).
43
+ # IntParam 0=auto/1d / 1=1d / 2=5m / 3=1m. cli 直读 self.params.market_period, 无 ast/无推断策略.
44
+ _MARKET_PERIOD_MAP = {0: "1d", 1: "1d", 2: "5m", 3: "1m"}
45
+
46
+
47
+ _CLI_PERIOD_ALIASES = {"1d", "1m", "5m", "1w", "1mon", "tick"}
48
+
49
+
50
+ def _resolve_market_period(
51
+ spec: StrategySpec, gateway_options: dict[str, str],
52
+ params: dict | None,
53
+ ) -> str | None:
54
+ """决定 driver market_period:
55
+ 1) CLI --gateway-options 显式 market_period → 翻译或透传
56
+ - 数字字符串 "0"/"1"/"2"/"3" 走 _MARKET_PERIOD_MAP (跟 IntParam 兼容)
57
+ - period 别名 "1d"/"1m"/"5m"/"1w"/"1mon" 原样透传
58
+ 2) 策略 self.params.market_period (skill 在 .py 生成) → 映射
59
+ 3) params.json strategy_params.market_period → 同上 (运行时覆盖)
60
+ 4) 默认 "1d" (向后兼容)
61
+
62
+ 返回 None 表示 CLI 已显式 period 别名, 不需要再 inject (兼容旧契约).
63
+ """
64
+ if "market_period" in gateway_options:
65
+ cli_value = str(gateway_options["market_period"]).strip()
66
+ # 数字字符串 (IntParam 0/1/2/3) → 翻译成 period 别名
67
+ if cli_value in {"0", "1", "2", "3"}:
68
+ mapped = _MARKET_PERIOD_MAP[int(cli_value)]
69
+ print(
70
+ f"[info] CLI market_period 数字 {cli_value} → {mapped} "
71
+ f"(_MARKET_PERIOD_MAP 翻译)",
72
+ flush=True,
73
+ )
74
+ return mapped
75
+ # period 别名 → 原样透传 (注入到 gateway_options 防止下游错读)
76
+ if cli_value in _CLI_PERIOD_ALIASES:
77
+ gateway_options["market_period"] = cli_value
78
+ return None # CLI 已显式 period 别名, 跳过自动推断
79
+ # 其它非法值 — 不静默 fallback, 报错给用户
80
+ raise LiveRunError(
81
+ f"--gateway-options market_period={cli_value!r} 非法. "
82
+ f"接受 IntParam 数字 0/1/2/3 或 period 别名 "
83
+ f"{sorted(_CLI_PERIOD_ALIASES)}"
84
+ )
85
+ # 优先级 2: 策略 inline params (无 ast, 实例化读 params)
86
+ if spec.mode == "class" and spec.strategy_cls is not None:
87
+ try:
88
+ inst = spec.strategy_cls()
89
+ mp = int(getattr(inst.params, "market_period", 1))
90
+ mapped = _MARKET_PERIOD_MAP.get(mp, "1d")
91
+ print(
92
+ f"[info] market_period 自动推断: {mapped} "
93
+ f"(params.market_period={mp}, 策略 inline 默认)",
94
+ flush=True,
95
+ )
96
+ return mapped
97
+ except Exception as e: # noqa: BLE001
98
+ print(
99
+ f"[warn] market_period 推断失败 ({type(e).__name__}: {e}), "
100
+ f"fallback '1d'",
101
+ flush=True,
102
+ )
103
+ return "1d" # 默认
104
+
105
+
42
106
  def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
43
- timeout: float = 30.0) -> list[dict[str, Any]]:
44
- """GET /data/history?security=<sym>&period=<p>&count=<n>&fq=None → list of bar dict (旧→新).
107
+ timeout: float = 30.0, subscribe: bool = False) -> list[dict[str, Any]]:
108
+ """GET /data/history?security=<sym>&period=<p>&count=<n>&fq=qfq[&subscribe=T/F] → list of bar dict (旧→新).
45
109
 
46
110
  bridge 不通 / 超时 / 非 200 直接 raise LiveRunError, 不静默退化 (用户原话:
47
111
  策略出错直接报错停止, 不走 mock 兜底).
112
+
113
+ subscribe: paper=False / broker_live=True — paper 不需要实时订阅 (一次性拉够
114
+ N 根 bar 即可), broker_live 需要开 subscribe=True 让 QMT 实时推新 bar, 避免
115
+ count=-1 + start/end 全空时只返回本地最新 1 条 snapshot 的问题.
48
116
  """
49
117
  from urllib.parse import urlencode
50
118
  from urllib.request import urlopen, Request
@@ -52,9 +120,10 @@ def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
52
120
  canonical = sym.split(".")[0]
53
121
  if canonical.lower().startswith(("sh", "sz")):
54
122
  canonical = canonical[2:]
55
- q = urlencode({
56
- "security": canonical, "period": period, "count": str(count), "fq": "None",
57
- })
123
+ params = {"security": canonical, "period": period, "count": str(count), "fq": "qfq"}
124
+ if subscribe:
125
+ params["subscribe"] = "true"
126
+ q = urlencode(params)
58
127
  url = f"{bridge_url.rstrip('/')}/data/history?{q}"
59
128
  try:
60
129
  with urlopen(Request(url), timeout=timeout) as resp:
@@ -91,11 +160,14 @@ def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
91
160
 
92
161
 
93
162
  def _fetch_bridge_history_batch(bridge_url: str, symbols: list[str], period: str = "1d",
94
- count: int = 30, timeout: float = 45.0) -> dict[str, list[dict[str, Any]]]:
163
+ count: int = 30, timeout: float = 45.0,
164
+ subscribe: bool = False) -> dict[str, list[dict[str, Any]]]:
95
165
  """GET /data/history?securities=...&multi=true → {sym: [bar dict]} 批量获取.
96
166
 
97
167
  优先使用 bridge_server 的批量 multi 接口 (一次 HTTP 拉多只), 大幅减少
98
168
  逐只 HTTP 开销 (300 只从 ~27s 降到 ~1s). 失败时返回 {} 让调用方回退逐只.
169
+
170
+ subscribe 语义同 _fetch_bridge_history (paper=False, broker_live=True).
99
171
  """
100
172
  from urllib.parse import urlencode
101
173
  from urllib.request import urlopen, Request
@@ -107,10 +179,11 @@ def _fetch_bridge_history_batch(bridge_url: str, symbols: list[str], period: str
107
179
  if c.lower().startswith(("sh", "sz")):
108
180
  c = c[2:]
109
181
  qmt_codes.append(c)
110
- q = urlencode({
111
- "securities": ",".join(qmt_codes),
112
- "period": period, "count": str(count), "fq": "None", "multi": "true",
113
- })
182
+ params = {"securities": ",".join(qmt_codes),
183
+ "period": period, "count": str(count), "fq": "qfq", "multi": "true"}
184
+ if subscribe:
185
+ params["subscribe"] = "true"
186
+ q = urlencode(params)
114
187
  url = f"{bridge_url.rstrip('/')}/data/history?{q}"
115
188
  try:
116
189
  with urlopen(Request(url), timeout=timeout) as resp:
@@ -140,7 +213,8 @@ _BATCH_CHUNK_SIZE = 50
140
213
  def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d",
141
214
  count: int = 30, timeout: float = 45.0,
142
215
  chunk_size: int = _BATCH_CHUNK_SIZE,
143
- max_workers: int | None = None) -> dict[str, Any]:
216
+ max_workers: int | None = None,
217
+ subscribe: bool = False) -> dict[str, Any]:
144
218
  """实盘 startup: 拉 N sym × N bar 历史, 拼成 {sym: DataFrame} 喂 compute_factors.
145
219
 
146
220
  实盘只支持日线 (1d) — QMT 5m/1m/tick 走 market_broker=qmt_market 的实时 tick,
@@ -151,6 +225,9 @@ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d"
151
225
  2. 每片用批量 multi 接口 (一次 HTTP 拉 ≤50 只)
152
226
  3. 多片 ThreadPoolExecutor 并发执行 (默认 min(32, 片数))
153
227
  4. 单只失败的片回退逐只 (兼容旧 bridge_server)
228
+
229
+ subscribe 透传给 _fetch_bridge_history / _fetch_bridge_history_batch —
230
+ paper=False, broker_live=True (run_live 内 mode→subscribe 映射).
154
231
  """
155
232
  import pandas as _pd
156
233
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -173,7 +250,8 @@ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d"
173
250
  def _fetch_chunk(chunk: list[str]) -> dict[str, _pd.DataFrame]:
174
251
  """拉一片: 批量优先, 失败逐只. 批量返回的裸码 key 会归一化到标准 symbol."""
175
252
  out: dict[str, _pd.DataFrame] = {}
176
- batch = _fetch_bridge_history_batch(bridge_url, chunk, period, count, timeout=timeout)
253
+ batch = _fetch_bridge_history_batch(bridge_url, chunk, period, count,
254
+ timeout=timeout, subscribe=subscribe)
177
255
  if batch:
178
256
  # 归一化批量返回的 key (裸码 → 标准), 并只保留本片请求的
179
257
  wanted = set(chunk)
@@ -186,7 +264,8 @@ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d"
186
264
  else:
187
265
  missing = chunk
188
266
  for sym in missing:
189
- bars = _fetch_bridge_history(bridge_url, sym, period, count, timeout=timeout)
267
+ bars = _fetch_bridge_history(bridge_url, sym, period, count,
268
+ timeout=timeout, subscribe=subscribe)
190
269
  out[sym] = _pd.DataFrame(bars) if bars else _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
191
270
  return out
192
271
 
@@ -205,7 +284,8 @@ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d"
205
284
  chunk = futures[fut]
206
285
  for sym in chunk:
207
286
  try:
208
- bars = _fetch_bridge_history(bridge_url, sym, period, count, timeout=timeout)
287
+ bars = _fetch_bridge_history(bridge_url, sym, period, count,
288
+ timeout=timeout, subscribe=subscribe)
209
289
  factors[sym] = _pd.DataFrame(bars) if bars else _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
210
290
  except Exception:
211
291
  factors[sym] = _pd.DataFrame(columns=["open", "high", "low", "close", "volume", "amount"])
@@ -354,11 +434,19 @@ def _with_live_history_depth(
354
434
  def on_start(self) -> _Any:
355
435
  ret_val = super().on_start()
356
436
  try:
357
- for day in fridays:
437
+ # Round 25+ 修复: calendar 是 bridge 历史 (count=30 默认, 全过去日期) —
438
+ # 若不过滤 today, akquant scheduler 启动期会一次性回放所有过去 schedule,
439
+ # 导致 on_timer FIRED 在启动瞬间触发 (实测 b95upfgju 12:13:55 rebalance),
440
+ # 错把启动当 14:55 调仓时机. 过滤 day >= today 让 wrapper 只注未来/今日.
441
+ today_str = _dt.date.today().strftime("%Y-%m-%d")
442
+ future_fridays = [d for d in fridays if d >= today_str]
443
+ for day in future_fridays:
358
444
  y, m, d = day.split("-")
359
445
  _schedule(self, _dt.datetime(int(y), int(m), int(d), 14, 55), "rebalance")
360
446
  print(
361
- f"[info] 周五 14:55 重排定时器注入: {len(fridays)} 个 ({fridays})",
447
+ f"[info] 周五 14:55 重排定时器注入: {len(future_fridays)} 个 "
448
+ f"(过去 {len(fridays) - len(future_fridays)} 个已跳过, "
449
+ f"未来 {future_fridays})",
362
450
  flush=True,
363
451
  )
364
452
  except Exception as e: # noqa: BLE001 — 定时器失败不应让 on_start 崩
@@ -561,6 +649,10 @@ def run_live(
561
649
  """
562
650
  gateway_options = parse_kv_pairs(gateway_options_raw)
563
651
  duration = _live_safety_defaults(mode, broker, gateway_options, duration)
652
+ # Round 19+2 — market_period 自动从策略 params 推断 (CLI 显式覆盖最高优先级).
653
+ resolved_mp = _resolve_market_period(spec, gateway_options, params)
654
+ if resolved_mp is not None:
655
+ gateway_options["market_period"] = resolved_mp
564
656
  # replay broker 的 bars 数据 — 供 v2 architecture compute_factors 复用
565
657
  replay_bars_df = None
566
658
  # 真实交易日历 (YYYY-MM-DD, 从 bars_df / bridge 历史提取) — 注入 strategy wrapper
@@ -664,6 +756,38 @@ def run_live(
664
756
  "如不需要预计算因子, 走 functional mode 兼容路径."
665
757
  )
666
758
 
759
+ # Round 25+ 修复: 交易日历 (周五 14:55 重排定时器) 必须在 wants_precompute
760
+ # 分支外独立拉 — 策略层 schedule_daily("15:00", "trigger") 周末也会触发,
761
+ # 真周五重排需要从 bridge 真实历史抽交易日历 (跳过周末/节假日). 之前逻辑把
762
+ # _fetch_live_factors 放在 wants_precompute else 分支内, 无 compute_factors
763
+ # 策略直接跳过 → calendar=[] → fridays=[] → 0 个周五百排定时器 (实测 bn9d2aa9u).
764
+ rebalance_calendar: list[str] | None = None
765
+ raw_factors: dict[str, Any] = {} # 兜底初值: 下面 if 不走时空 dict
766
+ if qmt_base_url or has_replay_data:
767
+ try:
768
+ if has_replay_data:
769
+ raw_factors = replay_bars_df
770
+ else:
771
+ # paper=False / broker_live=True — paper 不需要实时订阅, broker_live
772
+ # 必须 True 才能让 QMT 实时推新 bar (避免 count=-1 + 空时间窗只返
773
+ # 本地最新 1 条 snapshot 的问题). qmt_market.go live_mode 同语义.
774
+ raw_factors = _fetch_live_factors(qmt_base_url, symbols,
775
+ subscribe=(mode == "broker_live"))
776
+ rebalance_calendar = _calendar_from_df(raw_factors, symbols)
777
+ if rebalance_calendar:
778
+ print(
779
+ f"[info] 交易日历注入: {len(rebalance_calendar)} days, "
780
+ f"{len(_friday_date_strings(rebalance_calendar))} 周五 → 14:55 重排定时器",
781
+ flush=True,
782
+ )
783
+ except LiveRunError:
784
+ raise
785
+ except Exception as e:
786
+ raise LiveRunError(
787
+ f"实盘拉历史失败 (rebalance_calendar): {type(e).__name__}: {e} "
788
+ f"(用户原话: 出错直接报错停止, 不走 mock 兜底)"
789
+ ) from e
790
+
667
791
  if not wants_precompute:
668
792
  print(
669
793
  f"[info] class mode 无 compute_factors/filter_symbols — 跳过预计算, "
@@ -675,25 +799,7 @@ def run_live(
675
799
  if hasattr(strat_inst, "compute_factors"):
676
800
  # 实盘 startup: 拉 N sym × N bar 历史, 拼 {sym: DataFrame} 喂 compute_factors
677
801
  # qmt: bridge /data/history; replay: 本地 prebuilt bars (paper trading).
678
- try:
679
- if has_replay_data:
680
- raw_factors = replay_bars_df
681
- else:
682
- raw_factors = _fetch_live_factors(qmt_base_url, symbols)
683
- rebalance_calendar = _calendar_from_df(raw_factors, symbols)
684
- if rebalance_calendar:
685
- print(
686
- f"[info] 交易日历注入: {len(rebalance_calendar)} days, "
687
- f"{len(_friday_date_strings(rebalance_calendar))} 周五 → 14:55 重排定时器",
688
- flush=True,
689
- )
690
- except LiveRunError:
691
- raise
692
- except Exception as e:
693
- raise LiveRunError(
694
- f"实盘拉历史失败: {type(e).__name__}: {e} "
695
- f"(用户原话: 出错直接报错停止, 不走 mock 兜底)"
696
- ) from e
802
+ # raw_factors / rebalance_calendar 已在上面分支统一拉, 此处复用.
697
803
  try:
698
804
  factors = strat_inst.compute_factors(raw_factors) or {}
699
805
  except Exception as e:
@@ -791,45 +897,4 @@ def run_live(
791
897
  f"market_broker={market_broker or '<single>'} "
792
898
  f"symbols={symbols or '<all>'} duration={duration}", flush=True)
793
899
  print(f"[info] gateway_options={gateway_options}", flush=True)
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)
900
+ _akquant_run_live(**kwargs)
@@ -72,25 +72,20 @@ 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, replay_metrics: dict | None = None, version: str | None = None) -> dict:
76
- """PUT /api/v1/strategies/{id}/result → 上传 BacktestResult-shape dict + 可选 replay_metrics + version.
75
+ def upload_backtest_result(strategy_id: str, result: dict, version: str | None = None) -> dict:
76
+ """PUT /api/v1/strategies/{id}/result → 上传 BacktestResult-shape dict + version.
77
77
 
78
78
  兼容 akquant_runner.run_akquant_backtest() 输出 (13 顶层 key + metrics 子 dict 15 key + ...),
79
79
  先做轻清洗: NaN/Inf → None (json 标准不允许), 后传给 server.
80
80
 
81
- Round 18.62 P0-1: replay_metrics (Sharpe/TotalReturn/WinRate/Trades/DiffPct/RunAt) —
82
- 跟 result 平级, server handler 二次校验 diff 防绕过. None 时不传该 key (老调用方不破).
83
81
  Round 18.62 P1-1: version (X.Y.Z semver) — cli 默认 patch 自动 bump (cmd_commit step 1.4
84
82
  拿 server latest_version 算新 version), server handler 二次校验 semver 格式 + 严格递增.
85
83
  老调用方 (无 version kwarg) → 不传该 key → server 422 (P1-1 强校验).
84
+ Round 18.63: replay_metrics / P0-1 gate 移除 (用户决策) — 上传不再需要 replay.
86
85
  """
87
86
  payload = _sanitize_for_json(result)
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 已知问题修复.
87
+ # 无条件 wrap `{"result": ...}` 让 server 解析形状一致.
91
88
  body: dict = {"result": payload}
92
- if replay_metrics is not None:
93
- body["replay_metrics"] = _sanitize_for_json(replay_metrics)
94
89
  if version is not None:
95
90
  body["version"] = version
96
91
  return _api_call('PUT', f'/api/v1/strategies/{strategy_id}/result', body)
@@ -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.12
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.11"
7
+ version = "0.1.12"
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"