hamuna-quant-cli 0.1.0.dev93__py3-none-any.whl
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/README.md +117 -0
- hamuna_quant_cli/__init__.py +17 -0
- hamuna_quant_cli/__main__.py +978 -0
- hamuna_quant_cli/_market_fallback.py +82 -0
- hamuna_quant_cli/_metrics_15.py +342 -0
- hamuna_quant_cli/_test_akquant_parity.py +530 -0
- hamuna_quant_cli/akquant_data_adapter.py +295 -0
- hamuna_quant_cli/akquant_runner.py +620 -0
- hamuna_quant_cli/akquant_schema_adapter.py +443 -0
- hamuna_quant_cli/base_strategy.py +80 -0
- hamuna_quant_cli/cross_sectional_helpers.py +118 -0
- hamuna_quant_cli/live/__init__.py +25 -0
- hamuna_quant_cli/live/loader.py +121 -0
- hamuna_quant_cli/live/qmt_broker.py +683 -0
- hamuna_quant_cli/live/qmt_market.py +448 -0
- hamuna_quant_cli/live/runner.py +449 -0
- hamuna_quant_cli/prebuilt_downloader.py +263 -0
- hamuna_quant_cli/prebuilt_resolver.py +470 -0
- hamuna_quant_cli/qmt_translator.py +609 -0
- hamuna_quant_cli/runtime/__init__.py +2 -0
- hamuna_quant_cli/runtime/backtest.py +38 -0
- hamuna_quant_cli/runtime/cache.py +255 -0
- hamuna_quant_cli/runtime/discipline.py +359 -0
- hamuna_quant_cli/runtime/http_client.py +209 -0
- hamuna_quant_cli/runtime/s3client.py +109 -0
- hamuna_quant_cli/runtime/server_client.py +285 -0
- hamuna_quant_cli/scripts/server.json +4 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/METADATA +154 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/RECORD +32 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/WHEEL +5 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/entry_points.txt +2 -0
- hamuna_quant_cli-0.1.0.dev93.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""hamuna_qmt_market — akquant 自定义 **行情** gateway, 走 bridge_server HTTP API.
|
|
2
|
+
|
|
3
|
+
跟 hamuna_qmt_broker 平行 — 那个管账户/委托/成交/下单/撤单 (5 类交易 endpoint),
|
|
4
|
+
本模块管 snapshot (tick) + history (bar) (2 类行情 endpoint). 两者共用 bridge_server
|
|
5
|
+
同一个 helper 进程, 但 MarketGateway 必须独立 (akquant run_live 要求 market_broker
|
|
6
|
+
跟 trader_broker 分家 — broker=qmt 只给交易通道).
|
|
7
|
+
|
|
8
|
+
调用方式 (run_live 之前先 import 触发 register):
|
|
9
|
+
import hamuna_qmt_market # noqa: F401
|
|
10
|
+
run_live(market_broker="qmt_market", trader_broker="qmt", ...,
|
|
11
|
+
qmt_base_url="http://127.0.0.1:9000",
|
|
12
|
+
qmt_account_id="8888888888", qmt_paper=1)
|
|
13
|
+
|
|
14
|
+
bridge_server 端点:
|
|
15
|
+
GET /data/snapshot?securities=<sym1>,<sym2>,... → 全推实时 tick (QMT get_full_tick)
|
|
16
|
+
GET /data/history?security=<sym>&period=<p>&count=<n>&fq=<fwd/back/None>
|
|
17
|
+
→ 历史 K 线 (QMT get_market_data_ex)
|
|
18
|
+
GET /health → 探活
|
|
19
|
+
|
|
20
|
+
akquant MarketGateway 接口:
|
|
21
|
+
connect / disconnect / subscribe / unsubscribe / on_bar / on_tick / start
|
|
22
|
+
|
|
23
|
+
行情推送语义:
|
|
24
|
+
on_tick: 每个 poll_interval (默认 30s, 用户决策 ≥30s 限流保护) 对每个 subscribed symbol 调一次
|
|
25
|
+
/data/snapshot, 把 tick dict 原样推 callback (QMT 字段: lastPrice,
|
|
26
|
+
open/close/high/low/volume/amount, datetime, ...).
|
|
27
|
+
on_bar: warmup 阶段 backfill count 根历史 bar (period=1d) 一次性推; 之后每
|
|
28
|
+
poll_interval 拉一次当日 bar, 若 OHLCV 任何字段变化就推 (盘中
|
|
29
|
+
"今日累计" bar 持续更新直到收盘 — QMT 默认行为, 适合日线择时).
|
|
30
|
+
|
|
31
|
+
设计原则:
|
|
32
|
+
- thin client: 翻译 + HTTP, 不存业务逻辑
|
|
33
|
+
- 跟 broker 同 _HTTP / register / factory 套路, 不引入新依赖
|
|
34
|
+
- paper 模式: 不下单, 行情全真 (snapshot/history 都是只读) — paper e2e 仍能验
|
|
35
|
+
真桥 7 个端点 (5 交易 + 2 行情)
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
# ============================================================
|
|
42
|
+
# 共享 HTTP client — 跟 broker 模块同一份, 不重复实现
|
|
43
|
+
# ============================================================
|
|
44
|
+
from .qmt_broker import _HTTP, BrokerHTTPError, _emit_paper # noqa: F401 re-used
|
|
45
|
+
|
|
46
|
+
import threading
|
|
47
|
+
import time
|
|
48
|
+
from typing import Any, Callable
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ============================================================
|
|
52
|
+
# MarketGateway 工厂 — 跟 broker 同 closure pattern
|
|
53
|
+
# ============================================================
|
|
54
|
+
def _make_market_gateway() -> Any:
|
|
55
|
+
"""构造 QmtMarketGateway 类. 延迟到第一次调用时再 import akquant.
|
|
56
|
+
|
|
57
|
+
返回的类继承 akquant.gateway.protocols.MarketGateway. 命名上不显式继承 —
|
|
58
|
+
duck check 由 akquant run_live 通过 `isinstance(gw, MarketGateway)` 验证,
|
|
59
|
+
我们直接走显式继承避免 magic.
|
|
60
|
+
|
|
61
|
+
数据流: bridge_server /data/snapshot + /data/history → QmtMarketGateway 解析 →
|
|
62
|
+
akquant DataFeed (`feed.add_tick(Bar/Tick)`) → akquant engine 读 feed → strategy hook.
|
|
63
|
+
MarketGateway 协议暴露的 `on_tick`/`on_bar` 是兼容接口, akquant 实际不调用 —
|
|
64
|
+
它通过 DataFeed 中转. 这里保留这两个 setter 让 broker-level e2e 能直接挂 callback 验证.
|
|
65
|
+
"""
|
|
66
|
+
from akquant.gateway.protocols import MarketGateway
|
|
67
|
+
|
|
68
|
+
class QmtMarketGateway(MarketGateway):
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
feed: Any = None, # akquant DataFeed — 主路径用这个推
|
|
72
|
+
base_url: str = "http://127.0.0.1:9000",
|
|
73
|
+
period: str = "1d",
|
|
74
|
+
bar_count: int = 20,
|
|
75
|
+
poll_interval: float = 30.0, # 默认 30s/帧 (≈2/min) — 用户决策: 不打 QMT 限流; 想更快自己 CLI 透传
|
|
76
|
+
timeout: float = 5.0,
|
|
77
|
+
symbols: list[str] | None = None, # 启动时 auto-subscribe (来自 build_qmt_market 的 symbols)
|
|
78
|
+
) -> None:
|
|
79
|
+
self._feed = feed
|
|
80
|
+
self._http = _HTTP(base_url, timeout=timeout)
|
|
81
|
+
self._period = period
|
|
82
|
+
self._bar_count = bar_count
|
|
83
|
+
self._poll_interval = max(0.05, float(poll_interval))
|
|
84
|
+
# 启动时 auto-subscribe: build_qmt_market(symbols=...) 传来的就是 akquant run_live
|
|
85
|
+
# instruments — 立即装订到 _subscribed, 不依赖 akquant forwarder (实测发现 forwarder
|
|
86
|
+
# 在 functional mode 下安装时机晚于 strategy.subscribe, 错过首次订阅)
|
|
87
|
+
self._subscribed: list[str] = [s for s in (symbols or []) if s]
|
|
88
|
+
self._bar_callback: Callable[[dict[str, Any]], None] | None = None
|
|
89
|
+
self._tick_callback: Callable[[dict[str, Any]], None] | None = None
|
|
90
|
+
self._running = False
|
|
91
|
+
self._thread: threading.Thread | None = None
|
|
92
|
+
self._last_bar_signatures: dict[str, tuple] = {}
|
|
93
|
+
|
|
94
|
+
def connect(self) -> None:
|
|
95
|
+
health = self._http.get("/health", {})
|
|
96
|
+
if not isinstance(health, dict) or health.get("ready") is not True:
|
|
97
|
+
raise BrokerHTTPError(f"bridge /health 未 ready: {health}")
|
|
98
|
+
|
|
99
|
+
def disconnect(self) -> None:
|
|
100
|
+
self._running = False
|
|
101
|
+
if self._thread is not None:
|
|
102
|
+
self._thread.join(timeout=2.0)
|
|
103
|
+
self._thread = None
|
|
104
|
+
|
|
105
|
+
def subscribe(self, symbols) -> None:
|
|
106
|
+
for s in symbols:
|
|
107
|
+
if s not in self._subscribed:
|
|
108
|
+
self._subscribed.append(s)
|
|
109
|
+
|
|
110
|
+
def unsubscribe(self, symbols) -> None:
|
|
111
|
+
removed = set(symbols)
|
|
112
|
+
self._subscribed = [s for s in self._subscribed if s not in removed]
|
|
113
|
+
for s in removed:
|
|
114
|
+
self._last_bar_signatures.pop(s, None)
|
|
115
|
+
|
|
116
|
+
def on_bar(self, callback: Callable[[dict[str, Any]], None]) -> None:
|
|
117
|
+
self._bar_callback = callback
|
|
118
|
+
|
|
119
|
+
def on_tick(self, callback: Callable[[dict[str, Any]], None]) -> None:
|
|
120
|
+
self._tick_callback = callback
|
|
121
|
+
|
|
122
|
+
def start(self) -> None:
|
|
123
|
+
if self._running:
|
|
124
|
+
return
|
|
125
|
+
self._running = True
|
|
126
|
+
self._warmup_bars()
|
|
127
|
+
self._thread = threading.Thread(target=self._poll_loop, daemon=True,
|
|
128
|
+
name="qmt-market-poll")
|
|
129
|
+
self._thread.start()
|
|
130
|
+
|
|
131
|
+
# ---- internal ----
|
|
132
|
+
|
|
133
|
+
def _emit_tick(self, raw: dict[str, Any]) -> None:
|
|
134
|
+
symbol = raw.get("symbol", "")
|
|
135
|
+
tick_obj = _to_tick_obj(raw)
|
|
136
|
+
if self._feed is not None:
|
|
137
|
+
self._feed.add_tick(tick_obj)
|
|
138
|
+
if self._tick_callback:
|
|
139
|
+
self._tick_callback(raw)
|
|
140
|
+
|
|
141
|
+
def _emit_bar(self, raw: dict[str, Any]) -> None:
|
|
142
|
+
bar_obj = _to_bar_obj(raw)
|
|
143
|
+
if self._feed is not None:
|
|
144
|
+
self._feed.add_bar(bar_obj)
|
|
145
|
+
if self._bar_callback:
|
|
146
|
+
self._bar_callback(raw)
|
|
147
|
+
|
|
148
|
+
def _warmup_bars(self) -> None:
|
|
149
|
+
# warmup 期每个 sym 拉 1 次 history — bridge_server 1/min 限流保护下做指数退避
|
|
150
|
+
backoff = 1.0
|
|
151
|
+
for sym in list(self._subscribed):
|
|
152
|
+
while True:
|
|
153
|
+
try:
|
|
154
|
+
bars = self._fetch_bars(sym)
|
|
155
|
+
for bar in bars:
|
|
156
|
+
self._emit_bar(bar)
|
|
157
|
+
if bars:
|
|
158
|
+
self._last_bar_signatures[sym] = _bar_signature(bars[-1])
|
|
159
|
+
backoff = 1.0
|
|
160
|
+
break # 成功 → 下一个 sym
|
|
161
|
+
except Exception as e: # noqa: BLE001
|
|
162
|
+
err_str = str(e)
|
|
163
|
+
print(f"[qmt-market] warmup error {sym}: {err_str}", flush=True)
|
|
164
|
+
if "429" in err_str or "Too much connections" in err_str:
|
|
165
|
+
time.sleep(backoff)
|
|
166
|
+
backoff = min(backoff * 2.0, 60.0)
|
|
167
|
+
continue
|
|
168
|
+
raise # 非 429 错误立即 raise, 不静默吞
|
|
169
|
+
|
|
170
|
+
def _poll_loop(self) -> None:
|
|
171
|
+
backoff = 1.0 # 429 触发时指数退避 (cap 60s, 留 2x 安全余量 vs 30s poll)
|
|
172
|
+
while self._running:
|
|
173
|
+
t0 = time.monotonic()
|
|
174
|
+
rate_limited = False
|
|
175
|
+
try:
|
|
176
|
+
self._poll_once()
|
|
177
|
+
backoff = 1.0 # 成功 — 重置
|
|
178
|
+
except Exception as e: # noqa: BLE001 — polling 不能让线程死
|
|
179
|
+
err_str = str(e)
|
|
180
|
+
print(f"[qmt-market] poll error: {type(e).__name__}: {err_str}", flush=True)
|
|
181
|
+
if "429" in err_str or "Too much connections" in err_str:
|
|
182
|
+
rate_limited = True
|
|
183
|
+
elapsed = time.monotonic() - t0
|
|
184
|
+
sleep_for = max(0.0, self._poll_interval - elapsed)
|
|
185
|
+
if rate_limited:
|
|
186
|
+
time.sleep(backoff)
|
|
187
|
+
backoff = min(backoff * 2.0, 60.0)
|
|
188
|
+
elif sleep_for > 0:
|
|
189
|
+
time.sleep(sleep_for)
|
|
190
|
+
|
|
191
|
+
def _poll_once(self) -> None:
|
|
192
|
+
subs = list(self._subscribed)
|
|
193
|
+
if not subs:
|
|
194
|
+
return
|
|
195
|
+
ticks = self._fetch_ticks(subs)
|
|
196
|
+
for t in ticks:
|
|
197
|
+
self._emit_tick(t)
|
|
198
|
+
for sym in subs:
|
|
199
|
+
bars = self._fetch_bars(sym, count=1)
|
|
200
|
+
if not bars:
|
|
201
|
+
continue
|
|
202
|
+
last = bars[-1]
|
|
203
|
+
sig = _bar_signature(last)
|
|
204
|
+
if self._last_bar_signatures.get(sym) != sig:
|
|
205
|
+
self._last_bar_signatures[sym] = sig
|
|
206
|
+
self._emit_bar(last)
|
|
207
|
+
|
|
208
|
+
def _fetch_ticks(self, symbols: list[str]) -> list[dict[str, Any]]:
|
|
209
|
+
"""GET /data/snapshot?securities=sym1,sym2 → list of tick dict.
|
|
210
|
+
|
|
211
|
+
响应 (无 envelope): {ticks: {sym: tick_dict}, qmt_codes: [...], source: '...'}
|
|
212
|
+
ticks 内的 key 是 QMT 内部格式 (e.g. 600000.XSHG) — 转 6 位 canonical 跟 strategy 一致.
|
|
213
|
+
"""
|
|
214
|
+
securities_str = ",".join(_to_qmt_symbol(s) for s in symbols)
|
|
215
|
+
resp = self._http.get("/data/snapshot", {"securities": securities_str})
|
|
216
|
+
ticks_map = resp.get("ticks", {}) if isinstance(resp, dict) else {}
|
|
217
|
+
ticks: list[dict[str, Any]] = []
|
|
218
|
+
if isinstance(ticks_map, dict):
|
|
219
|
+
for qmt_sym, payload in ticks_map.items():
|
|
220
|
+
if not isinstance(payload, dict):
|
|
221
|
+
continue
|
|
222
|
+
tick = dict(payload)
|
|
223
|
+
# QMT sym (e.g. 600000.XSHG) → canonical 6 位 (e.g. 600000)
|
|
224
|
+
bare = qmt_sym.split(".")[0] if "." in qmt_sym else qmt_sym
|
|
225
|
+
tick.setdefault("symbol", bare)
|
|
226
|
+
ticks.append(tick)
|
|
227
|
+
return ticks
|
|
228
|
+
|
|
229
|
+
def _fetch_bars(self, symbol: str, count: int | None = None) -> list[dict[str, Any]]:
|
|
230
|
+
"""GET /data/history?security=...&period=1d&count=N → list of bar dict (旧→新)."""
|
|
231
|
+
qmt_sym = _to_qmt_symbol(symbol)
|
|
232
|
+
cnt = count if count is not None else self._bar_count
|
|
233
|
+
resp = self._http.get("/data/history", {
|
|
234
|
+
"security": qmt_sym, "period": self._period,
|
|
235
|
+
"count": str(cnt), "fq": "None",
|
|
236
|
+
})
|
|
237
|
+
value = _unwrap_envelope(resp)
|
|
238
|
+
return _bars_from_history(value, symbol)
|
|
239
|
+
|
|
240
|
+
return QmtMarketGateway
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ============================================================
|
|
244
|
+
# helpers — bridge_server envelope + symbol/bar 适配
|
|
245
|
+
# ============================================================
|
|
246
|
+
def _to_qmt_symbol(symbol: str) -> str:
|
|
247
|
+
"""akquant 习惯 sh600000 (前缀小写) → bridge_server 接 6 位 canonical (600000).
|
|
248
|
+
|
|
249
|
+
接受 sh/sz 前缀或纯 6 位,统一返 6 位 uppercase (e.g. sh600000 → "600000").
|
|
250
|
+
bridge_server 对 sh/sz 前缀不识别 (snapshot 返回 qmt_codes 但 ticks={}),
|
|
251
|
+
必须 strip 掉前缀才能拿到真 tick / bar.
|
|
252
|
+
"""
|
|
253
|
+
s = symbol.strip()
|
|
254
|
+
if len(s) >= 7 and s[:2].lower() in ("sh", "sz"):
|
|
255
|
+
return s[2:].upper()
|
|
256
|
+
return s.upper()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _unwrap_envelope(resp: dict[str, Any]) -> Any:
|
|
260
|
+
"""bridge_server envelope {ok, value, request_id, ...} → 业务数据 value 字段.
|
|
261
|
+
|
|
262
|
+
行情端点 (/data/snapshot, /data/history) **不走 envelope** — 直接返业务 dict,
|
|
263
|
+
envelope 是交易端点 (/account, /positions, /place_order ...) 的格式. 这里
|
|
264
|
+
兼容两种: 有 envelope 返 value, 没 envelope 原样返.
|
|
265
|
+
"""
|
|
266
|
+
if not isinstance(resp, dict):
|
|
267
|
+
return resp
|
|
268
|
+
if resp.get("ok") is False:
|
|
269
|
+
raise BrokerHTTPError(f"bridge 返回 ok=false: {resp.get('code')}: {resp.get('message')}")
|
|
270
|
+
return resp.get("value", resp)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _bar_signature(bar: dict[str, Any]) -> tuple:
|
|
274
|
+
"""bar OHLCV 摘要 — 推 on_bar 去重用."""
|
|
275
|
+
return (
|
|
276
|
+
bar.get("open"), bar.get("high"), bar.get("low"),
|
|
277
|
+
bar.get("close"), bar.get("volume"), bar.get("amount"),
|
|
278
|
+
bar.get("datetime") or bar.get("date") or bar.get("time"),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# 解析时间字段 — QMT tick `time` 是 epoch ms, bar `time`/`stime` 是日期字符串 + epoch ms.
|
|
283
|
+
def _parse_bar_timestamp_ns(bar: dict[str, Any]) -> int:
|
|
284
|
+
"""bar dict → timestamp (纳秒). 优先级: time (epoch ms) > stime ('YYYYMMDD')."""
|
|
285
|
+
t = bar.get("time")
|
|
286
|
+
if isinstance(t, (int, float)) and t > 0:
|
|
287
|
+
return int(t * 1_000_000) # ms → ns
|
|
288
|
+
stime = bar.get("stime")
|
|
289
|
+
if isinstance(stime, str) and len(stime) == 8 and stime.isdigit():
|
|
290
|
+
# '20260811' → 当日 00:00 (本地) epoch ns (用 09:30 开盘更准但要时区配置, 简化为 0:00)
|
|
291
|
+
from datetime import datetime, timezone, timedelta
|
|
292
|
+
dt = datetime.strptime(stime, "%Y%m%d").replace(tzinfo=timezone(timedelta(hours=8)))
|
|
293
|
+
return int(dt.timestamp() * 1_000_000_000)
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _parse_tick_timestamp_ns(tick: dict[str, Any]) -> int:
|
|
298
|
+
"""tick dict → timestamp (纳秒). QMT `time` 是 epoch ms."""
|
|
299
|
+
t = tick.get("time")
|
|
300
|
+
if isinstance(t, (int, float)) and t > 0:
|
|
301
|
+
return int(t * 1_000_000) # ms → ns
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _to_bar_obj(raw: dict[str, Any]):
|
|
306
|
+
"""raw bar dict → akquant Bar 构造."""
|
|
307
|
+
from akquant import Bar
|
|
308
|
+
return Bar(
|
|
309
|
+
timestamp=_parse_bar_timestamp_ns(raw),
|
|
310
|
+
open=float(raw.get("open") or 0.0),
|
|
311
|
+
high=float(raw.get("high") or 0.0),
|
|
312
|
+
low=float(raw.get("low") or 0.0),
|
|
313
|
+
close=float(raw.get("close") or 0.0),
|
|
314
|
+
volume=float(raw.get("volume") or 0.0),
|
|
315
|
+
symbol=str(raw.get("symbol") or ""),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _to_tick_obj(raw: dict[str, Any]):
|
|
320
|
+
"""raw tick dict → akquant Tick 构造. QMT lastPrice → price, volume 字段."""
|
|
321
|
+
from akquant import Tick
|
|
322
|
+
return Tick(
|
|
323
|
+
timestamp=_parse_tick_timestamp_ns(raw),
|
|
324
|
+
symbol=str(raw.get("symbol") or ""),
|
|
325
|
+
price=float(raw.get("lastPrice") or raw.get("price") or 0.0),
|
|
326
|
+
volume=float(raw.get("volume") or 0.0),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# QMT DataFrame JSON 序列化的标准 columns (amount, close, high, low, open, ...)
|
|
331
|
+
# 见 bridge_server `/data/history` `dtype=dataframe` 响应.
|
|
332
|
+
_QMT_HISTORY_COLUMNS = ("amount", "close", "high", "low", "open", "openInterest",
|
|
333
|
+
"preClose", "settelementPrice", "stime", "suspendFlag",
|
|
334
|
+
"time", "volume")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _bars_from_history(value: Any, symbol: str) -> list[dict[str, Any]]:
|
|
338
|
+
"""bridge_server /data/history 响应 (无 envelope) → list of OHLCV bar dict.
|
|
339
|
+
|
|
340
|
+
形态 A (QMT DataFrame JSON, 当前默认): {dtype: 'dataframe', columns: [...], records: [[...]]}
|
|
341
|
+
records[i] 与 columns[i] 一一对应, 转 dict 后附加 datetime (stime/time).
|
|
342
|
+
形态 B (旧 envelope 内 bars list): {"bars": [{...}, ...]}
|
|
343
|
+
形态 C (裸 list of dict): [{...}, ...]
|
|
344
|
+
"""
|
|
345
|
+
if not value:
|
|
346
|
+
return []
|
|
347
|
+
if isinstance(value, list):
|
|
348
|
+
return [{**b, "symbol": symbol} for b in value if isinstance(b, dict)]
|
|
349
|
+
if isinstance(value, dict):
|
|
350
|
+
# 形态 A: QMT DataFrame JSON
|
|
351
|
+
if value.get("dtype") == "dataframe" and "records" in value:
|
|
352
|
+
cols = value.get("columns") or _QMT_HISTORY_COLUMNS
|
|
353
|
+
recs = value.get("records") or []
|
|
354
|
+
bars: list[dict[str, Any]] = []
|
|
355
|
+
for rec in recs:
|
|
356
|
+
if not isinstance(rec, list):
|
|
357
|
+
continue
|
|
358
|
+
bar = {col: rec[i] for i, col in enumerate(cols) if i < len(rec)}
|
|
359
|
+
bar["symbol"] = symbol
|
|
360
|
+
# datetime: 优先 stime (YYYYMMDD), 回退 time (epoch ms)
|
|
361
|
+
bar["datetime"] = bar.get("stime") or bar.get("time")
|
|
362
|
+
bars.append(bar)
|
|
363
|
+
return bars
|
|
364
|
+
# 形态 B
|
|
365
|
+
if "bars" in value and isinstance(value["bars"], list):
|
|
366
|
+
return [{**b, "symbol": symbol} for b in value["bars"] if isinstance(b, dict)]
|
|
367
|
+
# 形态 A 旧版: dict-of-list (key=sym 或 single-key dict)
|
|
368
|
+
target = value.get(symbol) or value.get(_to_qmt_symbol(symbol))
|
|
369
|
+
if target is None and len(value) == 1:
|
|
370
|
+
target = next(iter(value.values()))
|
|
371
|
+
if isinstance(target, dict):
|
|
372
|
+
dates = target.get("datetime") or target.get("date") or target.get("time") or []
|
|
373
|
+
n = len(dates)
|
|
374
|
+
if n == 0:
|
|
375
|
+
return []
|
|
376
|
+
return [
|
|
377
|
+
{
|
|
378
|
+
"symbol": symbol,
|
|
379
|
+
"datetime": dates[i] if i < len(dates) else None,
|
|
380
|
+
"open": _at(target.get("open"), i),
|
|
381
|
+
"high": _at(target.get("high"), i),
|
|
382
|
+
"low": _at(target.get("low"), i),
|
|
383
|
+
"close": _at(target.get("close"), i),
|
|
384
|
+
"volume": _at(target.get("volume"), i),
|
|
385
|
+
"amount": _at(target.get("amount"), i),
|
|
386
|
+
}
|
|
387
|
+
for i in range(n)
|
|
388
|
+
]
|
|
389
|
+
return []
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _at(seq: Any, i: int) -> Any:
|
|
393
|
+
"""list-like 安全取第 i 个; 标量直接返."""
|
|
394
|
+
if isinstance(seq, list):
|
|
395
|
+
return seq[i] if i < len(seq) else None
|
|
396
|
+
if hasattr(seq, "__getitem__") and not isinstance(seq, (str, bytes)):
|
|
397
|
+
try:
|
|
398
|
+
return seq[i]
|
|
399
|
+
except (IndexError, KeyError, TypeError):
|
|
400
|
+
return None
|
|
401
|
+
return seq
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ============================================================
|
|
405
|
+
# builder + register
|
|
406
|
+
# ============================================================
|
|
407
|
+
def build_qmt_market(feed: Any, symbols: list[str], use_aggregator: bool, **kwargs: Any) -> Any:
|
|
408
|
+
"""akquant run_live(market_broker="qmt_market", ...) 的 builder 入口.
|
|
409
|
+
|
|
410
|
+
kwargs:
|
|
411
|
+
qmt_base_url (default "http://127.0.0.1:9000")
|
|
412
|
+
market_period (default "1d") — bar 周期 (QMT period: 1d/5m/1m/...)
|
|
413
|
+
market_bar_count (default 20) — warmup 拉几根
|
|
414
|
+
market_poll (default 30.0) — 轮询秒数 (用户决策 2026-08-18: ≥30s)
|
|
415
|
+
market_timeout (default 5.0) — HTTP 超时
|
|
416
|
+
"""
|
|
417
|
+
from akquant.gateway.protocols import GatewayBundle
|
|
418
|
+
|
|
419
|
+
base_url = kwargs.get("qmt_base_url") or kwargs.get("base_url") or "http://127.0.0.1:9000"
|
|
420
|
+
period = kwargs.get("market_period") or kwargs.get("period") or "1d"
|
|
421
|
+
bar_count = int(kwargs.get("market_bar_count") or kwargs.get("bar_count") or 20)
|
|
422
|
+
poll = float(kwargs.get("market_poll") or kwargs.get("poll_interval") or 30.0)
|
|
423
|
+
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 才治网络抖动)
|
|
424
|
+
QmtMarketGateway = _make_market_gateway()
|
|
425
|
+
gw = QmtMarketGateway(
|
|
426
|
+
feed=feed, # akquant DataFeed — 主路径推 tick/bar 用
|
|
427
|
+
base_url=base_url, period=period, bar_count=bar_count,
|
|
428
|
+
poll_interval=poll, timeout=timeout,
|
|
429
|
+
symbols=list(symbols or []), # auto-subscribe (绕开 akquant forwarder 时序 bug)
|
|
430
|
+
)
|
|
431
|
+
return GatewayBundle(
|
|
432
|
+
market_gateway=gw,
|
|
433
|
+
trader_gateway=None, # market-only builder, 不含 trader
|
|
434
|
+
trader_capabilities=None,
|
|
435
|
+
metadata={"broker": "qmt_market", "bridge": "bullettrade_compat",
|
|
436
|
+
"period": period, "bar_count": bar_count, "poll_interval": poll},
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _safe_register() -> None:
|
|
441
|
+
try:
|
|
442
|
+
from akquant.gateway import register_broker
|
|
443
|
+
register_broker("qmt_market", build_qmt_market)
|
|
444
|
+
except ImportError:
|
|
445
|
+
pass
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
_safe_register()
|