hamuna-quant-cli 0.1.23__tar.gz → 0.1.25__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.23 → hamuna_quant_cli-0.1.25}/PKG-INFO +3 -6
  2. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/README.md +2 -5
  3. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/__init__.py +1 -1
  4. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/__main__.py +5 -9
  5. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/live/qmt_broker.py +9 -218
  6. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/live/qmt_market.py +16 -17
  7. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/live/runner.py +52 -141
  8. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli.egg-info/PKG-INFO +3 -6
  9. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/pyproject.toml +1 -1
  10. hamuna_quant_cli-0.1.25/tests/test_live_safety.py +42 -0
  11. hamuna_quant_cli-0.1.23/tests/test_live_safety.py +0 -52
  12. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/MANIFEST.in +0 -0
  13. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/README.md +0 -0
  14. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/_market_fallback.py +0 -0
  15. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/_metrics_15.py +0 -0
  16. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
  17. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
  18. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/akquant_runner.py +0 -0
  19. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/akquant_schema_adapter.py +0 -0
  20. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/base_strategy.py +0 -0
  21. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
  22. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/live/__init__.py +0 -0
  23. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/live/loader.py +0 -0
  24. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/live/server_market_client.py +0 -0
  25. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
  26. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
  27. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/qmt_translator.py +0 -0
  28. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/runtime/__init__.py +0 -0
  29. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/runtime/backtest.py +0 -0
  30. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/runtime/cache.py +0 -0
  31. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/runtime/discipline.py +0 -0
  32. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/runtime/http_client.py +0 -0
  33. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/runtime/s3client.py +0 -0
  34. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/runtime/server_client.py +0 -0
  35. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli/scripts/server.json +0 -0
  36. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli.egg-info/SOURCES.txt +0 -0
  37. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
  38. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
  39. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli.egg-info/requires.txt +0 -0
  40. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
  41. {hamuna_quant_cli-0.1.23 → hamuna_quant_cli-0.1.25}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hamuna-quant-cli
3
- Version: 0.1.23
3
+ Version: 0.1.25
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
@@ -130,11 +130,8 @@ hamuna_quant_cli upload --name "v2_smoke" --config /tmp/c.json --code /tmp/s.py
130
130
  ```bash
131
131
  # 1) 写策略 (同上)
132
132
 
133
- # 2) paper 模式 (smoke, 不连真实 broker)
134
- hamuna_quant_cli live run /tmp/s.py \
135
- --mode paper --broker replay --symbols sh600000,sz600036 --duration 30s
136
-
137
- # 3) qmt 实盘 (需配 broker=qmt + market_broker=qmt_market + gateway_options)
133
+ # 2) qmt 实盘 (需配 broker=qmt + market_broker=qmt_market + gateway_options)
134
+ # Round 18.63.18: paper 模式已删 — 模拟由 QMT 客户端原生「模拟信号模式」承担
138
135
  hamuna_quant_cli live run /tmp/s.py \
139
136
  --mode broker_live --broker qmt --market-broker qmt_market \
140
137
  --symbols sh600000,sz600036 \
@@ -91,11 +91,8 @@ hamuna_quant_cli upload --name "v2_smoke" --config /tmp/c.json --code /tmp/s.py
91
91
  ```bash
92
92
  # 1) 写策略 (同上)
93
93
 
94
- # 2) paper 模式 (smoke, 不连真实 broker)
95
- hamuna_quant_cli live run /tmp/s.py \
96
- --mode paper --broker replay --symbols sh600000,sz600036 --duration 30s
97
-
98
- # 3) qmt 实盘 (需配 broker=qmt + market_broker=qmt_market + gateway_options)
94
+ # 2) qmt 实盘 (需配 broker=qmt + market_broker=qmt_market + gateway_options)
95
+ # Round 18.63.18: paper 模式已删 — 模拟由 QMT 客户端原生「模拟信号模式」承担
99
96
  hamuna_quant_cli live run /tmp/s.py \
100
97
  --mode broker_live --broker qmt --market-broker qmt_market \
101
98
  --symbols sh600000,sz600036 \
@@ -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.23"
15
+ __version__ = "0.1.25"
@@ -9,7 +9,7 @@ Usage:
9
9
  hamuna_quant_cli qmt-translate <strategy.py> <cfg.json>
10
10
  hamuna_quant_cli commit <strategy_id> --strategy ... --result ... --config ... --params ...
11
11
  hamuna_quant_cli dataset {list|fetch|manifest}
12
- hamuna_quant_cli live run <strategy.py> --mode paper --broker qmt ...
12
+ hamuna_quant_cli live run <strategy.py> --mode broker_live --broker qmt ...
13
13
 
14
14
  PYTHONPATH: 不需要 (pip install hamuna-quant-cli 后全局可用).
15
15
  """
@@ -909,7 +909,6 @@ def cmd_live_run(args) -> int:
909
909
  log_level=args.log_level,
910
910
  log_file=args.log_file,
911
911
  market_broker=args.market_broker,
912
- replay_days=args.replay_days,
913
912
  params=sp if isinstance(sp, dict) else None,
914
913
  )
915
914
  except LiveRunError as e:
@@ -1048,10 +1047,10 @@ def build_parser() -> argparse.ArgumentParser:
1048
1047
  live_sub = live_p.add_subparsers(dest="subcmd", required=True)
1049
1048
  run_p = live_sub.add_parser("run", help="运行一个 strategy.py")
1050
1049
  run_p.add_argument("strategy", type=Path, help="strategy.py 路径")
1051
- run_p.add_argument("--mode", choices=["paper", "broker_live"], default="paper",
1052
- help="trading_mode (default: paper)")
1053
- run_p.add_argument("--broker", default="replay",
1054
- help="broker 名称: ctp / qmf / replay / qmt (default: replay)")
1050
+ run_p.add_argument("--mode", choices=["broker_live"], default="broker_live",
1051
+ help="trading_mode (Round 18.63.18: 仅 broker_live, paper 已删)")
1052
+ run_p.add_argument("--broker", default="qmt",
1053
+ help="broker 名称: qmt / 自定义 (replay 已删)")
1055
1054
  run_p.add_argument("--market-broker", default=None,
1056
1055
  help="独立行情 broker (e.g. qmt_market)")
1057
1056
  run_p.add_argument("--symbols", default=None,
@@ -1064,9 +1063,6 @@ def build_parser() -> argparse.ArgumentParser:
1064
1063
  help="strategy.py 里的类名")
1065
1064
  run_p.add_argument("--gateway-options", default=None,
1066
1065
  help="k=v 字典, 透传给 run_live")
1067
- run_p.add_argument("--replay-days", type=int, default=30,
1068
- help="broker=replay 时取最近 N 天真实日线 (默认 30; "
1069
- "策略 warmup 超窗口需加大, e.g. 90)")
1070
1066
  run_p.add_argument("--initial-cash", type=float, default=None)
1071
1067
  run_p.add_argument("--log-level", default="INFO",
1072
1068
  choices=["DEBUG", "INFO", "WARNING", "ERROR"])
@@ -84,11 +84,6 @@ class BrokerHTTPError(RuntimeError):
84
84
  """bridge_server 返回 ok=False 或 HTTP 状态非 2xx."""
85
85
 
86
86
 
87
- def _emit_paper(msg: str) -> None:
88
- """paper 模式日志 — flush=True 保证 akquant 拉日志实时看到, 不被缓冲."""
89
- print(f"[PAPER] {msg}", flush=True)
90
-
91
-
92
87
  class _HTTP:
93
88
  def __init__(self, base_url: str, timeout: float = 30.0) -> None: # ponytail: 默认 30s 容忍 TCP connect 偶发超时 — ceiling = qmt_market.poll_interval (30s), 再高就盖过 poll cycle; Retry 自动重试已覆盖偶发抖动 (urllib3.Retry, 见 _RETRY_*)
94
89
  self.base_url = base_url.rstrip("/")
@@ -267,12 +262,9 @@ def _make_trader_gateway():
267
262
  account_id: str = "",
268
263
  account_type: str = "stock",
269
264
  timeout: float = 5.0,
270
- paper: bool = True,
271
265
  ) -> None:
272
- """
273
- paper: 默认 True — 安全默认, paper 模式下 place_order / cancel_order
274
- 不发真 HTTP, 走本地 mock. 显式真下单必须 paper=False.
275
- """
266
+ """Round 18.63.18: paper 参数已删 — QMT 客户端原生模拟信号模式,
267
+ CLI 永远发真 passorder (模拟由 QMT GUI 切)."""
276
268
  super().__init__()
277
269
  ak = _import_akquant_gateway()
278
270
  self._BrokerCapability = ak["BrokerCapability"]
@@ -281,13 +273,6 @@ def _make_trader_gateway():
281
273
  self._account_id = account_id
282
274
  self._account_type = account_type
283
275
  self._connected = False
284
- self._paper = paper
285
- self._paper_order_seq = 0 # 仅 paper 模式用, mock 订单号递增
286
- if paper:
287
- _emit_paper(
288
- f"qmt broker paper 模式启动 — account={account_id} "
289
- f"place_order / cancel_order 不发真 HTTP, mock 返 PAPER-NNNNNN"
290
- )
291
276
 
292
277
  # ----- 生命周期 -----
293
278
  def connect(self) -> None:
@@ -352,16 +337,8 @@ def _make_trader_gateway():
352
337
  def place_order(self, req): # type: ignore[override]
353
338
  if not self._connected:
354
339
  raise BrokerHTTPError("place_order: 未 connect, 先调 start()/connect()")
355
- # paper 模式: 不发真 HTTP, mock 返 PAPER-NNNNNN — e2e 验证不真下单
356
- if self._paper:
357
- self._paper_order_seq += 1
358
- order_id = f"PAPER-{self._paper_order_seq:06d}"
359
- self.record_broker_order(order_id, req.client_order_id)
360
- _emit_paper(
361
- f"place_order 跳过真下单: symbol={req.symbol} side={req.side} "
362
- f"qty={req.quantity} price={req.price} → mock order_id={order_id}"
363
- )
364
- return order_id
340
+ # Round 18.63.18: paper mock 路径已删 — QMT 客户端原生模拟信号模式
341
+ # (GUI 手动切), CLI 永远发真 passorder, 模拟由 QMT 侧承担.
365
342
  ak = _import_akquant_gateway()
366
343
  side = str(req.side).upper()
367
344
  # bridge 的 amount 字段 — QMT 用 int, 但 akquant quantity 是 float, 截断到 int.
@@ -417,9 +394,6 @@ def _make_trader_gateway():
417
394
  def cancel_order(self, broker_order_id: str) -> None:
418
395
  if not self._connected:
419
396
  raise BrokerHTTPError("cancel_order: 未 connect")
420
- if self._paper:
421
- _emit_paper(f"cancel_order 跳过真撤单: broker_order_id={broker_order_id}")
422
- return
423
397
  self._http.post("/cancel_order", {
424
398
  "account_id": self._account_id,
425
399
  "account_type": self._account_type,
@@ -644,13 +618,14 @@ def build_qmt(
644
618
  qmt_account_id (必填)
645
619
  qmt_account_type (default "stock")
646
620
  qmt_timeout (default 5.0 秒)
647
- qmt_paper (default "1"=paper; 显式真下单设 "0" — 默认安全)
621
+
622
+ Round 18.63.18 (用户拍板): qmt_paper 已删 — QMT 客户端原生模拟信号模式,
623
+ CLI 永远发真 passorder, 模拟由 QMT GUI 切.
648
624
  """
649
625
  base_url = kwargs.get("qmt_base_url") or kwargs.get("base_url") or "http://127.0.0.1:9000"
650
626
  account_id = kwargs.get("qmt_account_id") or kwargs.get("account_id") or ""
651
627
  account_type = kwargs.get("qmt_account_type") or kwargs.get("account_type") or "stock"
652
628
  timeout = float(kwargs.get("qmt_timeout") or kwargs.get("timeout") or 30.0) # ponytail: 跟 _HTTP.timeout 默认 30s 保持一致 (caller 显式传 timeout=5.0 会盖过这里默认, 但 build_qmt 不传 timeout 时必须 30s 才治网络抖动)
653
- paper = _qmt_paper_default(kwargs.get("qmt_paper"))
654
629
  if not account_id:
655
630
  raise ValueError(
656
631
  "hamuna_qmt_broker: 缺少 qmt_account_id — 透传给 run_live(..., qmt_account_id='...')"
@@ -659,27 +634,16 @@ def build_qmt(
659
634
  QmtTraderGateway = _make_trader_gateway()
660
635
  trader = QmtTraderGateway(
661
636
  base_url=base_url, account_id=account_id, account_type=account_type,
662
- timeout=timeout, paper=paper,
637
+ timeout=timeout,
663
638
  )
664
639
  return ak["GatewayBundle"](
665
640
  market_gateway=None, # 行情走 akquant DataFeed (无 push)
666
641
  trader_gateway=trader,
667
642
  trader_capabilities=trader.get_capabilities(),
668
- metadata={"broker": "qmt", "bridge": "bullettrade_compat", "paper": paper},
643
+ metadata={"broker": "qmt", "bridge": "bullettrade_compat"},
669
644
  )
670
645
 
671
646
 
672
- def _qmt_paper_default(v: Any) -> bool:
673
- """CLI 字符串 → paper bool. 默认 True (安全).
674
- 显式 "0"/"false"/"no"/"off" → False (真下单); 其它 (含 None/""/"1"/"true") → True (paper)."""
675
- if v is None or v == "":
676
- return True
677
- s = str(v).strip().lower()
678
- if s in ("0", "false", "no", "off"):
679
- return False
680
- return True
681
-
682
-
683
647
  # 注册 (side-effect import). akquant 未装时不抛 — 让 --help 仍能跑.
684
648
  def _safe_register() -> None:
685
649
  try:
@@ -690,176 +654,3 @@ def _safe_register() -> None:
690
654
 
691
655
 
692
656
  _safe_register()
693
-
694
-
695
- # ============================================================
696
- # self-check — 不依赖 akquant, 起本地 mock bridge_server, 验证 HTTP shape
697
- # ============================================================
698
- if __name__ == "__main__":
699
- import threading
700
- from http.server import BaseHTTPRequestHandler, HTTPServer
701
-
702
- class _MockBridge(BaseHTTPRequestHandler):
703
- def log_message(self, fmt, *args):
704
- pass # 静音
705
-
706
- def _read_json(self):
707
- length = int(self.headers.get("Content-Length") or 0)
708
- return json.loads(self.rfile.read(length).decode("utf-8")) if length else {}
709
-
710
- def _send(self, code, payload):
711
- body = json.dumps(payload).encode("utf-8")
712
- self.send_response(code)
713
- self.send_header("Content-Type", "application/json")
714
- self.send_header("Content-Length", str(len(body)))
715
- self.end_headers()
716
- self.wfile.write(body)
717
-
718
- def do_GET(self):
719
- if self.path.startswith("/health"):
720
- self._send(200, {"ok": True, "value": {"status": "ok"}})
721
- return
722
- if self.path.startswith("/orders"):
723
- self._send(200, {"ok": True, "value": {"orders": [{
724
- "order_id": "ORD1", "security": "600000.SH", "raw_status": 6,
725
- "filled": 100, "price": 10.5, "amount": 100,
726
- "qmt_user_order_id": "coid1",
727
- }]}})
728
- return
729
- if self.path.startswith("/trades"):
730
- self._send(200, {"ok": True, "value": {"trades": [{
731
- "trade_id": "TRD1", "order_id": "ORD1", "security": "600000.SH",
732
- "amount": 100, "price": 10.5, "side": "BUY",
733
- "qmt_user_order_id": "coid1",
734
- }]}})
735
- return
736
- if self.path.startswith("/flaky"):
737
- # transient retry 验证: 前 _RETRY_TOTAL 次返 503, 之后 200.
738
- # 计数器放 server (handler 每请求新实例, 放 self 会每次清零 → 永不 200)
739
- n = getattr(self.server, "_flaky_n", 0)
740
- if n < _RETRY_TOTAL:
741
- self.server._flaky_n = n + 1
742
- self._send(503, {"ok": False, "code": "TRY_AGAIN"})
743
- return
744
- self._send(200, {"ok": True, "value": {"attempts": n + 1}})
745
- return
746
- self._send(404, {"ok": False, "code": "NOT_FOUND"})
747
-
748
- def do_POST(self):
749
- data = self._read_json()
750
- if "/set_account" in self.path:
751
- # Round 18.63.12: connect() 先 POST /set_account 设全局账户 —
752
- # mock 同步返回 ok, 否则 paper 模式 connect 抛异常.
753
- self._send(200, {"ok": True, "value": {
754
- "account_id": data.get("account_id"),
755
- "account_type": data.get("account_type") or "stock",
756
- }})
757
- return
758
- if "/account" in self.path:
759
- self._send(200, {"ok": True, "value": {
760
- "account_id": data.get("account_id"), "account_type": "stock",
761
- "available_cash": 50000.0, "cash": 100000.0, "total_value": 150000.0,
762
- }})
763
- return
764
- if "/positions" in self.path:
765
- self._send(200, {"ok": True, "value": {"positions": [{
766
- "security": "600000.SH", "amount": 100, "closeable_amount": 100,
767
- "avg_cost": 10.0,
768
- }]}})
769
- return
770
- if "/place_order" in self.path:
771
- self._send(200, {"ok": True, "value": {
772
- "order_id": "ORD2", "order_ref": "ORD2", "security": "600000.SH",
773
- "side": data.get("side"), "amount": data.get("amount"),
774
- "price": data.get("price"),
775
- }})
776
- return
777
- if "/cancel_order" in self.path:
778
- self._send(200, {"ok": True, "value": {"cancelled": True}})
779
- return
780
- self._send(404, {"ok": False, "code": "NOT_FOUND"})
781
-
782
- server = HTTPServer(("127.0.0.1", 0), _MockBridge)
783
- port = server.server_address[1]
784
- threading.Thread(target=server.serve_forever, daemon=True).start()
785
- print(f"[self-check] mock bridge_server at http://127.0.0.1:{port}")
786
-
787
- # 1. symbol 映射 (无 akquant 依赖)
788
- assert _to_qmt_symbol("sh600000") == "600000.SH"
789
- assert _to_qmt_symbol("600000.XSHG") == "600000.SH"
790
- assert _to_qmt_symbol("sz000001") == "000001.SZ"
791
- assert _to_qmt_symbol("000001.XSHE") == "000001.SZ"
792
- assert _from_qmt_symbol("600000.XSHG") == "600000.SH"
793
- assert _from_qmt_symbol("600000.SH") == "600000.SH"
794
- assert _from_qmt_symbol("000001.XSHE") == "000001.SZ"
795
- assert _from_qmt_symbol("000001.SZ") == "000001.SZ"
796
- print("[self-check] OK symbol 映射")
797
-
798
- # 2. HTTP client — 6 个 endpoint shape 验证
799
- http = _HTTP(f"http://127.0.0.1:{port}", timeout=2.0)
800
- acct = http.post("/account", {"account_id": "ACCT", "account_type": "stock"})
801
- assert acct["available_cash"] == 50000.0, acct
802
- pos = http.post("/positions", {"account_id": "ACCT"})
803
- assert pos["positions"][0]["security"] == "600000.SH", pos
804
- order = http.post("/place_order", {
805
- "account_id": "ACCT", "security": "600000.SH", "side": "BUY",
806
- "amount": 100, "price": 10.0,
807
- })
808
- assert order["order_id"] == "ORD2", order
809
- http.post("/cancel_order", {"account_id": "ACCT", "order_id": "ORD2"})
810
- orders = http.get("/orders", {"account_id": "ACCT", "order_id": "ORD1"})
811
- assert orders["orders"][0]["raw_status"] == 6
812
- trades = http.get("/trades", {"account_id": "ACCT"})
813
- assert trades["trades"][0]["trade_id"] == "TRD1"
814
- print("[self-check] OK HTTP client shape")
815
-
816
- # 2.5 retry — /flaky 前 pending 次 503, Retry 自动重试后 200 (返回 attempts=total+1)
817
- flaky = http.get("/flaky", {"account_id": "ACCT"})
818
- assert isinstance(flaky, dict) and flaky.get("attempts") == _RETRY_TOTAL + 1, flaky
819
- print("[self-check] OK transient retry (503 自动重试后成功)")
820
-
821
- # 3. akquant 协议 — 检查 TraderGatewayBase 子类化 + 方法齐
822
- try:
823
- ak = _import_akquant_gateway()
824
- except ImportError as e:
825
- print(f"[self-check] akquant 未装 ({e.__cause__ or e}) — 跳过协议层验证")
826
- else:
827
- QmtTraderGateway = _make_trader_gateway()
828
- # 实例化 (不连, 只查 shape). paper=False 让 e2e 走真 HTTP place_order 路径.
829
- gw = QmtTraderGateway(
830
- base_url=f"http://127.0.0.1:{port}", account_id="ACCT", account_type="stock",
831
- timeout=2.0, paper=False,
832
- )
833
- for name in (
834
- "connect", "disconnect", "start", "place_order", "cancel_order",
835
- "query_order", "query_trades", "query_account", "query_positions",
836
- "get_capabilities", "heartbeat", "on_order", "on_trade",
837
- "on_execution_report", "record_broker_order", "client_order_id_for",
838
- ):
839
- assert hasattr(gw, name), f"missing {name}"
840
- cap = gw.get_capabilities()
841
- assert cap.broker_name == "qmt"
842
- assert "qmt_bridge" in cap.features
843
- print("[self-check] OK akquant 协议点齐 (TraderGatewayBase 子类)")
844
- # 4. e2e: 真实 connect / place / cancel / query
845
- gw.connect()
846
- a = gw.query_account()
847
- assert a.account_id == "ACCT" and a.available_cash == 50000.0, a
848
- ps = gw.query_positions()
849
- assert len(ps) == 1 and ps[0].symbol == "600000.SH", ps
850
- oid = gw.place_order(ak["UnifiedOrderRequest"](
851
- client_order_id="coid-test", symbol="600000.XSHG", side="buy",
852
- quantity=100, price=10.0, order_type="Limit",
853
- ))
854
- assert oid == "ORD2", oid
855
- gw.cancel_order(oid)
856
- snap = gw.query_order("ORD1")
857
- # raw_status=6 → Filled, 但 query_order 不一定拿到 coid 反查 (mock 返回 ORD1 没在 record 里)
858
- # 测试 record_broker_order + 反查
859
- gw.record_broker_order("ORD1", "coid-test")
860
- snap2 = gw.query_order("ORD1")
861
- assert snap2 is not None and snap2.symbol == "600000.SH", snap2
862
- print("[self-check] OK e2e connect→place→cancel→query")
863
-
864
- server.shutdown()
865
- print("[self-check] ALL PASSED")
@@ -9,7 +9,7 @@
9
9
  import hamuna_qmt_market # noqa: F401
10
10
  run_live(market_broker="qmt_market", trader_broker="qmt", ...,
11
11
  qmt_base_url="http://127.0.0.1:9000",
12
- qmt_account_id="8888888888", qmt_paper=1)
12
+ qmt_account_id="8888888888")
13
13
 
14
14
  bridge_server 端点:
15
15
  GET /data/snapshot?securities=<sym1>,<sym2>,... → 全推实时 tick (QMT get_full_tick)
@@ -29,7 +29,7 @@ akquant MarketGateway 接口:
29
29
  "今日累计" bar 持续更新直到收盘 — QMT 默认行为, 适合日线择时).
30
30
 
31
31
  Round 33 双源验证 (server 数据源):
32
- dual_source="off" : 单源 QMT (默认, paper 模式).
32
+ dual_source="off" : 单源 QMT (默认).
33
33
  dual_source="verify": 双源 QMT + server 并行拉, 不一致 server 为主 + log warn.
34
34
  broker_live 默认开 (runner.py _live_safety_defaults).
35
35
  dual_source="fallback": QMT 主, server 备; server 仅在 QMT 失败时启用.
@@ -40,8 +40,6 @@ Round 33 双源验证 (server 数据源):
40
40
  设计原则:
41
41
  - thin client: 翻译 + HTTP, 不存业务逻辑
42
42
  - 跟 broker 同 _HTTP / register / factory 套路 (requests.Session + Retry transient 自动重试)
43
- - paper 模式: 不下单, 行情全真 (snapshot/history 都是只读) — paper e2e 仍能验
44
- 真桥 7 个端点 (5 交易 + 2 行情)
45
43
  """
46
44
  from __future__ import annotations
47
45
 
@@ -50,7 +48,7 @@ __version__ = "0.1.0"
50
48
  # ============================================================
51
49
  # 共享 HTTP client — 跟 broker 模块同一份, 不重复实现
52
50
  # ============================================================
53
- from .qmt_broker import _HTTP, BrokerHTTPError, _emit_paper # noqa: F401 re-used
51
+ from .qmt_broker import _HTTP, BrokerHTTPError # noqa: F401 re-used
54
52
  from .server_market_client import ServerMarketClient, OneMinuteAggregator
55
53
 
56
54
  import threading
@@ -86,7 +84,7 @@ def _make_market_gateway() -> Any:
86
84
  poll_interval: float = 30.0, # 默认 30s/帧 (≈2/min) — 用户决策: 不打 QMT 限流; 想更快自己 CLI 透传
87
85
  timeout: float = 5.0,
88
86
  symbols: list[str] | None = None, # 启动时 auto-subscribe (来自 build_qmt_market 的 symbols)
89
- subscribe: bool = False, # paper=False / broker_live=True — 透传给 /data/history
87
+ subscribe: bool = True, # Round 18.63.18: paper 已删, broker_live 恒 True — 透传给 /data/history
90
88
  # Round 33: server 数据源 + 双源验证
91
89
  server_base_url: str | None = None, # None → 不开 server 源 (单源 QMT)
92
90
  server_api_key: str | None = None, # None → runtime.http_client._load_token() 自动读
@@ -484,9 +482,8 @@ def _make_market_gateway() -> Any:
484
482
  def _fetch_bars(self, symbol: str, count: int | None = None) -> list[dict[str, Any]]:
485
483
  """GET /data/history?security=...&period=1d&count=N[&subscribe=T/F] → list of bar dict (旧→新).
486
484
 
487
- subscribe: paper=False / broker_live=True (round 28+1 决策) — paper
488
- 一次性拉够 N 根即可, broker_live 必须 True 让 QMT 实时推新 bar,
489
- 避免 count=-1 + 空时间窗只返本地最新 1 条 snapshot 的问题.
485
+ subscribe: Round 18.63.18 paper 已删 — broker_live 恒 True 让 QMT
486
+ 实时推新 bar, 避免 count=-1 + 空时间窗只返本地最新 1 条 snapshot 的问题.
490
487
 
491
488
  Round 33 双源 (dual_source="verify"): QMT 历史 bar 为主, server
492
489
  (容维 /StockBars 通过 backend /market/bars) 拉同样窗口. period=1m
@@ -496,8 +493,8 @@ def _make_market_gateway() -> Any:
496
493
  cnt = count if count is not None else self._bar_count
497
494
  params = {"security": qmt_sym, "period": self._period,
498
495
  "count": str(cnt), "fq": "qfq"}
499
- # Round 32: 显式带 subscribe, 不靠 bridge 默认 (已翻 True). paper 轮询纯历史
500
- # → subscribe=false 防误开 QMT 订阅; broker_live → true (实时推新 bar).
496
+ # Round 32: 显式带 subscribe, 不靠 bridge 默认 (已翻 True). broker_live
497
+ # → true (实时推新 bar); 显式 market_subscribe=0 可关 (纯历史拉取).
501
498
  params["subscribe"] = "true" if self._subscribe else "false"
502
499
  resp = self._http.get("/data/history", params)
503
500
  value = _unwrap_envelope(resp)
@@ -784,10 +781,11 @@ def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwar
784
781
  market_bar_count (default 20) — warmup 拉几根
785
782
  market_poll (default 30.0) — 轮询秒数 (用户决策 2026-08-18: ≥30s)
786
783
  market_timeout (default 5.0) — HTTP 超时
787
- market_subscribe (default False) — /data/history subscribe= 参数
788
- (paper=False, broker_live=True)
784
+ market_subscribe (default True) — /data/history subscribe= 参数
785
+ (Round 18.63.18: paper 已删, broker_live
786
+ 恒 True; 显式 "0"/"false" 可关纯历史拉取)
789
787
  透传方式: --gateway-options
790
- "market_subscribe=1" (或 True)
788
+ "market_subscribe=0" (关)
791
789
  server_base_url (default None) — Round 33: server 数据源 base URL
792
790
  (None → 不开 server 源)
793
791
  server_api_key (default None) — Round 33: 显式 API key; None → 自动
@@ -805,9 +803,10 @@ def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwar
805
803
  bar_count = int(kwargs.get("market_bar_count") or kwargs.get("bar_count") or 20)
806
804
  poll = float(kwargs.get("market_poll") or kwargs.get("poll_interval") or 30.0)
807
805
  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 才治网络抖动)
808
- # market_subscribe: CLI 字符串 "1"/"true"/"yes" → True, 其它/缺失 → False
809
- subscribe_raw = kwargs.get("market_subscribe") or kwargs.get("subscribe") or ""
810
- subscribe = str(subscribe_raw).strip().lower() in ("1", "true", "yes", "on")
806
+ # market_subscribe: Round 18.63.18 paper 已删, broker_live 恒 True (实时推新 bar);
807
+ # 显式 "0"/"false"/"no"/"off" 可关闭 (纯历史拉取, 防误开 QMT 订阅).
808
+ subscribe_raw = kwargs.get("market_subscribe") or kwargs.get("subscribe") or "1"
809
+ subscribe = not str(subscribe_raw).strip().lower() in ("0", "false", "no", "off", "")
811
810
  # Round 33 双源 kwargs
812
811
  server_base_url = kwargs.get("server_base_url")
813
812
  server_api_key = kwargs.get("server_api_key")
@@ -9,7 +9,6 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  import sys
12
- import json # Round 18.62 P0-1: replay metrics 落盘
13
12
  from pathlib import Path
14
13
  from typing import Any
15
14
 
@@ -133,9 +132,8 @@ def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
133
132
  bridge 不通 / 超时 / 非 200 直接 raise LiveRunError, 不静默退化 (用户原话:
134
133
  策略出错直接报错停止, 不走 mock 兜底).
135
134
 
136
- subscribe: paper=False / broker_live=True — paper 不需要实时订阅 (一次性拉够
137
- N 根 bar 即可), broker_live 需要开 subscribe=True 让 QMT 实时推新 bar, 避免
138
- count=-1 + start/end 全空时只返回本地最新 1 条 snapshot 的问题.
135
+ subscribe: broker_live 恒 True (Round 18.63.18: paper 已删) — 让 QMT 实时
136
+ 推新 bar, 避免 count=-1 + start/end 全空时只返回本地最新 1 条 snapshot 的问题.
139
137
  """
140
138
  from urllib.parse import urlencode
141
139
  from urllib.request import urlopen, Request
@@ -145,7 +143,7 @@ def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
145
143
  canonical = canonical[2:]
146
144
  params = {"security": canonical, "period": period, "count": str(count), "fq": "qfq"}
147
145
  # Round 32: bridge_server subscribe 默认翻 True (qmt-docs 官方对齐) 后, 这里不能
148
- # 省掉 query 靠默认 — 纯历史/paper 路径必须显式 subscribe=false 防误开 QMT 订阅
146
+ # 省掉 query 靠默认 — 显式带 subscribe 防误开/误关 QMT 订阅
149
147
  # (Round 27 300 只批量防卡护栏从 bridge 默认移到调用端显式 false).
150
148
  params["subscribe"] = "true" if subscribe else "false"
151
149
  q = urlencode(params)
@@ -192,7 +190,7 @@ def _fetch_bridge_history_batch(bridge_url: str, symbols: list[str], period: str
192
190
  优先使用 bridge_server 的批量 multi 接口 (一次 HTTP 拉多只), 大幅减少
193
191
  逐只 HTTP 开销 (300 只从 ~27s 降到 ~1s). 失败时返回 {} 让调用方回退逐只.
194
192
 
195
- subscribe 语义同 _fetch_bridge_history (paper=False, broker_live=True).
193
+ subscribe 语义同 _fetch_bridge_history (broker_live 恒 True, Round 18.63.18).
196
194
  """
197
195
  from urllib.parse import urlencode
198
196
  from urllib.request import urlopen, Request
@@ -252,7 +250,7 @@ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d"
252
250
  4. 单只失败的片回退逐只 (兼容旧 bridge_server)
253
251
 
254
252
  subscribe 透传给 _fetch_bridge_history / _fetch_bridge_history_batch —
255
- paper=False, broker_live=True (run_live 内 mode→subscribe 映射).
253
+ broker_live 恒 True (Round 18.63.18: paper 已删).
256
254
  """
257
255
  import pandas as _pd
258
256
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -317,16 +315,15 @@ def _fetch_live_factors(bridge_url: str, universe: list[str], period: str = "1d"
317
315
  return factors
318
316
 
319
317
 
320
- def _build_instruments(symbols: list[str] | None, normalize: bool = False) -> list[Any] | None:
318
+ def _build_instruments(symbols: list[str] | None) -> list[Any] | None:
321
319
  """symbols 列表 → akquant.Instrument 列表. None → None (全市场, 让策略自行 subscribe).
322
320
 
323
- normalize=True: 裸码 → 带后缀 (replay 数据来自 prebuilt, stockCode 是 '600000.SH'
324
- 形态, instrument symbol 必须一致才匹配 ReplayMarketGateway 的订阅过滤).
325
-
326
321
  Round 18.59: 按 6 位代码前缀推断 AssetType — akquant 0.3.x AssetType 枚举没 ETF 也没 Bond,
327
322
  510/511/512/515/159 (沪/深 ETF) → Fund; 113/127/128 (可转债) → Fund; 其他 6 位 → Stock.
328
323
  之前硬写 AssetType.Stock 让 351 个 ETF 被 broker 内部按 asset_type 分流时丢弃 → universe=[].
329
324
  ponytail: akquant 0.4.x 若新增 AssetType.ETF / AssetType.Bond 则此映射需重审.
325
+
326
+ Round 18.63.18: normalize 参数已删 (曾用于 replay prebuilt 裸码归一, replay 已删).
330
327
  """
331
328
  if not symbols:
332
329
  return None
@@ -336,14 +333,8 @@ def _build_instruments(symbols: list[str] | None, normalize: bool = False) -> li
336
333
  _ETF_PREFIXES = ("510", "511", "512", "515", "159")
337
334
  _BOND_PREFIXES = ("113", "127", "128") # 可转债 (沪 113 / 深 127 / 沪 128)
338
335
 
339
- def _sym(s: str) -> str:
340
- if normalize:
341
- from ..akquant_schema_adapter import normalize_symbol
342
- return normalize_symbol(s)
343
- return s
344
-
345
336
  def _asset_type(bare: str) -> Any:
346
- # _sym 后可能是 6 位裸码或 600000.SH 形态 — 都按前 3 位判
337
+ # 6 位裸码或 600000.SH 形态 — 都按前 3 位判
347
338
  code6 = bare.split(".")[0][-6:] if "." in bare else bare[-6:]
348
339
  if not (len(code6) == 6 and code6.isdigit()):
349
340
  return AssetType.Stock # 非法 6 位 fallback, 不阻塞启动
@@ -354,38 +345,7 @@ def _build_instruments(symbols: list[str] | None, normalize: bool = False) -> li
354
345
  return AssetType.Fund
355
346
  return AssetType.Stock
356
347
 
357
- return [Instrument(symbol=_sym(s), asset_type=_asset_type(_sym(s))) for s in symbols if s.strip()]
358
-
359
-
360
- def _recent_real_bars(symbols: list[str], days: int = 30) -> tuple[Any, str]:
361
- """最近 N 天真实日线 → replay bars DataFrame (akquant dataframe_to_bars 格式).
362
-
363
- 数据源 = 本地 prebuilt bundle (真实历史, 与回测同源; bundle 覆盖到最新交易日).
364
- 返 (df, src): df 列 = date/open/high/low/close/volume/symbol + "股票代码" (多标的
365
- 识别必需, dataframe_to_bars normalize.py 只认这个列名).
366
-
367
- Raises:
368
- LiveRunError: 无 --symbols / 数据不可达 (提示先 dataset fetch).
369
- """
370
- from datetime import datetime, timedelta
371
-
372
- from ..prebuilt_resolver import resolve
373
-
374
- if not symbols:
375
- raise LiveRunError(
376
- "broker=replay 需要 --symbols 限定回放标的 (从本地 prebuilt 取最近真实数据)"
377
- )
378
- end = datetime.now().strftime("%Y%m%d")
379
- start = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d")
380
- df, src = resolve(symbols, start, end)
381
- if df is None or len(df) == 0:
382
- raise LiveRunError(
383
- f"最近 {days} 天无 bar 数据 (universe={symbols}, {start}~{end}, src={src}). "
384
- f"先 `hamuna_quant_cli dataset fetch --symbols {','.join(symbols)}` 下载 prebuilt"
385
- )
386
- out = df.copy()
387
- out["股票代码"] = out["symbol"]
388
- return out, src
348
+ return [Instrument(symbol=s, asset_type=_asset_type(s)) for s in symbols if s.strip()]
389
349
 
390
350
 
391
351
  def configure_logging(level: str, log_file: Path | None) -> None:
@@ -417,7 +377,7 @@ def _with_live_history_depth(
417
377
  wrapper 在 on_start 里按日历注册周五 14:55 的 schedule() 定时器 (payload
418
378
  'rebalance') — 这正是 akquant live 缺的横截面周频触发:live 不注入
419
379
  _trading_days (on_cross_section 静默死) 且 schedule_daily 堕入真实墙钟
420
- (replay 死), 手动 schedule() 绝对 ts 是唯一通路 (2026-08-27 探针实测触发).
380
+ (replay 已删 18.63.18), 手动 schedule() 绝对 ts 是唯一通路 (2026-08-27 探针实测触发).
421
381
 
422
382
  params: 用户实盘参数 (params.json strategy_params) — 作构造 kwargs 注入.
423
383
  live 端 _build_strategy_instance 是 `cls()` 无参实例化, 用户参数经
@@ -562,14 +522,13 @@ def _friday_date_strings(calendar: list[str] | None) -> list[str]:
562
522
  def _calendar_from_df(df: Any, symbols: list[str] | None = None) -> list[str] | None:
563
523
  """从实盘 startup 拉到的历史数据提取真实交易日列表 (YYYY-MM-DD 去重排序).
564
524
 
565
- 兼容两种 raw_factors 形态:
566
- - replay: 单 bars_df (列含 date/时间)
525
+ 形态 (Round 18.63.18: replay 已删, 只剩 bridge):
567
526
  - bridge: {sym: DataFrame} dict (列名 bridge 透传, 可能 date/stime/time/datetime)
568
527
 
569
528
  返回 None 表示无可用日期列 (跳过日历注入). 日期列探测优先级
570
529
  date > datetime > stime > time. bridge /data/history 的 bar 无 date 列,
571
530
  stime 是 'YYYYMMDD' 字符串 (真机实测 2026-08-28), time 是毫秒时间戳 —
572
- time 必须 unit='ms' 解析, 否则 to_datetime 默认 ns 单位得 1970-01-01 垃圾日 (Round 18.23 replay 路径的坑).
531
+ time 必须 unit='ms' 解析, 否则 to_datetime 默认 ns 单位得 1970-01-01 垃圾日 (Round 18.23 的坑).
573
532
  """
574
533
  import datetime as _dt
575
534
  import pandas as _pd
@@ -620,20 +579,21 @@ def _live_safety_defaults(
620
579
  gateway_options: dict[str, str],
621
580
  duration: str,
622
581
  ) -> str:
623
- """P0-1/P0-2 实盘安全默认: broker_live+qmt 强制真实下单 + 永久运行.
582
+ """P0-2 实盘安全默认: broker_live+qmt 永久运行 + dual_source 验证.
624
583
 
625
- 原地改 gateway_options (注入 qmt_paper=0 或报错), 返回修正后的 duration.
584
+ Round 18.63.18 (用户拍板): 去掉 qmt_paper mock 下单路径 — QMT 客户端原生
585
+ 有「模拟信号模式」(GUI 手动切), CLI 永远发真 passorder, 模拟由 QMT 侧承担.
586
+ 返回修正后的 duration.
626
587
  """
627
588
  if mode == "broker_live" and broker == "qmt":
628
- paper_val = gateway_options.get("qmt_paper")
629
- if paper_val is None:
630
- gateway_options["qmt_paper"] = "0"
631
- print("[info] broker_live 未显式 qmt_paper → 强制 0 (真实下单)", flush=True)
632
- elif str(paper_val).strip().lower() in ("1", "true", "yes", "on"):
633
- raise LiveRunError(
634
- "broker_live + qmt_paper=1 矛盾: 真实盘模式不能 paper 下单. "
635
- "要去掉 qmt_paper=1 或改用 --mode paper."
636
- )
589
+ # Round 33: broker_live 默认开 dual_source=verify (QMT + server 端双源验证).
590
+ # server_base_url 不显式传 → qmt_market 里 server=None → 退化为单源 QMT (旧路径).
591
+ # user 想开 server 验证 → --gateway-options "dual_source=verify,server_base_url=http://..."
592
+ # 或 env HAMUNA_SERVER 让 server_market_client 自动读.
593
+ if gateway_options.get("dual_source") is None:
594
+ gateway_options["dual_source"] = "verify"
595
+ print("[info] broker_live 默认 dual_source=verify (QMT+server 双源); "
596
+ "传 dual_source=off 可关闭", flush=True)
637
597
 
638
598
  if duration in ("1h", "0", "0s", "0m", "0h", ""):
639
599
  # Round 18.63.14: "0"/"1h" = 永久运行. 之前返回 "0" 会被 akquant
@@ -644,15 +604,6 @@ def _live_safety_defaults(
644
604
  print("[info] broker_live 默认 duration=0 (永久运行); 如需限时显式传 --duration", flush=True)
645
605
  return ""
646
606
  print(f"[warn] broker_live 使用显式 duration={duration} — 到期自动停止", flush=True)
647
-
648
- # Round 33: broker_live 默认开 dual_source=verify (QMT + server 端双源验证).
649
- # server_base_url 不显式传 → qmt_market 里 server=None → 退化为单源 QMT (旧路径).
650
- # user 想开 server 验证 → --gateway-options "dual_source=verify,server_base_url=http://..."
651
- # 或 env HAMUNA_SERVER 让 server_market_client 自动读.
652
- if gateway_options.get("dual_source") is None:
653
- gateway_options["dual_source"] = "verify"
654
- print("[info] broker_live 默认 dual_source=verify (QMT+server 双源); "
655
- "传 dual_source=off 可关闭", flush=True)
656
607
  return duration
657
608
 
658
609
 
@@ -668,14 +619,13 @@ def run_live(
668
619
  log_level: str,
669
620
  log_file: Path | None,
670
621
  market_broker: str | None,
671
- replay_days: int = 30,
672
622
  params: dict | None = None,
673
623
  ) -> None:
674
624
  """翻译 + 转发到 akquant.run_live.
675
625
 
676
626
  spec: loader 加载出的 StrategySpec (class 或 functional)
677
- mode: "paper" / "broker_live"
678
- broker: "ctp" / "qmf" / "replay" / 自定义 broker 名字
627
+ mode: "broker_live" (Round 18.63.18: paper 已删 — QMT 原生模拟信号模式)
628
+ broker: "qmt" / 自定义 broker 名字 (replay 已删)
679
629
  market_broker: 独立行情 broker id (e.g. "qmt_market"); None=单 broker
680
630
  当 market_broker 设了, akquant 要求 trader_broker 也设 (二者成对, broker 字段被忽略).
681
631
  我们固定配对: market_broker="qmt_market" + trader_broker="qmt".
@@ -692,43 +642,13 @@ def run_live(
692
642
  resolved_mp = _resolve_market_period(spec, gateway_options, params)
693
643
  if resolved_mp is not None:
694
644
  gateway_options["market_period"] = resolved_mp
695
- # replay broker 的 bars 数据 — 供 v2 architecture compute_factors 复用
696
- replay_bars_df = None
697
- # 真实交易日历 (YYYY-MM-DD, 从 bars_df / bridge 历史提取) — 注入 strategy wrapper
645
+ # 真实交易日历 (YYYY-MM-DD, 从 bridge 历史提取) — 注入 strategy wrapper
698
646
  # 做周五 14:55 重排定时器 (Round 18.22: live 无 _trading_days, schedule_daily 墙钟死)
699
647
  rebalance_calendar: list[str] | None = None
700
648
 
701
- # replay broker 必须配 trading_mode="paper" — 提前给出清晰错误, 而不是让 akquant 在深处抛
702
- if broker == "replay" and mode == "broker_live":
703
- raise LiveRunError(
704
- "broker=replay + trading_mode=broker_live 不兼容 — replay 只有行情, "
705
- "无交易通道, 用 broker_live 会抛 ValueError. 改用 mode=paper 或换 broker."
706
- )
707
-
708
- # broker=replay → 自动注入最近 N 天真实数据 (本地 prebuilt), paper 撮合走引擎.
709
- # bounded_event_total 让回放完自动停, 不依赖 --duration 墙钟.
710
- if broker == "replay":
711
- bars_df, replay_src = _recent_real_bars(symbols, days=replay_days)
712
- replay_bars_df = bars_df # 供 v2 architecture compute_factors 复用
713
- instruments = _build_instruments(sorted(bars_df["symbol"].unique()), normalize=True)
714
- gateway_options["bars"] = bars_df
715
- print(
716
- f"[info] replay 数据: {replay_src} · {len(bars_df)} bars · "
717
- f"{sorted(bars_df['symbol'].unique())} (最近 {replay_days} 天)",
718
- flush=True,
719
- )
720
- if len(bars_df) < 60:
721
- print(
722
- f"[warn] bars < 60 — 有 warmup 的策略可能全程不触发 on_bar (0 交易). "
723
- f"可加 --replay-days 扩大窗口 (e.g. --replay-days 180)",
724
- flush=True,
725
- )
726
- else:
727
- instruments = _build_instruments(symbols)
728
- # run_live 始终要求至少 1 个 instrument (不是可选) — 不传 --symbols 时
729
- # 用占位 sh600000, paper 模式 broker=qmt 不真下单, smoke 能跑通.
730
- if not instruments:
731
- instruments = _build_instruments(["sh600000"])
649
+ # Round 18.63.18 (用户拍板): broker=replay 本地回放已删 — QMT 客户端原生
650
+ # 模拟信号模式承担"无真盘验证", CLI live run 只走 broker_live+qmt 真下单.
651
+ instruments = _build_instruments(symbols)
732
652
  configure_logging(log_level, log_file)
733
653
 
734
654
  # broker="qmt" → import 触发 register_broker("qmt", ...), 校验 qmt_account_id 必填.
@@ -771,10 +691,7 @@ def run_live(
771
691
  # functional mode / 无 bridge_url 跳过 (兼容老策略).
772
692
  if spec.mode == "class" and spec.strategy_cls is not None:
773
693
  qmt_base_url = gateway_options.get("qmt_base_url")
774
- # replay (paper trading) 用本地 bars 数据; qmt 用 bridge /data/history.
775
- # 两者都走 compute_factors / filter_symbols (v2 architecture).
776
- has_replay_data = replay_bars_df is not None
777
- if qmt_base_url or has_replay_data:
694
+ if qmt_base_url:
778
695
  try:
779
696
  strat_inst = spec.strategy_cls()
780
697
  except Exception as e:
@@ -802,30 +719,24 @@ def run_live(
802
719
  # 策略直接跳过 → calendar=[] → fridays=[] → 0 个周五百排定时器 (实测 bn9d2aa9u).
803
720
  rebalance_calendar: list[str] | None = None
804
721
  raw_factors: dict[str, Any] = {} # 兜底初值: 下面 if 不走时空 dict
805
- if qmt_base_url or has_replay_data:
806
- try:
807
- if has_replay_data:
808
- raw_factors = replay_bars_df
809
- else:
810
- # paper=False / broker_live=True — paper 不需要实时订阅, broker_live
811
- # 必须 True 才能让 QMT 实时推新 bar (避免 count=-1 + 空时间窗只返
812
- # 本地最新 1 条 snapshot 的问题). qmt_market.go live_mode 同语义.
813
- raw_factors = _fetch_live_factors(qmt_base_url, symbols,
814
- subscribe=(mode == "broker_live"))
815
- rebalance_calendar = _calendar_from_df(raw_factors, symbols)
816
- if rebalance_calendar:
817
- print(
818
- f"[info] 交易日历注入: {len(rebalance_calendar)} days, "
819
- f"{len(_friday_date_strings(rebalance_calendar))} 周五 → 14:55 重排定时器",
820
- flush=True,
821
- )
822
- except LiveRunError:
823
- raise
824
- except Exception as e:
825
- raise LiveRunError(
826
- f"实盘拉历史失败 (rebalance_calendar): {type(e).__name__}: {e} "
827
- f"(用户原话: 出错直接报错停止, 不走 mock 兜底)"
828
- ) from e
722
+ try:
723
+ # broker_live 必须 subscribe=True 才能让 QMT 实时推新 bar
724
+ # (避免 count=-1 + 空时间窗只返本地最新 1 条 snapshot 的问题).
725
+ raw_factors = _fetch_live_factors(qmt_base_url, symbols, subscribe=True)
726
+ rebalance_calendar = _calendar_from_df(raw_factors, symbols)
727
+ if rebalance_calendar:
728
+ print(
729
+ f"[info] 交易日历注入: {len(rebalance_calendar)} days, "
730
+ f"{len(_friday_date_strings(rebalance_calendar))} 周五 → 14:55 重排定时器",
731
+ flush=True,
732
+ )
733
+ except LiveRunError:
734
+ raise
735
+ except Exception as e:
736
+ raise LiveRunError(
737
+ f"实盘拉历史失败 (rebalance_calendar): {type(e).__name__}: {e} "
738
+ f"(用户原话: 出错直接报错停止, 不走 mock 兜底)"
739
+ ) from e
829
740
 
830
741
  if not wants_precompute:
831
742
  print(
@@ -837,7 +748,7 @@ def run_live(
837
748
  factors: dict[str, Any] = {}
838
749
  if hasattr(strat_inst, "compute_factors"):
839
750
  # 实盘 startup: 拉 N sym × N bar 历史, 拼 {sym: DataFrame} 喂 compute_factors
840
- # qmt: bridge /data/history; replay: 本地 prebuilt bars (paper trading).
751
+ # qmt: bridge /data/history (Round 18.63.18: replay 已删).
841
752
  # raw_factors / rebalance_calendar 已在上面分支统一拉, 此处复用.
842
753
  try:
843
754
  factors = strat_inst.compute_factors(raw_factors) or {}
@@ -882,7 +793,7 @@ def run_live(
882
793
  instruments = _build_instruments(filtered)
883
794
  else:
884
795
  print(
885
- "[info] class mode 无 qmt_base_url / replay bars; 跳过 compute_factors / filter_symbols",
796
+ "[info] class mode 无 qmt_base_url; 跳过 compute_factors / filter_symbols",
886
797
  flush=True,
887
798
  )
888
799
  elif spec.mode == "functional":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hamuna-quant-cli
3
- Version: 0.1.23
3
+ Version: 0.1.25
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
@@ -130,11 +130,8 @@ hamuna_quant_cli upload --name "v2_smoke" --config /tmp/c.json --code /tmp/s.py
130
130
  ```bash
131
131
  # 1) 写策略 (同上)
132
132
 
133
- # 2) paper 模式 (smoke, 不连真实 broker)
134
- hamuna_quant_cli live run /tmp/s.py \
135
- --mode paper --broker replay --symbols sh600000,sz600036 --duration 30s
136
-
137
- # 3) qmt 实盘 (需配 broker=qmt + market_broker=qmt_market + gateway_options)
133
+ # 2) qmt 实盘 (需配 broker=qmt + market_broker=qmt_market + gateway_options)
134
+ # Round 18.63.18: paper 模式已删 — 模拟由 QMT 客户端原生「模拟信号模式」承担
138
135
  hamuna_quant_cli live run /tmp/s.py \
139
136
  --mode broker_live --broker qmt --market-broker qmt_market \
140
137
  --symbols sh600000,sz600036 \
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hamuna-quant-cli"
7
- version = "0.1.23"
7
+ version = "0.1.25"
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"
@@ -0,0 +1,42 @@
1
+ """P0-2 实盘安全默认测试 (2026-08; Round 18.63.18 更新).
2
+
3
+ 验证 hamuna_quant_cli.live.runner._live_safety_defaults:
4
+ - broker_live + qmt + duration=1h (CLI 默认) → 改为 "" (永久)
5
+ - broker_live + qmt + 显式 duration=2h → 保留 2h
6
+ - broker_live + qmt → 默认注入 dual_source=verify
7
+ - Round 18.63.18 (用户拍板): qmt_paper 已删 — QMT 客户端原生模拟信号模式,
8
+ CLI 永远发真 passorder. 不再强制/拒绝 qmt_paper, gateway_options 里出现
9
+ 该键也不做任何处理 (build_qmt 忽略未知 kwargs).
10
+ """
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ # 确保能 import 到 hamuna_quant_cli (仓库根)
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ sys.path.insert(0, str(ROOT))
17
+
18
+ from hamuna_quant_cli.live.runner import _live_safety_defaults # noqa: E402
19
+
20
+ def test_broker_live_qmt_default_duration_permanent():
21
+ opts = {}
22
+ duration = _live_safety_defaults("broker_live", "qmt", opts, "1h")
23
+ assert duration == "", "broker_live 默认 duration=1h 应改为 '' (永久运行)"
24
+ assert opts.get("dual_source") == "verify", "broker_live+qmt 默认开双源验证"
25
+
26
+ def test_broker_live_qmt_explicit_duration_preserved():
27
+ opts = {}
28
+ duration = _live_safety_defaults("broker_live", "qmt", opts, "2h")
29
+ assert duration == "2h", "显式 duration 应保留"
30
+
31
+ def test_broker_live_qmt_paper_key_ignored():
32
+ # Round 18.63.18: qmt_paper 概念已删, 出现也不报错 (QMT 原生模拟信号模式)
33
+ opts = {"qmt_paper": "1"}
34
+ duration = _live_safety_defaults("broker_live", "qmt", opts, "1h")
35
+ assert duration == ""
36
+ assert opts.get("dual_source") == "verify"
37
+
38
+ def test_other_mode_untouched():
39
+ opts = {}
40
+ duration = _live_safety_defaults("broker_live", "other", opts, "1h")
41
+ assert opts == {}, "非 qmt broker 不应注入 dual_source"
42
+ assert duration == "1h"
@@ -1,52 +0,0 @@
1
- """P0-1/P0-2 实盘安全默认测试 (2026-08).
2
-
3
- 验证 hamuna_quant_cli.live.runner._live_safety_defaults:
4
- - broker_live + qmt + 未显式 qmt_paper → 强制注入 "0" (真实下单)
5
- - broker_live + qmt + 显式 qmt_paper=1 → LiveRunError
6
- - broker_live + qmt + duration=1h (CLI 默认) → 改为 "0" (永久)
7
- - broker_live + qmt + 显式 duration=2h → 保留 2h
8
- - paper / 非 qmt → 不改 gateway_options, duration 原样
9
- """
10
- import sys
11
- from pathlib import Path
12
-
13
- # 确保能 import 到 hamuna_quant_cli (仓库根)
14
- ROOT = Path(__file__).resolve().parent.parent
15
- sys.path.insert(0, str(ROOT))
16
-
17
- from hamuna_quant_cli.live.runner import LiveRunError, _live_safety_defaults # noqa: E402
18
-
19
-
20
- def test_broker_live_qmt_injects_real_trading():
21
- opts = {}
22
- duration = _live_safety_defaults("broker_live", "qmt", opts, "1h")
23
- assert opts["qmt_paper"] == "0", "broker_live+qmt 必须强制真实下单"
24
- assert duration == "0", "broker_live 默认 duration=1h 应改为 0 (永久)"
25
-
26
-
27
- def test_broker_live_qmt_explicit_paper_raises():
28
- import pytest
29
- opts = {"qmt_paper": "1"}
30
- with pytest.raises(LiveRunError, match="qmt_paper=1"):
31
- _live_safety_defaults("broker_live", "qmt", opts, "1h")
32
-
33
-
34
- def test_broker_live_qmt_explicit_duration_preserved():
35
- opts = {}
36
- duration = _live_safety_defaults("broker_live", "qmt", opts, "2h")
37
- assert opts["qmt_paper"] == "0"
38
- assert duration == "2h", "显式 duration 应保留"
39
-
40
-
41
- def test_paper_mode_untouched():
42
- opts = {}
43
- duration = _live_safety_defaults("paper", "qmt", opts, "1h")
44
- assert opts == {}, "paper 模式不应注入 qmt_paper"
45
- assert duration == "1h"
46
-
47
-
48
- def test_broker_live_non_qmt_untouched():
49
- opts = {}
50
- duration = _live_safety_defaults("broker_live", "replay", opts, "1h")
51
- assert opts == {}, "非 qmt broker 不应注入"
52
- assert duration == "1h"