hamuna-quant-cli 0.1.12__tar.gz → 0.1.14__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 (40) hide show
  1. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/PKG-INFO +1 -1
  2. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/__init__.py +1 -1
  3. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/__main__.py +1 -1
  4. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/live/qmt_market.py +314 -14
  5. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/live/runner.py +15 -4
  6. hamuna_quant_cli-0.1.14/hamuna_quant_cli/live/server_market_client.py +311 -0
  7. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/cache.py +17 -0
  8. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/http_client.py +32 -0
  9. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
  10. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/SOURCES.txt +1 -0
  11. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/pyproject.toml +1 -1
  12. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/MANIFEST.in +0 -0
  13. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/README.md +0 -0
  14. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/README.md +0 -0
  15. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/_market_fallback.py +0 -0
  16. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/_metrics_15.py +0 -0
  17. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
  18. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
  19. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/akquant_runner.py +0 -0
  20. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/akquant_schema_adapter.py +0 -0
  21. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/base_strategy.py +0 -0
  22. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
  23. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/live/__init__.py +0 -0
  24. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/live/loader.py +0 -0
  25. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/live/qmt_broker.py +0 -0
  26. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
  27. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
  28. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/qmt_translator.py +0 -0
  29. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/__init__.py +0 -0
  30. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/backtest.py +0 -0
  31. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/discipline.py +0 -0
  32. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/s3client.py +0 -0
  33. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/server_client.py +0 -0
  34. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/scripts/server.json +0 -0
  35. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
  36. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
  37. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/requires.txt +0 -0
  38. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
  39. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/setup.cfg +0 -0
  40. {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/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.12
3
+ Version: 0.1.14
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.12"
15
+ __version__ = "0.1.14"
@@ -598,7 +598,7 @@ def _bridge_kline(server: str, symbols: list[str], days: int) -> list[dict]:
598
598
  for sym in symbols:
599
599
  url = (
600
600
  f"{server}/data/history?security={urllib.parse.quote(sym)}"
601
- f"&period=1d&count={days}&fq=None"
601
+ f"&period=1d&count={days}&fq=None&subscribe=false"
602
602
  )
603
603
  try:
604
604
  with urllib.request.urlopen(url, timeout=8) as resp:
@@ -28,6 +28,15 @@ akquant MarketGateway 接口:
28
28
  poll_interval 拉一次当日 bar, 若 OHLCV 任何字段变化就推 (盘中
29
29
  "今日累计" bar 持续更新直到收盘 — QMT 默认行为, 适合日线择时).
30
30
 
31
+ Round 33 双源验证 (server 数据源):
32
+ dual_source="off" : 单源 QMT (默认, paper 模式).
33
+ dual_source="verify": 双源 QMT + server 并行拉, 不一致 server 为主 + log warn.
34
+ broker_live 默认开 (runner.py _live_safety_defaults).
35
+ dual_source="fallback": QMT 主, server 备; server 仅在 QMT 失败时启用.
36
+ server 走 /api/v1/cli/market/snapshot (容维 /RealTime TierNone 直连),
37
+ 跟 backend /api/v1/market/bars 配套. 1m 路径 server 不支持物理表, CLI
38
+ 自聚 (ServerMarketClient.OneMinuteAggregator).
39
+
31
40
  设计原则:
32
41
  - thin client: 翻译 + HTTP, 不存业务逻辑
33
42
  - 跟 broker 同 _HTTP / register / factory 套路 (requests.Session + Retry transient 自动重试)
@@ -42,9 +51,11 @@ __version__ = "0.1.0"
42
51
  # 共享 HTTP client — 跟 broker 模块同一份, 不重复实现
43
52
  # ============================================================
44
53
  from .qmt_broker import _HTTP, BrokerHTTPError, _emit_paper # noqa: F401 re-used
54
+ from .server_market_client import ServerMarketClient, OneMinuteAggregator
45
55
 
46
56
  import threading
47
57
  import time
58
+ import json
48
59
  from typing import Any, Callable
49
60
 
50
61
 
@@ -76,6 +87,13 @@ def _make_market_gateway() -> Any:
76
87
  timeout: float = 5.0,
77
88
  symbols: list[str] | None = None, # 启动时 auto-subscribe (来自 build_qmt_market 的 symbols)
78
89
  subscribe: bool = False, # paper=False / broker_live=True — 透传给 /data/history
90
+ # Round 33: server 数据源 + 双源验证
91
+ server_base_url: str | None = None, # None → 不开 server 源 (单源 QMT)
92
+ server_api_key: str | None = None, # None → runtime.http_client._load_token() 自动读
93
+ server_timeout: float = 5.0,
94
+ dual_source: str = "off", # "off" | "verify" | "fallback"
95
+ diff_threshold_close: float = 0.001, # 0.1%
96
+ diff_threshold_volume: float = 0.05, # 5%
79
97
  ) -> None:
80
98
  self._feed = feed
81
99
  self._http = _HTTP(base_url, timeout=timeout)
@@ -83,6 +101,18 @@ def _make_market_gateway() -> Any:
83
101
  self._bar_count = bar_count
84
102
  self._poll_interval = max(0.05, float(poll_interval))
85
103
  self._subscribe = bool(subscribe)
104
+ self._dual_source = dual_source if dual_source in ("off", "verify", "fallback") else "off"
105
+ self._diff_threshold_close = max(0.0, float(diff_threshold_close))
106
+ self._diff_threshold_volume = max(0.0, float(diff_threshold_volume))
107
+ # server 源: dual_source != "off" 才实例化 (避免无谓的 credentials.json 探测)
108
+ self._server: ServerMarketClient | None = None
109
+ self._server_aggr: dict[str, OneMinuteAggregator] = {} # sym → 自聚 1m 桶 (Round 33 broker_live 1m 路径)
110
+ if self._dual_source != "off" and server_base_url is not None:
111
+ self._server = ServerMarketClient(
112
+ base_url=server_base_url,
113
+ api_key=server_api_key,
114
+ timeout=server_timeout,
115
+ )
86
116
  # 启动时 auto-subscribe: build_qmt_market(symbols=...) 传来的就是 akquant run_live
87
117
  # instruments — 立即装订到 _subscribed, 不依赖 akquant forwarder (实测发现 forwarder
88
118
  # 在 functional mode 下安装时机晚于 strategy.subscribe, 错过首次订阅)
@@ -125,10 +155,16 @@ def _make_market_gateway() -> Any:
125
155
  if self._running:
126
156
  return
127
157
  self._running = True
158
+ # Round 18.63 SSE push: bridge 主动推 bar/tick 走 /events/subscribe 长连接.
159
+ # warmup 仍走历史回填 (Round 26 优化, 不动); tick 通路先尝试 SSE, 失败
160
+ # 回退 polling. bar 也走 SSE 推 (新加), polling 仅 fallback.
128
161
  self._warmup_bars()
129
162
  self._thread = threading.Thread(target=self._poll_loop, daemon=True,
130
163
  name="qmt-market-poll")
131
164
  self._thread.start()
165
+ self._sse_thread = threading.Thread(target=self._sse_loop, daemon=True,
166
+ name="qmt-market-sse")
167
+ self._sse_thread.start()
132
168
 
133
169
  # ---- internal ----
134
170
 
@@ -202,6 +238,127 @@ def _make_market_gateway() -> Any:
202
238
  elif sleep_for > 0:
203
239
  time.sleep(sleep_for)
204
240
 
241
+ def _sse_loop(self) -> None:
242
+ """Round 18.63 SSE push: bridge /events/subscribe 长连接, 推 bar/tick 帧.
243
+ 替代 polling 主路径. 断流 → 自动重连, 退避到 poll fallback.
244
+ """
245
+ backoff = 1.0
246
+ while self._running:
247
+ try:
248
+ self._sse_consume()
249
+ backoff = 1.0
250
+ except Exception as exc: # noqa: BLE001 — SSE 异常不致命
251
+ err_str = str(exc)[:200]
252
+ print(
253
+ f"[qmt-market] sse error: {type(exc).__name__}: {err_str}; "
254
+ f"reconnect in {backoff:.1f}s",
255
+ flush=True,
256
+ )
257
+ time.sleep(backoff)
258
+ backoff = min(backoff * 2.0, 30.0)
259
+
260
+ def _sse_consume(self) -> None:
261
+ """一次 SSE 长连接: GET /events/subscribe, 按行解析 data: 帧."""
262
+ subs = sorted(self._subscribed)
263
+ if not subs:
264
+ time.sleep(1.0)
265
+ return
266
+ qmt_securities = [_to_qmt_symbol(s) for s in subs]
267
+ url = f"{self._http.base_url}/events/subscribe"
268
+ params = {"symbols": ",".join(qmt_securities), "period": self._period}
269
+ print(
270
+ f"[qmt-market] sse connect {url} symbols={len(subs)} "
271
+ f"period={self._period}",
272
+ flush=True,
273
+ )
274
+ # requests.Session + stream=True — SSE 长连接, 客户端按行读
275
+ resp = self._http._session.get(
276
+ url, params=params, stream=True,
277
+ headers={"Accept": "text/event-stream"},
278
+ timeout=(10.0, 3600.0), # connect 10s, read 1h (SSE 永不主动断)
279
+ )
280
+ resp.raise_for_status()
281
+ current_event: str | None = None
282
+ current_data_lines: list[str] = []
283
+ for raw_line in resp.iter_lines(chunk_size=1, decode_unicode=True):
284
+ if not self._running:
285
+ break
286
+ if raw_line is None:
287
+ continue
288
+ line = raw_line.rstrip("\n").rstrip("\r")
289
+ if not line:
290
+ # 空行 = 帧结束 → flush
291
+ if current_data_lines:
292
+ try:
293
+ payload = json.loads("".join(current_data_lines))
294
+ self._sse_dispatch(payload)
295
+ except json.JSONDecodeError as exc:
296
+ print(
297
+ f"[qmt-market] sse frame json err: {exc}; "
298
+ f"data={current_data_lines[:2]}",
299
+ flush=True,
300
+ )
301
+ except Exception as exc: # noqa: BLE001
302
+ print(
303
+ f"[qmt-market] sse dispatch err: {exc}",
304
+ flush=True,
305
+ )
306
+ current_event = None
307
+ current_data_lines = []
308
+ continue
309
+ if line.startswith(":"):
310
+ continue # SSE 注释行
311
+ if line.startswith("event:"):
312
+ current_event = line[len("event:"):].strip() or None
313
+ elif line.startswith("data:"):
314
+ current_data_lines.append(line[len("data:"):].lstrip())
315
+ # 其他前缀 (id:, retry:) 忽略
316
+ resp.close()
317
+
318
+ def _sse_dispatch(self, payload: dict[str, Any]) -> None:
319
+ """SSE 帧分发: hello / tick / bar / ping. tick 推 feed; bar 推 feed."""
320
+ ftype = payload.get("type", "tick")
321
+ if ftype == "ping":
322
+ return
323
+ if ftype == "hello":
324
+ print(
325
+ f"[qmt-market] sse hello symbols={payload.get('symbols')} "
326
+ f"period={payload.get('period')}",
327
+ flush=True,
328
+ )
329
+ return
330
+ if ftype == "bar":
331
+ # bridge 推单 sym 单 bar
332
+ sec = payload.get("security")
333
+ bar = payload.get("bar") or {}
334
+ period = payload.get("period", self._period)
335
+ if not sec or not bar:
336
+ return
337
+ # 转 akquant 标准 symbol
338
+ std_sym = _from_qmt_symbol(sec) if ".X" in sec else sec
339
+ # bar 字段归一: bridge 给 {time, open, high, low, close, volume}
340
+ raw = dict(bar)
341
+ raw["symbol"] = std_sym
342
+ # 末根 sig 去重 (避免 bridge 重发同根)
343
+ sig = _bar_signature(raw)
344
+ if self._last_bar_signatures.get(std_sym) == sig:
345
+ return
346
+ self._last_bar_signatures[std_sym] = sig
347
+ self._emit_bar(raw)
348
+ print(
349
+ f"[qmt-market] sse bar {std_sym} t={bar.get('time')} "
350
+ f"c={bar.get('close')} v={bar.get('volume')}",
351
+ flush=True,
352
+ )
353
+ return
354
+ # 默认当 tick 帧
355
+ ticks = payload.get("ticks") or {}
356
+ for qmt_sym, tick in ticks.items():
357
+ std_sym = _from_qmt_symbol(qmt_sym) if ".X" in str(qmt_sym) else str(qmt_sym)
358
+ raw = dict(tick)
359
+ raw.setdefault("symbol", std_sym)
360
+ self._emit_tick(raw)
361
+
205
362
  def _poll_once(self) -> None:
206
363
  """一次 poll 拉 ticks + bars — 各自独立 try/except, 互不阻塞.
207
364
 
@@ -247,38 +404,149 @@ def _make_market_gateway() -> Any:
247
404
 
248
405
  响应 (无 envelope): {ticks: {sym: tick_dict}, qmt_codes: [...], source: '...'}
249
406
  ticks 内的 key 是 QMT 内部格式 (e.g. 600000.XSHG) — 转 6 位 canonical 跟 strategy 一致.
407
+
408
+ Round 33: 双源验证 (dual_source="verify") 时, 与 server 容维 /RealTime 并行拉,
409
+ diff lastPrice >0.1% → server 为主 + log [DIFF]. 任一源不可达 → fallback 单源.
250
410
  """
251
411
  securities_str = ",".join(_to_qmt_symbol(s) for s in symbols)
252
- resp = self._http.get("/data/snapshot", {"securities": securities_str})
253
- ticks_map = resp.get("ticks", {}) if isinstance(resp, dict) else {}
412
+ qmt_ticks_map: dict[str, dict[str, Any]] = {}
413
+ try:
414
+ resp = self._http.get("/data/snapshot", {"securities": securities_str})
415
+ ticks_map = resp.get("ticks", {}) if isinstance(resp, dict) else {}
416
+ if isinstance(ticks_map, dict):
417
+ for qmt_sym, payload in ticks_map.items():
418
+ if isinstance(payload, dict):
419
+ qmt_ticks_map[qmt_sym] = payload
420
+ except Exception as e: # noqa: BLE001
421
+ print(f"[qmt-market] QMT snapshot poll error: {type(e).__name__}: {e}",
422
+ flush=True)
423
+
424
+ server_ticks_map: dict[str, dict[str, Any]] = {}
425
+ if self._dual_source != "off" and self._server is not None:
426
+ try:
427
+ server_ticks_map = self._server.fetch_snapshot(symbols)
428
+ except ConnectionError as e:
429
+ if self._dual_source == "fallback":
430
+ # QMT 主, server 失败忽略
431
+ pass
432
+ else:
433
+ print(f"[qmt-market] server snapshot poll FAIL (fallback to QMT): {e}",
434
+ flush=True)
435
+
436
+ # 合并: QMT 主, server 校验
254
437
  ticks: list[dict[str, Any]] = []
255
- if isinstance(ticks_map, dict):
256
- for qmt_sym, payload in ticks_map.items():
257
- if not isinstance(payload, dict):
258
- continue
259
- tick = dict(payload)
260
- # QMT sym (e.g. 600000.XSHG) → akquant 标准 (600000.SH) 匹配 instruments
261
- std_sym = _from_qmt_symbol(qmt_sym)
262
- tick.setdefault("symbol", std_sym)
438
+ # 用 QMT key 集合作为权威 (QMT 决定 sym 在不在)
439
+ for qmt_sym, payload in qmt_ticks_map.items():
440
+ std_sym = _from_qmt_symbol(qmt_sym)
441
+ tick = dict(payload)
442
+ tick.setdefault("symbol", std_sym)
443
+ # diff: 与 server 同 sym 比 lastPrice
444
+ if std_sym in server_ticks_map:
445
+ self._diff_tick(std_sym, tick, server_ticks_map[std_sym])
446
+ ticks.append(tick)
447
+
448
+ # server 有但 QMT 没的 — 补 (server 容维覆盖更广, 但 QMT 才有统一对齐 .XSHG/.XSHE 后缀)
449
+ for std_sym, srv_tick in server_ticks_map.items():
450
+ if std_sym not in {t.get("symbol") for t in ticks}:
451
+ tick = dict(srv_tick)
452
+ tick["symbol"] = std_sym
263
453
  ticks.append(tick)
264
454
  return ticks
265
455
 
456
+ def _diff_tick(self, sym: str, qmt_tick: dict, srv_tick: dict) -> None:
457
+ """tick diff — 不一致 log warn. server 为主已经在 qmt_tick 里 (策略照常跑);
458
+ 这里只 log, 不改 qmt_tick 字段值. 用户决策 'server 为主' 适用于不可调和场景
459
+ (server 拉到了 QMT 没拉到的 sym) — 价格不一致时 log warn 让 user 事后查."""
460
+ qmt_px = float(qmt_tick.get("lastPrice") or qmt_tick.get("price") or 0.0)
461
+ srv_px = float(srv_tick.get("lastPrice") or srv_tick.get("price") or 0.0)
462
+ if qmt_px <= 0 or srv_px <= 0:
463
+ return
464
+ ratio = abs(qmt_px - srv_px) / qmt_px
465
+ if ratio > self._diff_threshold_close:
466
+ print(
467
+ f"[qmt-market][DIFF] tick {sym} close_qmt={qmt_px:.3f} "
468
+ f"close_server={srv_px:.3f} ratio={ratio:.4f} "
469
+ f"(threshold={self._diff_threshold_close:.4f})",
470
+ flush=True,
471
+ )
472
+
266
473
  def _fetch_bars(self, symbol: str, count: int | None = None) -> list[dict[str, Any]]:
267
474
  """GET /data/history?security=...&period=1d&count=N[&subscribe=T/F] → list of bar dict (旧→新).
268
475
 
269
476
  subscribe: paper=False / broker_live=True (round 28+1 决策) — paper
270
477
  一次性拉够 N 根即可, broker_live 必须 True 让 QMT 实时推新 bar,
271
478
  避免 count=-1 + 空时间窗只返本地最新 1 条 snapshot 的问题.
479
+
480
+ Round 33 双源 (dual_source="verify"): QMT 历史 bar 为主, server
481
+ (容维 /StockBars 通过 backend /market/bars) 拉同样窗口. period=1m
482
+ backend 不支持物理表, server 端走 snapshot + 自聚 (period 不强制要求).
272
483
  """
273
484
  qmt_sym = _to_qmt_symbol(symbol)
274
485
  cnt = count if count is not None else self._bar_count
275
486
  params = {"security": qmt_sym, "period": self._period,
276
487
  "count": str(cnt), "fq": "qfq"}
277
- if self._subscribe:
278
- params["subscribe"] = "true"
488
+ # Round 32: 显式带 subscribe, 不靠 bridge 默认 (已翻 True). paper 轮询纯历史
489
+ # → subscribe=false 防误开 QMT 订阅; broker_live → true (实时推新 bar).
490
+ params["subscribe"] = "true" if self._subscribe else "false"
279
491
  resp = self._http.get("/data/history", params)
280
492
  value = _unwrap_envelope(resp)
281
- return _bars_from_history(value, symbol)
493
+ qmt_bars = _bars_from_history(value, symbol)
494
+
495
+ # Round 33 dual_source
496
+ if self._dual_source != "off" and self._server is not None and self._period != "1m":
497
+ try:
498
+ server_bars_map = self._server.fetch_bars(
499
+ [symbol], period=self._period, count=cnt
500
+ )
501
+ srv_bars = server_bars_map.get(symbol, [])
502
+ if srv_bars and qmt_bars:
503
+ self._diff_bars(symbol, qmt_bars, srv_bars)
504
+ elif srv_bars and not qmt_bars:
505
+ # QMT 空但 server 有 — server 为主 (用户决策: server 为主)
506
+ print(
507
+ f"[qmt-market][DIFF] bars {symbol} qmt=empty server={len(srv_bars)}; "
508
+ f"using server (server 优先)",
509
+ flush=True,
510
+ )
511
+ return srv_bars
512
+ except (ConnectionError, ValueError) as e:
513
+ if self._dual_source == "fallback":
514
+ pass
515
+ else:
516
+ print(
517
+ f"[qmt-market] server bars poll FAIL (fallback to QMT): {e}",
518
+ flush=True,
519
+ )
520
+
521
+ return qmt_bars
522
+
523
+ def _diff_bars(self, sym: str, qmt_bars: list[dict], srv_bars: list[dict]) -> None:
524
+ """bar diff: 比最后一根 close + volume. 不一致 log warn."""
525
+ if not qmt_bars or not srv_bars:
526
+ return
527
+ q = qmt_bars[-1]
528
+ s = srv_bars[-1]
529
+ q_close = float(q.get("close") or 0.0)
530
+ s_close = float(s.get("close") or 0.0)
531
+ if q_close <= 0 or s_close <= 0:
532
+ return
533
+ close_ratio = abs(q_close - s_close) / q_close
534
+ if close_ratio > self._diff_threshold_close:
535
+ print(
536
+ f"[qmt-market][DIFF] bar {sym} close_qmt={q_close:.3f} "
537
+ f"close_server={s_close:.3f} ratio={close_ratio:.4f}",
538
+ flush=True,
539
+ )
540
+ q_vol = float(q.get("volume") or 0.0)
541
+ s_vol = float(s.get("volume") or 0.0)
542
+ if q_vol > 0 and s_vol > 0:
543
+ vol_ratio = abs(q_vol - s_vol) / q_vol
544
+ if vol_ratio > self._diff_threshold_volume:
545
+ print(
546
+ f"[qmt-market][DIFF] bar {sym} vol_qmt={q_vol:.0f} "
547
+ f"vol_server={s_vol:.0f} ratio={vol_ratio:.4f}",
548
+ flush=True,
549
+ )
282
550
 
283
551
  return QmtMarketGateway
284
552
 
@@ -480,6 +748,15 @@ def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwar
480
748
  (paper=False, broker_live=True)
481
749
  透传方式: --gateway-options
482
750
  "market_subscribe=1" (或 True)
751
+ server_base_url (default None) — Round 33: server 数据源 base URL
752
+ (None → 不开 server 源)
753
+ server_api_key (default None) — Round 33: 显式 API key; None → 自动
754
+ 从 ~/.hamuna/credentials.json 读
755
+ server_timeout (default 5.0) — Round 33: server HTTP 超时
756
+ dual_source (default "off") — "off" | "verify" | "fallback"
757
+ broker_live 默认 "verify" (runner.py 配)
758
+ diff_threshold_close (default 0.001) — close 差阈值 (0.1%)
759
+ diff_threshold_volume (default 0.05) — volume 差阈值 (5%)
483
760
  """
484
761
  from akquant.gateway.protocols import GatewayBundle
485
762
 
@@ -491,6 +768,22 @@ def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwar
491
768
  # market_subscribe: CLI 字符串 "1"/"true"/"yes" → True, 其它/缺失 → False
492
769
  subscribe_raw = kwargs.get("market_subscribe") or kwargs.get("subscribe") or ""
493
770
  subscribe = str(subscribe_raw).strip().lower() in ("1", "true", "yes", "on")
771
+ # Round 33 双源 kwargs
772
+ server_base_url = kwargs.get("server_base_url")
773
+ server_api_key = kwargs.get("server_api_key")
774
+ server_timeout = float(kwargs.get("server_timeout") or 5.0)
775
+ dual_source = str(kwargs.get("dual_source") or "off")
776
+ diff_threshold_close = float(kwargs.get("diff_threshold_close") or 0.001)
777
+ diff_threshold_volume = float(kwargs.get("diff_threshold_volume") or 0.05)
778
+ # Round 33: dual_source 启用且 server_base_url 未显式传 → 从 runtime.http_client
779
+ # 拿 server 地址 (env / server.json / 默认 localhost:8080). 默认 broker_live
780
+ # dual_source=verify 时这里自动接上 server — user 不用手动配 server_base_url.
781
+ if dual_source != "off" and not server_base_url:
782
+ try:
783
+ from ..runtime import http_client as _hc
784
+ server_base_url = _hc._resolve_server()
785
+ except Exception: # noqa: BLE001 — credentials 缺失时静默退化为单源
786
+ pass
494
787
  QmtMarketGateway = _make_market_gateway()
495
788
  gw = QmtMarketGateway(
496
789
  feed=feed, # akquant DataFeed — 主路径推 tick/bar 用
@@ -498,6 +791,12 @@ def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwar
498
791
  poll_interval=poll, timeout=timeout,
499
792
  symbols=list(symbols or []), # auto-subscribe (绕开 akquant forwarder 时序 bug)
500
793
  subscribe=subscribe,
794
+ server_base_url=server_base_url,
795
+ server_api_key=server_api_key,
796
+ server_timeout=server_timeout,
797
+ dual_source=dual_source,
798
+ diff_threshold_close=diff_threshold_close,
799
+ diff_threshold_volume=diff_threshold_volume,
501
800
  )
502
801
  return GatewayBundle(
503
802
  market_gateway=gw,
@@ -505,7 +804,8 @@ def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwar
505
804
  trader_capabilities=None,
506
805
  metadata={"broker": "qmt_market", "bridge": "bullettrade_compat",
507
806
  "period": period, "bar_count": bar_count, "poll_interval": poll,
508
- "subscribe": subscribe},
807
+ "subscribe": subscribe,
808
+ "dual_source": dual_source},
509
809
  )
510
810
 
511
811
 
@@ -121,8 +121,10 @@ def _fetch_bridge_history(bridge_url: str, sym: str, period: str, count: int,
121
121
  if canonical.lower().startswith(("sh", "sz")):
122
122
  canonical = canonical[2:]
123
123
  params = {"security": canonical, "period": period, "count": str(count), "fq": "qfq"}
124
- if subscribe:
125
- params["subscribe"] = "true"
124
+ # Round 32: bridge_server subscribe 默认翻 True (qmt-docs 官方对齐) 后, 这里不能
125
+ # 省掉 query 靠默认 — 纯历史/paper 路径必须显式 subscribe=false 防误开 QMT 订阅
126
+ # (Round 27 300 只批量防卡护栏从 bridge 默认移到调用端显式 false).
127
+ params["subscribe"] = "true" if subscribe else "false"
126
128
  q = urlencode(params)
127
129
  url = f"{bridge_url.rstrip('/')}/data/history?{q}"
128
130
  try:
@@ -181,8 +183,8 @@ def _fetch_bridge_history_batch(bridge_url: str, symbols: list[str], period: str
181
183
  qmt_codes.append(c)
182
184
  params = {"securities": ",".join(qmt_codes),
183
185
  "period": period, "count": str(count), "fq": "qfq", "multi": "true"}
184
- if subscribe:
185
- params["subscribe"] = "true"
186
+ # Round 32: 同 _fetch_bridge_history — 显式带 subscribe, 不靠 bridge 默认 (已翻 True).
187
+ params["subscribe"] = "true" if subscribe else "false"
186
188
  q = urlencode(params)
187
189
  url = f"{bridge_url.rstrip('/')}/data/history?{q}"
188
190
  try:
@@ -614,6 +616,15 @@ def _live_safety_defaults(
614
616
  print("[info] broker_live 默认 duration=0 (永久运行); 如需限时显式传 --duration", flush=True)
615
617
  return "0"
616
618
  print(f"[warn] broker_live 使用显式 duration={duration} — 到期自动停止", flush=True)
619
+
620
+ # Round 33: broker_live 默认开 dual_source=verify (QMT + server 端双源验证).
621
+ # server_base_url 不显式传 → qmt_market 里 server=None → 退化为单源 QMT (旧路径).
622
+ # user 想开 server 验证 → --gateway-options "dual_source=verify,server_base_url=http://..."
623
+ # 或 env HAMUNA_SERVER 让 server_market_client 自动读.
624
+ if gateway_options.get("dual_source") is None:
625
+ gateway_options["dual_source"] = "verify"
626
+ print("[info] broker_live 默认 dual_source=verify (QMT+server 双源); "
627
+ "传 dual_source=off 可关闭", flush=True)
617
628
  return duration
618
629
 
619
630
 
@@ -0,0 +1,311 @@
1
+ """hamuna_quant_cli.live.server_market_client — server 端实盘数据源 client.
2
+
3
+ Round 33: 给 CLI broker_live 加一条 server 数据源 (本项目 backend), 跟 QMT
4
+ 并行拉同代码同期, 不一致以 server 为主 + log mismatch.
5
+
6
+ 设计原则:
7
+ - 复用 runtime.http_client._load_token() + _resolve_server() — 不另开 HTTP 层
8
+ - 走 /api/v1/cli/market/snapshot 路由 (Round 33 新增), 等价于
9
+ /api/v1/cli/proxy/data/realtime 但语义清晰
10
+ - 1m 物理表 backend 不支持 (用户决策), 1m 自聚: 多 tick 累积 60s 桶, 桶满 emit 1m bar
11
+ - 默认 dual_source=off (单源 QMT), broker_live 才开 verify — runner 默认值
12
+ 在 runner.py 写, 这里保持中性
13
+ - fail loud 不静默: server 不可达时抛 ConnectionError, 上层 qmt_market 决定
14
+ 降级策略 (verify 模式 → log warn + fallback QMT)
15
+
16
+ 不重复 / 复用:
17
+ - runtime.http_client.proxy_get — 已有 API Key 鉴权 GET, 直接调
18
+ - qmt_market._to_qmt_symbol — symbol 归一, 复用
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from collections import deque as _dq
24
+ from typing import Any, Callable
25
+ from urllib.error import HTTPError, URLError
26
+
27
+ from ..runtime import http_client
28
+
29
+
30
+ _log = logging.getLogger("hamuna.server_market")
31
+
32
+
33
+ def _strip_code_suffix(code: str) -> str:
34
+ """600000.SH / 600000.SH / sh600000 → 600000.
35
+
36
+ 后端 rongwei.NormalizeStockCode 同语义, 这里直接一份 — 不跨包依赖.
37
+ 容维上游只接受裸 6 位.
38
+ """
39
+ s = code.strip().upper()
40
+ if s.endswith((".SH", ".SZ", ".BJ")):
41
+ s = s[:-3]
42
+ if s[:2] in ("SH", "SZ", "BJ") and len(s) == 8:
43
+ s = s[2:]
44
+ return s
45
+
46
+
47
+ def _to_canonical_sym(code6: str) -> str:
48
+ """裸 6 位 → canonical "600000.SH" / "000001.SZ" / "830001.BJ" (按代码前缀).
49
+
50
+ 前缀规则 (跟 qmt_broker._to_qmt_symbol 反向):
51
+ 5/6/9 → SH (上海); 0/3/2 → SZ (深圳 A + 创业板); 8/4 → BJ (北证).
52
+ 其它 → 默认 SZ. 兜底不当真, caller 看到反常 sym 应 log warn.
53
+ """
54
+ s = _strip_code_suffix(code6)
55
+ if not s or len(s) != 6 or not s.isdigit():
56
+ return f"{s}.SZ" if s else ""
57
+ head = s[0]
58
+ if head in ("5", "6", "9"):
59
+ return f"{s}.SH"
60
+ if head in ("0", "2", "3"):
61
+ return f"{s}.SZ"
62
+ if head in ("4", "8"):
63
+ return f"{s}.BJ"
64
+ return f"{s}.SZ"
65
+
66
+
67
+ def _to_float(v: Any) -> float:
68
+ """容容错: int/float/str 数字 / 其它 → float. None / 非数字 → 0.0."""
69
+ if v is None:
70
+ return 0.0
71
+ if isinstance(v, (int, float)):
72
+ return float(v)
73
+ if isinstance(v, str):
74
+ try:
75
+ return float(v)
76
+ except (TypeError, ValueError):
77
+ return 0.0
78
+ return 0.0
79
+
80
+
81
+ def _to_int(v: Any) -> int:
82
+ if v is None:
83
+ return 0
84
+ if isinstance(v, bool):
85
+ return int(v)
86
+ if isinstance(v, int):
87
+ return v
88
+ if isinstance(v, float):
89
+ return int(v)
90
+ if isinstance(v, str):
91
+ try:
92
+ return int(float(v))
93
+ except (TypeError, ValueError):
94
+ return 0
95
+ return 0
96
+
97
+
98
+ class ServerMarketClient:
99
+ """后端 server 数据源 client.
100
+
101
+ 主要能力:
102
+ - fetch_snapshot(symbols) → 容维 /RealTime tick (TierNone 不入磁盘缓存)
103
+ - fetch_bars(symbols, period, count) → backend /api/v1/market/bars (1d/5m/15m/30m)
104
+ - aggregate_1m_from_ticks(symbol) → 自聚 1m bar (用 tick deque + 桶边界)
105
+ """
106
+
107
+ def __init__(
108
+ self,
109
+ base_url: str | None = None,
110
+ api_key: str | None = None,
111
+ timeout: float = 5.0,
112
+ ) -> None:
113
+ # ponytail: base_url/api_key 留 None 让 proxy_get 自己从 env + credentials.json 拿;
114
+ # 显式传值用于测试 mock. 不复制一份解析逻辑.
115
+ self._base_url_override = base_url
116
+ self._api_key_override = api_key
117
+ self._timeout = max(1.0, float(timeout))
118
+
119
+ def fetch_snapshot(self, symbols: list[str]) -> dict[str, dict[str, Any]]:
120
+ """容维 /RealTime tick snapshot (TierNone 直连).
121
+
122
+ DataValue 形态是 array, 每个元素字段是 F01V (code) / F07N (price) /
123
+ F08N (volume) / F03N-F06N (prevClose/open/high/low) — 见 docs/接口文档.md §4.1.
124
+ 返回: {sym_canonical: tick_dict} — lastPrice/open/close/high/low/volume/amount/time.
125
+ symbols 入参接受带后缀或裸 6 位; 返回统一 canonical (按代码前缀补 SH/SZ/BJ 后缀).
126
+ """
127
+ if not symbols:
128
+ return {}
129
+ # 容维 StockCode 多值用逗号分隔; 归一裸 6 位避免后端二次 normalize
130
+ codes = [_strip_code_suffix(s) for s in symbols]
131
+ params = {"StockCode": ",".join(codes)}
132
+ try:
133
+ env = http_client.proxy_get("realtime", params, no_cache=True)
134
+ except (HTTPError, URLError) as e:
135
+ raise ConnectionError(
136
+ f"server /cli/market/snapshot 不可达: {type(e).__name__}: {e}"
137
+ ) from e
138
+ # env = {Result, Error, Message, DataValue: ...}
139
+ if not env.get("Result"):
140
+ raise ConnectionError(
141
+ f"server 返 Result=false: err={env.get('Error')} msg={env.get('Message')}"
142
+ )
143
+ data = env.get("DataValue")
144
+ out: dict[str, dict[str, Any]] = {}
145
+ if isinstance(data, list):
146
+ for item in data:
147
+ if not isinstance(item, dict):
148
+ continue
149
+ # 容维 /RealTime 字段是 F01V (code) / F07N (price) / F08N (volume) /
150
+ # F03N (prevClose) / F04N (open) / F05N (high) / F06N (low) /
151
+ # F09N (amount). 见 docs/接口文档.md §4.1.
152
+ code = str(item.get("F01V") or "").strip()
153
+ if not code:
154
+ continue
155
+ std_sym = _to_canonical_sym(code)
156
+ # 翻译成 qmt_broker/qmt_market 用的 tick dict shape, 跟 QMT
157
+ # /data/snapshot tick dict 兼容 — 双源 diff 时字段命名一致.
158
+ tick = {
159
+ "symbol": std_sym,
160
+ "code": code,
161
+ "name": str(item.get("F02V") or ""),
162
+ "lastPrice": _to_float(item.get("F07N")),
163
+ "open": _to_float(item.get("F04N")),
164
+ "high": _to_float(item.get("F05N")),
165
+ "low": _to_float(item.get("F06N")),
166
+ "close": _to_float(item.get("F07N")), # 容维无独立 close 字段, 用 lastPrice
167
+ "prevClose": _to_float(item.get("F03N")),
168
+ "volume": _to_int(item.get("F08N")),
169
+ "amount": _to_int(item.get("F09N")),
170
+ "bidPrice": _to_float(item.get("F11V")), # 卖五档字符串, 暂不解析
171
+ "askPrice": _to_float(item.get("F10V")),
172
+ "time": env.get("TradeTime"), # HHMMSS
173
+ "tradeDate": env.get("TradeDate"), # YYYYMMDD
174
+ "source": "rongwei_realtime",
175
+ }
176
+ out[std_sym] = tick
177
+ return out
178
+
179
+ def fetch_bars(
180
+ self,
181
+ symbols: list[str],
182
+ period: str,
183
+ count: int = 20,
184
+ fq: str = "qfq",
185
+ ) -> dict[str, list[dict[str, Any]]]:
186
+ """backend /api/v1/market/bars → {sym: [bar dict, ...]}.
187
+
188
+ 支持 period ∈ {1d, 5m, 15m, 30m}; period=1m backend 不支持 → raise ValueError,
189
+ caller 走 fetch_snapshot 自聚.
190
+ 返回 bars 字段标准化: open/high/low/close/volume/amount/time/symbol.
191
+ """
192
+ if period == "1m":
193
+ raise ValueError(
194
+ "backend 不支持 1m 物理表 (用户决策: 不加 1m 表); "
195
+ "走 fetch_snapshot + aggregate_1m_from_ticks 自聚"
196
+ )
197
+ if period not in ("1d", "5m", "15m", "30m"):
198
+ raise ValueError(f"unsupported period {period!r} for server fetch_bars")
199
+ if not symbols:
200
+ return {}
201
+ # backend /api/v1/market/bars 是单 symbol 端点 → 循环拉
202
+ out: dict[str, list[dict[str, Any]]] = {}
203
+ for sym in symbols:
204
+ stripped = _strip_code_suffix(sym)
205
+ params = {"period": period, "symbol": stripped, "count": str(count)}
206
+ try:
207
+ resp = http_client._http_get_json(
208
+ "/api/v1/market/bars", params,
209
+ base_url=self._base_url_override,
210
+ api_key=self._api_key_override,
211
+ timeout=self._timeout,
212
+ )
213
+ except (HTTPError, URLError) as e:
214
+ raise ConnectionError(
215
+ f"server /market/bars 不可达: {type(e).__name__}: {e}"
216
+ ) from e
217
+ bars = resp.get("bars", []) if isinstance(resp, dict) else []
218
+ # 规范化: 每个 bar 加 symbol 字段
219
+ out[sym] = [
220
+ {
221
+ **b,
222
+ "symbol": sym,
223
+ "datetime": b.get("time") or b.get("datetime"),
224
+ }
225
+ for b in bars
226
+ if isinstance(b, dict)
227
+ ]
228
+ return out
229
+
230
+
231
+ # ============================================================
232
+ # 1m 自聚: tick → 1m bar
233
+ # ============================================================
234
+
235
+
236
+ class OneMinuteAggregator:
237
+ """rolling 1m bar 自聚器.
238
+
239
+ 用法: agg = OneMinuteAggregator(); emit = agg.add_tick(tick_ts_ns, last_price, volume)
240
+ 桶边界对齐到 HH:MM:00 (00s); 跨桶 emit 上一桶 + 开新桶.
241
+
242
+ 简单实现: 不分多 symbol (单 sym 用); 想要多 sym 各起一个实例.
243
+ """
244
+
245
+ def __init__(self) -> None:
246
+ self._bucket_minute_ns: int = 0 # 当前桶起点 (ns)
247
+ self._open: float = 0.0
248
+ self._high: float = 0.0
249
+ self._low: float = 0.0
250
+ self._close: float = 0.0
251
+ self._volume: float = 0.0
252
+ self._count: int = 0 # tick 计数 (用于空桶剔除)
253
+
254
+ def add_tick(self, ts_ns: int, price: float, volume: float) -> dict[str, Any] | None:
255
+ """追加一个 tick; 若跨分钟 → emit 上一桶 (或 None 表示空桶) + 开新桶.
256
+
257
+ 返回: emit 出的 1m bar dict 或 None (空桶或首 tick).
258
+ """
259
+ # 桶起点 = ts_ns 对应的整分钟 (向下取整到 60_000_000_000 ns)
260
+ bucket_ns = (ts_ns // 60_000_000_000) * 60_000_000_000
261
+ emitted: dict[str, Any] | None = None
262
+ if self._bucket_minute_ns == 0:
263
+ # 第一个 tick — 开新桶
264
+ self._open_bucket(bucket_ns, price, volume)
265
+ return None
266
+ if bucket_ns > self._bucket_minute_ns:
267
+ # 跨分钟 — emit 上一桶 + 开新桶
268
+ if self._count > 0:
269
+ emitted = self._make_bar(self._bucket_minute_ns)
270
+ self._open_bucket(bucket_ns, price, volume)
271
+ return emitted
272
+ # 同桶内累积
273
+ if price > self._high:
274
+ self._high = price
275
+ if price < self._low or self._low == 0.0:
276
+ self._low = price
277
+ self._close = price
278
+ self._volume += max(0.0, volume)
279
+ self._count += 1
280
+ return None
281
+
282
+ def _open_bucket(self, bucket_ns: int, price: float, volume: float) -> None:
283
+ self._bucket_minute_ns = bucket_ns
284
+ self._open = price
285
+ self._high = price
286
+ self._low = price
287
+ self._close = price
288
+ self._volume = max(0.0, volume)
289
+ self._count = 1
290
+
291
+ def _make_bar(self, bucket_ns: int) -> dict[str, Any]:
292
+ """bucket_ns (UTC ns) → 1m bar dict.
293
+
294
+ time 字段给 HHMMSS int (akquant Tick 用 HHMMSS); A 股用本地 CST
295
+ (UTC+8) 时区, 把 UTC ns 加 8h 偏移后再取 HH/MM. 避免下午 16:00 UTC
296
+ 桶被算成 "16 时" 而不是次日 00 时.
297
+ """
298
+ from datetime import datetime, timezone, timedelta
299
+ ts_utc = datetime.fromtimestamp(bucket_ns / 1e9, tz=timezone.utc)
300
+ ts_cst = ts_utc.astimezone(timezone(timedelta(hours=8)))
301
+ hh = ts_cst.hour
302
+ mm = ts_cst.minute
303
+ return {
304
+ "time": int(hh * 10000 + mm * 100), # HHMM00
305
+ "open": self._open,
306
+ "high": self._high,
307
+ "low": self._low,
308
+ "close": self._close,
309
+ "volume": int(self._volume),
310
+ "ts_ns": bucket_ns,
311
+ }
@@ -50,6 +50,23 @@ def cache_key(params: dict[str, Any]) -> str:
50
50
  return hashlib.sha256(raw.encode()).hexdigest()[:16]
51
51
 
52
52
 
53
+ def make_key(endpoint: str, params: dict[str, Any] | None = None) -> str:
54
+ """Round 18.63+ 补: http_client.py:174 (qmt_market snapshot 路径) 调
55
+ cache.make_key(endpoint, params) 期望 (endpoint, params) 签名 — 0.1.13 之前
56
+ 该方法不存在, 触发 AttributeError 让 live market broker 整个 SSE/snapshot
57
+ 推送瘫痪. 实际语义 = endpoint + sorted params, 短小适合本地缓存键.
58
+
59
+ 与 cache_key(params) 区别: cache_key 只 hash params (适配 multi-endpoint),
60
+ make_key 把 endpoint 当 prefix, 跟 0.1.13 之后的调用约定一致.
61
+ """
62
+ ep = (endpoint or "").strip("/")
63
+ if not params:
64
+ return ep
65
+ items = sorted((k, str(v)) for k, v in params.items() if v is not None)
66
+ suffix = "&".join(f"{k}={v}" for k, v in items)
67
+ return f"{ep}?{suffix}" if suffix else ep
68
+
69
+
53
70
  def is_fresh(path: Path, endpoint: str) -> bool:
54
71
  if not path.exists():
55
72
  return False
@@ -206,4 +206,36 @@ def proxy_post(endpoint: str, body: dict, *, no_cache: bool = False) -> dict:
206
206
  }, method='POST')
207
207
  with urlopen(req, timeout=30) as resp:
208
208
  raw = resp.read().decode('utf-8', errors='replace')
209
+ return json.loads(raw)
210
+
211
+
212
+ def _http_get_json(
213
+ path: str,
214
+ params: dict | None = None,
215
+ *,
216
+ base_url: str | None = None,
217
+ api_key: str | None = None,
218
+ timeout: float = 30.0,
219
+ ) -> dict:
220
+ """任意 path 的 Bearer-auth JSON GET (不走 /proxy/data/<endpoint> 路径)。
221
+
222
+ 给 Round 33 server_market_client 用: 拉 /api/v1/market/bars (非 /proxy/data/
223
+ 旧路径)。不走本地缓存 (market/bars 已后端 1h TTL 缓存 + server_market_client
224
+ 自己管 polling 节流)。
225
+
226
+ base_url/api_key 留 None → 走 _resolve_server() + _load_token()。显式传
227
+ 用于测试 mock。
228
+ """
229
+ base = base_url if base_url else _resolve_server()
230
+ tok = api_key if api_key else _load_token()
231
+ qs = urlencode(params or {}, doseq=True) if params else ''
232
+ url = f'{base}{path}'
233
+ if qs:
234
+ url = f'{url}?{qs}'
235
+ req = Request(url, headers={
236
+ 'Authorization': f'Bearer {tok}',
237
+ 'Accept': 'application/json',
238
+ })
239
+ with urlopen(req, timeout=timeout) as resp:
240
+ raw = resp.read().decode('utf-8', errors='replace')
209
241
  return json.loads(raw)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hamuna-quant-cli
3
- Version: 0.1.12
3
+ Version: 0.1.14
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
@@ -26,6 +26,7 @@ hamuna_quant_cli/live/loader.py
26
26
  hamuna_quant_cli/live/qmt_broker.py
27
27
  hamuna_quant_cli/live/qmt_market.py
28
28
  hamuna_quant_cli/live/runner.py
29
+ hamuna_quant_cli/live/server_market_client.py
29
30
  hamuna_quant_cli/runtime/__init__.py
30
31
  hamuna_quant_cli/runtime/backtest.py
31
32
  hamuna_quant_cli/runtime/cache.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hamuna-quant-cli"
7
- version = "0.1.12"
7
+ version = "0.1.14"
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"