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.
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/__init__.py +1 -1
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/__main__.py +1 -1
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/live/qmt_market.py +314 -14
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/live/runner.py +15 -4
- hamuna_quant_cli-0.1.14/hamuna_quant_cli/live/server_market_client.py +311 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/cache.py +17 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/http_client.py +32 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/PKG-INFO +1 -1
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/SOURCES.txt +1 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/pyproject.toml +1 -1
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/MANIFEST.in +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/README.md +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/README.md +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/_market_fallback.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/_metrics_15.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/_test_akquant_parity.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/akquant_data_adapter.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/akquant_runner.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/akquant_schema_adapter.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/base_strategy.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/cross_sectional_helpers.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/live/__init__.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/live/loader.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/live/qmt_broker.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/prebuilt_downloader.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/prebuilt_resolver.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/qmt_translator.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/__init__.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/backtest.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/discipline.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/s3client.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/server_client.py +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/scripts/server.json +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/dependency_links.txt +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/entry_points.txt +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/requires.txt +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/top_level.txt +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/setup.cfg +0 -0
- {hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/tests/test_live_safety.py +0 -0
|
@@ -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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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)
|
|
@@ -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.
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/_test_akquant_parity.py
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/akquant_data_adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/akquant_schema_adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/cross_sectional_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli/runtime/server_client.py
RENAMED
|
File without changes
|
|
File without changes
|
{hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{hamuna_quant_cli-0.1.12 → hamuna_quant_cli-0.1.14}/hamuna_quant_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|