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,683 @@
|
|
|
1
|
+
"""hamuna_qmt_broker — akquant 自定义 broker, 走 bridge_server HTTP API.
|
|
2
|
+
|
|
3
|
+
设计:
|
|
4
|
+
- 纯交易 broker, 行情走 akquant 现有 `DataFeed` (`market_gateway=None`).
|
|
5
|
+
QMT 行情 (snapshot / history / subscribe) 留给 MarketGateway (下一阶段),
|
|
6
|
+
当前 trader_gateway 只做账户 / 委托 / 成交 / 下单 / 撤单 5 类.
|
|
7
|
+
- HTTP 客户端用 stdlib `urllib.request`, 不引入新依赖.
|
|
8
|
+
- bridge_server 协议: 单 POST/GET /<route>, JSON body, 响应 {ok, code, data, request_id}.
|
|
9
|
+
|
|
10
|
+
调用方式 (run_live 之前先 import 触发 register):
|
|
11
|
+
import hamuna_qmt_broker # noqa: F401
|
|
12
|
+
run_live(broker="qmt", ..., qmt_base_url="http://127.0.0.1:9000",
|
|
13
|
+
qmt_account_id="8888888888", qmt_account_type="stock")
|
|
14
|
+
|
|
15
|
+
设计原则:
|
|
16
|
+
- thin client: 翻译 + HTTP, 不存任何业务逻辑
|
|
17
|
+
- fail fast: HTTP / 解析失败立即抛清晰错误 (不进 retry-loop)
|
|
18
|
+
- 缺 akquant 时模块仍可 import + register 不报错; 真实调用才会 ImportError
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
# ============================================================
|
|
25
|
+
# 延迟 import — 让 self-check 不依赖 akquant 也能跑通
|
|
26
|
+
# ============================================================
|
|
27
|
+
def _import_akquant_gateway():
|
|
28
|
+
try:
|
|
29
|
+
from akquant.gateway import register_broker
|
|
30
|
+
from akquant.gateway.broker_models import (
|
|
31
|
+
BrokerCapability,
|
|
32
|
+
UnifiedAccount,
|
|
33
|
+
UnifiedOrderRequest,
|
|
34
|
+
UnifiedOrderSnapshot,
|
|
35
|
+
UnifiedOrderStatus,
|
|
36
|
+
UnifiedPosition,
|
|
37
|
+
UnifiedTrade,
|
|
38
|
+
)
|
|
39
|
+
from akquant.gateway.protocols import GatewayBundle
|
|
40
|
+
from akquant.gateway.trader_base import TraderGatewayBase
|
|
41
|
+
return {
|
|
42
|
+
"register_broker": register_broker,
|
|
43
|
+
"BrokerCapability": BrokerCapability,
|
|
44
|
+
"UnifiedAccount": UnifiedAccount,
|
|
45
|
+
"UnifiedOrderRequest": UnifiedOrderRequest,
|
|
46
|
+
"UnifiedOrderSnapshot": UnifiedOrderSnapshot,
|
|
47
|
+
"UnifiedOrderStatus": UnifiedOrderStatus,
|
|
48
|
+
"UnifiedPosition": UnifiedPosition,
|
|
49
|
+
"UnifiedTrade": UnifiedTrade,
|
|
50
|
+
"GatewayBundle": GatewayBundle,
|
|
51
|
+
"TraderGatewayBase": TraderGatewayBase,
|
|
52
|
+
}
|
|
53
|
+
except ImportError as e:
|
|
54
|
+
raise ImportError(
|
|
55
|
+
f"hamuna_qmt_broker 需要 akquant>=0.3.41 的 gateway 模块. "
|
|
56
|
+
f"`pip install --upgrade 'akquant>=0.3.41'`. 当前错误: {e}"
|
|
57
|
+
) from e
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ============================================================
|
|
61
|
+
# HTTP client — urllib 包装, 一次 retry on connection error
|
|
62
|
+
# ============================================================
|
|
63
|
+
import json
|
|
64
|
+
import urllib.error
|
|
65
|
+
import urllib.request
|
|
66
|
+
from typing import Any
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BrokerHTTPError(RuntimeError):
|
|
70
|
+
"""bridge_server 返回 ok=False 或 HTTP 状态非 2xx."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _emit_paper(msg: str) -> None:
|
|
74
|
+
"""paper 模式日志 — flush=True 保证 akquant 拉日志实时看到, 不被缓冲."""
|
|
75
|
+
print(f"[PAPER] {msg}", flush=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _HTTP:
|
|
79
|
+
def __init__(self, base_url: str, timeout: float = 30.0) -> None: # ponytail: 默认 30s 容忍 TCP connect 偶发超时 (用户实测 stacktrace 暴露 urllib 5s 在内网抖动下会 TimeoutError) — ceiling = qmt_market.poll_interval (30s), 再高就盖过 poll cycle; upgrade path = 改 requests + urllib3.Retry 自动重试 transient failure
|
|
80
|
+
self.base_url = base_url.rstrip("/")
|
|
81
|
+
self.timeout = float(timeout)
|
|
82
|
+
|
|
83
|
+
def _request(self, method: str, route: str, payload: dict[str, Any] | None) -> dict[str, Any]:
|
|
84
|
+
url = self.base_url + route
|
|
85
|
+
body: bytes | None = None
|
|
86
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
87
|
+
if payload is not None:
|
|
88
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
89
|
+
headers["Content-Type"] = "application/json; charset=utf-8"
|
|
90
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
91
|
+
try:
|
|
92
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
93
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
94
|
+
except urllib.error.HTTPError as e:
|
|
95
|
+
raw = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
|
96
|
+
raise BrokerHTTPError(
|
|
97
|
+
f"bridge_server {method} {route} HTTP {e.code}: {raw[:200]}"
|
|
98
|
+
) from e
|
|
99
|
+
except urllib.error.URLError as e:
|
|
100
|
+
raise BrokerHTTPError(
|
|
101
|
+
f"bridge_server {method} {route} 连接失败: {e.reason} "
|
|
102
|
+
f"(base_url={self.base_url})"
|
|
103
|
+
) from e
|
|
104
|
+
try:
|
|
105
|
+
parsed = json.loads(raw)
|
|
106
|
+
except json.JSONDecodeError as e:
|
|
107
|
+
raise BrokerHTTPError(
|
|
108
|
+
f"bridge_server {method} {route} 返回非 JSON: {raw[:200]}"
|
|
109
|
+
) from e
|
|
110
|
+
if not isinstance(parsed, dict):
|
|
111
|
+
raise BrokerHTTPError(
|
|
112
|
+
f"bridge_server {method} {route} 响应不是 dict: {raw[:200]}"
|
|
113
|
+
)
|
|
114
|
+
if not parsed.get("ok"):
|
|
115
|
+
raise BrokerHTTPError(
|
|
116
|
+
f"bridge_server {method} {route} 失败: "
|
|
117
|
+
f"code={parsed.get('code')!r} message={parsed.get('message') or parsed.get('data')}"
|
|
118
|
+
)
|
|
119
|
+
# bridge_server envelope: {ok, value, request_id, ts}. 业务负载在 `value`.
|
|
120
|
+
return parsed.get("value") if isinstance(parsed.get("value"), dict) else parsed
|
|
121
|
+
|
|
122
|
+
def get(self, route: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
123
|
+
if params:
|
|
124
|
+
from urllib.parse import urlencode
|
|
125
|
+
route = route + ("&" if "?" in route else "?") + urlencode(params)
|
|
126
|
+
return self._request("GET", route, payload=None)
|
|
127
|
+
|
|
128
|
+
def post(self, route: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
129
|
+
return self._request("POST", route, payload=payload)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ============================================================
|
|
133
|
+
# 安全码映射: akquant 侧用 sh600000 / sz000001 / 600000.XSHG, bridge 接受 600000.SH / 600000.XSHG.
|
|
134
|
+
# 转换原则: 已是 .XSHG/.XSHE 形式直接 to_qmt; sh/sz 前缀剥掉; 裸 6 位按首字 (5/6/7/9→SH, 0/1/2/3→SZ) 加 .SH/.SZ.
|
|
135
|
+
# ============================================================
|
|
136
|
+
def _to_qmt_symbol(symbol: str) -> str:
|
|
137
|
+
s = (symbol or "").strip()
|
|
138
|
+
if not s:
|
|
139
|
+
return s
|
|
140
|
+
upper = s.upper()
|
|
141
|
+
if upper.endswith((".XSHG", ".XSHE", ".SH", ".SZ", ".SSE", ".SZE")):
|
|
142
|
+
# 统一转成 bridge 的 .SH / .SZ 形式
|
|
143
|
+
code, suf = s.rsplit(".", 1)
|
|
144
|
+
suf = suf.upper()
|
|
145
|
+
if suf in ("XSHG", "SSE", "SH"):
|
|
146
|
+
return f"{code}.SH"
|
|
147
|
+
if suf in ("XSHE", "SZE", "SZ"):
|
|
148
|
+
return f"{code}.SZ"
|
|
149
|
+
return s
|
|
150
|
+
# 兼容 sh600000 / sz000001
|
|
151
|
+
if upper.startswith(("SH", "SZ")) and len(upper) >= 8:
|
|
152
|
+
head = upper[:2]
|
|
153
|
+
code = upper[2:]
|
|
154
|
+
return f"{code}.{head}"
|
|
155
|
+
# 裸 6 位
|
|
156
|
+
if len(s) == 6 and s.isdigit():
|
|
157
|
+
if s[0] in ("5", "6", "7", "9"):
|
|
158
|
+
return f"{s}.SH"
|
|
159
|
+
if s[0] in ("0", "1", "2", "3"):
|
|
160
|
+
return f"{s}.SZ"
|
|
161
|
+
return s
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _from_qmt_symbol(qmt_symbol: str) -> str:
|
|
165
|
+
"""bridge 返回的 sh/sz 形式 → akquant 友好 (保留 .XSHG/.XSHE, 带 .X 利于识别)."""
|
|
166
|
+
s = (qmt_symbol or "").strip()
|
|
167
|
+
if not s or "." not in s:
|
|
168
|
+
return s
|
|
169
|
+
code, suf = s.rsplit(".", 1)
|
|
170
|
+
suf = suf.upper()
|
|
171
|
+
if suf == "SH":
|
|
172
|
+
return f"{code}.XSHG"
|
|
173
|
+
if suf == "SZ":
|
|
174
|
+
return f"{code}.XSHE"
|
|
175
|
+
return s
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ============================================================
|
|
179
|
+
# QMT 委托状态 (raw_status int) → UnifiedOrderStatus
|
|
180
|
+
# QMT 内部状态码约定 (xtquant 标准, 与 bridge_server 直传):
|
|
181
|
+
# 0=未知 1=待报 2=已报待撤 3=已撤 4=部撤 5=部成 6=已成 7=废单 8=已报未成
|
|
182
|
+
# 实际不同 QMT 版本可能略不同 — 不确定时 fallback "New"
|
|
183
|
+
# ============================================================
|
|
184
|
+
_QMT_ORDER_STATUS_MAP = {
|
|
185
|
+
0: "New", 1: "Submitted", 2: "Submitted", 3: "Cancelled",
|
|
186
|
+
4: "Cancelled", 5: "PartiallyFilled", 6: "Filled", 7: "Rejected",
|
|
187
|
+
8: "Submitted",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _map_order_status(raw: Any) -> Any:
|
|
192
|
+
"""int → UnifiedOrderStatus; 失败返 New."""
|
|
193
|
+
ak = _import_akquant_gateway()
|
|
194
|
+
UnifiedOrderStatus = ak["UnifiedOrderStatus"]
|
|
195
|
+
if isinstance(raw, int) and raw in _QMT_ORDER_STATUS_MAP:
|
|
196
|
+
return UnifiedOrderStatus(_QMT_ORDER_STATUS_MAP[raw])
|
|
197
|
+
return UnifiedOrderStatus.NEW
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ============================================================
|
|
201
|
+
# QmtTraderGateway — akquant TraderGatewayBase 子类
|
|
202
|
+
# ============================================================
|
|
203
|
+
def _make_trader_gateway():
|
|
204
|
+
ak = _import_akquant_gateway()
|
|
205
|
+
TraderGatewayBase = ak["TraderGatewayBase"]
|
|
206
|
+
|
|
207
|
+
class QmtTraderGateway(TraderGatewayBase):
|
|
208
|
+
"""走 bridge_server HTTP 的 akquant trader gateway.
|
|
209
|
+
|
|
210
|
+
kwargs 由 run_live(**kwargs) 透传到 builder, builder 再传到我:
|
|
211
|
+
base_url (default "http://127.0.0.1:9000")
|
|
212
|
+
account_id (必填)
|
|
213
|
+
account_type (default "stock")
|
|
214
|
+
timeout (default 5.0 秒)
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
# ---- 类级别常量: BrokerCapability ----
|
|
218
|
+
_CAP_NAME = "qmt"
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
base_url: str = "http://127.0.0.1:9000",
|
|
223
|
+
account_id: str = "",
|
|
224
|
+
account_type: str = "stock",
|
|
225
|
+
timeout: float = 5.0,
|
|
226
|
+
paper: bool = True,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""
|
|
229
|
+
paper: 默认 True — 安全默认, paper 模式下 place_order / cancel_order
|
|
230
|
+
不发真 HTTP, 走本地 mock. 显式真下单必须 paper=False.
|
|
231
|
+
"""
|
|
232
|
+
super().__init__()
|
|
233
|
+
ak = _import_akquant_gateway()
|
|
234
|
+
self._BrokerCapability = ak["BrokerCapability"]
|
|
235
|
+
self._UnifiedOrderRequest = ak["UnifiedOrderRequest"]
|
|
236
|
+
self._http = _HTTP(base_url, timeout=timeout)
|
|
237
|
+
self._account_id = account_id
|
|
238
|
+
self._account_type = account_type
|
|
239
|
+
self._connected = False
|
|
240
|
+
self._paper = paper
|
|
241
|
+
self._paper_order_seq = 0 # 仅 paper 模式用, mock 订单号递增
|
|
242
|
+
if paper:
|
|
243
|
+
_emit_paper(
|
|
244
|
+
f"qmt broker paper 模式启动 — account={account_id} "
|
|
245
|
+
f"place_order / cancel_order 不发真 HTTP, mock 返 PAPER-NNNNNN"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# ----- 生命周期 -----
|
|
249
|
+
def connect(self) -> None:
|
|
250
|
+
"""调 /account 验证桥活 + 账户配 (bridge_server 没显式 /connect 协议)."""
|
|
251
|
+
data = self._http.post("/account", {
|
|
252
|
+
"account_id": self._account_id,
|
|
253
|
+
"account_type": self._account_type,
|
|
254
|
+
})
|
|
255
|
+
if not isinstance(data, dict) or not data.get("account_id"):
|
|
256
|
+
raise BrokerHTTPError(
|
|
257
|
+
f"connect: /account 返回异常: {data!r}"
|
|
258
|
+
)
|
|
259
|
+
self._connected = True
|
|
260
|
+
|
|
261
|
+
def disconnect(self) -> None:
|
|
262
|
+
self._connected = False
|
|
263
|
+
|
|
264
|
+
def start(self) -> None:
|
|
265
|
+
"""无推模式 (bridge_server 不主动 push). v0.2 加轮询 push."""
|
|
266
|
+
if not self._connected:
|
|
267
|
+
self.connect()
|
|
268
|
+
|
|
269
|
+
# ----- 能力声明 -----
|
|
270
|
+
def get_capabilities(self): # type: ignore[override]
|
|
271
|
+
return self._BrokerCapability(
|
|
272
|
+
broker_name=self._CAP_NAME,
|
|
273
|
+
broker_live=True,
|
|
274
|
+
client_order_id=True,
|
|
275
|
+
order_type=True,
|
|
276
|
+
time_in_force_str=True,
|
|
277
|
+
position_effect=True,
|
|
278
|
+
reduce_only=False,
|
|
279
|
+
position_details=True,
|
|
280
|
+
supports_short_sell=False,
|
|
281
|
+
broker_extra_fields=(
|
|
282
|
+
"strategy_name", "order_remark", "quick_trade",
|
|
283
|
+
"qmt_pr_type",
|
|
284
|
+
),
|
|
285
|
+
supported_position_effects=("auto", "open", "close"),
|
|
286
|
+
features=frozenset({
|
|
287
|
+
"qmt_bridge",
|
|
288
|
+
"bullettrade_compat",
|
|
289
|
+
"http_polling_only", # 标无 push, 给上层知道
|
|
290
|
+
}),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# ----- 下单 -----
|
|
294
|
+
def place_order(self, req): # type: ignore[override]
|
|
295
|
+
if not self._connected:
|
|
296
|
+
raise BrokerHTTPError("place_order: 未 connect, 先调 start()/connect()")
|
|
297
|
+
# paper 模式: 不发真 HTTP, mock 返 PAPER-NNNNNN — e2e 验证不真下单
|
|
298
|
+
if self._paper:
|
|
299
|
+
self._paper_order_seq += 1
|
|
300
|
+
order_id = f"PAPER-{self._paper_order_seq:06d}"
|
|
301
|
+
self.record_broker_order(order_id, req.client_order_id)
|
|
302
|
+
_emit_paper(
|
|
303
|
+
f"place_order 跳过真下单: symbol={req.symbol} side={req.side} "
|
|
304
|
+
f"qty={req.quantity} price={req.price} → mock order_id={order_id}"
|
|
305
|
+
)
|
|
306
|
+
return order_id
|
|
307
|
+
ak = _import_akquant_gateway()
|
|
308
|
+
side = str(req.side).upper()
|
|
309
|
+
# bridge 的 amount 字段 — QMT 用 int, 但 akquant quantity 是 float, 截断到 int.
|
|
310
|
+
amount = int(req.quantity)
|
|
311
|
+
if amount <= 0:
|
|
312
|
+
raise BrokerHTTPError(
|
|
313
|
+
f"place_order: amount 必须 > 0, got {req.quantity}"
|
|
314
|
+
)
|
|
315
|
+
order_type = str(req.order_type or "Market").lower()
|
|
316
|
+
# bridge pr_type: 11=限价 12=市价IOC 13=市价GFD. 默认按 order_type 推断.
|
|
317
|
+
pr_type = int(req.extra.get("qmt_pr_type", 0)) or (
|
|
318
|
+
11 if order_type in ("limit", "limit_if_touched") else 12
|
|
319
|
+
)
|
|
320
|
+
price = float(req.price or 0.0)
|
|
321
|
+
if pr_type != 12 and price <= 0:
|
|
322
|
+
raise BrokerHTTPError(
|
|
323
|
+
f"place_order: 限价单需要 price > 0 (got {price}, pr_type={pr_type})"
|
|
324
|
+
)
|
|
325
|
+
payload = {
|
|
326
|
+
"account_id": self._account_id,
|
|
327
|
+
"account_type": self._account_type,
|
|
328
|
+
"security": _to_qmt_symbol(req.symbol),
|
|
329
|
+
"side": "BUY" if side in ("buy", "long", "b") else "SELL",
|
|
330
|
+
"amount": amount,
|
|
331
|
+
"price": price,
|
|
332
|
+
"pr_type": pr_type,
|
|
333
|
+
"strategy_name": str(req.extra.get("strategy_name", "hamuna_strategy")),
|
|
334
|
+
"order_remark": str(req.extra.get("order_remark") or req.client_order_id)[:24],
|
|
335
|
+
"quick_trade": int(req.extra.get("quick_trade", 0)),
|
|
336
|
+
"client_order_id": req.client_order_id,
|
|
337
|
+
}
|
|
338
|
+
data = self._http.post("/place_order", payload)
|
|
339
|
+
order_id = str(data.get("order_id") or data.get("order_ref") or "")
|
|
340
|
+
if order_id:
|
|
341
|
+
self.record_broker_order(order_id, req.client_order_id)
|
|
342
|
+
# 即便 order_id 空 (bridge 的 submit_unknown), 也返回它拿到的 ref, 让上层知道.
|
|
343
|
+
return order_id or str(data.get("passorder_return") or "")
|
|
344
|
+
|
|
345
|
+
# ----- 撤单 -----
|
|
346
|
+
def cancel_order(self, broker_order_id: str) -> None:
|
|
347
|
+
if not self._connected:
|
|
348
|
+
raise BrokerHTTPError("cancel_order: 未 connect")
|
|
349
|
+
if self._paper:
|
|
350
|
+
_emit_paper(f"cancel_order 跳过真撤单: broker_order_id={broker_order_id}")
|
|
351
|
+
return
|
|
352
|
+
self._http.post("/cancel_order", {
|
|
353
|
+
"account_id": self._account_id,
|
|
354
|
+
"account_type": self._account_type,
|
|
355
|
+
"order_id": broker_order_id,
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
# ----- 查询: 单委托 -----
|
|
359
|
+
def query_order(self, broker_order_id: str): # type: ignore[override]
|
|
360
|
+
if not self._connected:
|
|
361
|
+
return None
|
|
362
|
+
try:
|
|
363
|
+
data = self._http.get("/orders", {
|
|
364
|
+
"account_id": self._account_id,
|
|
365
|
+
"account_type": self._account_type,
|
|
366
|
+
"order_id": broker_order_id,
|
|
367
|
+
})
|
|
368
|
+
except BrokerHTTPError:
|
|
369
|
+
return None
|
|
370
|
+
orders = (data or {}).get("orders") or []
|
|
371
|
+
if not orders:
|
|
372
|
+
return None
|
|
373
|
+
o = orders[0]
|
|
374
|
+
ak = _import_akquant_gateway()
|
|
375
|
+
client_oid = self.client_order_id_for(broker_order_id) or str(
|
|
376
|
+
o.get("qmt_user_order_id") or o.get("order_remark") or ""
|
|
377
|
+
)
|
|
378
|
+
return ak["UnifiedOrderSnapshot"](
|
|
379
|
+
client_order_id=client_oid,
|
|
380
|
+
broker_order_id=str(o.get("order_id") or broker_order_id),
|
|
381
|
+
symbol=_from_qmt_symbol(str(o.get("security") or "")),
|
|
382
|
+
status=_map_order_status(o.get("raw_status")),
|
|
383
|
+
filled_quantity=float(o.get("filled") or 0),
|
|
384
|
+
avg_fill_price=float(o.get("price") or 0.0),
|
|
385
|
+
position_effect=str(
|
|
386
|
+
{"OPEN": "open", "CLOSE": "close"}.get(
|
|
387
|
+
str(o.get("position_effect") or "").upper(), "auto"
|
|
388
|
+
)
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# ----- 查询: 成交 -----
|
|
393
|
+
def query_trades(self, since=None): # type: ignore[override]
|
|
394
|
+
if not self._connected:
|
|
395
|
+
return []
|
|
396
|
+
data = self._http.get("/trades", {
|
|
397
|
+
"account_id": self._account_id,
|
|
398
|
+
"account_type": self._account_type,
|
|
399
|
+
})
|
|
400
|
+
ak = _import_akquant_gateway()
|
|
401
|
+
UnifiedTrade = ak["UnifiedTrade"]
|
|
402
|
+
out: list = []
|
|
403
|
+
for t in (data or {}).get("trades") or []:
|
|
404
|
+
oid = str(t.get("order_id") or "")
|
|
405
|
+
client_oid = self.client_order_id_for(oid) or str(
|
|
406
|
+
t.get("qmt_user_order_id") or t.get("order_remark") or ""
|
|
407
|
+
)
|
|
408
|
+
out.append(UnifiedTrade(
|
|
409
|
+
trade_id=str(t.get("trade_id") or ""),
|
|
410
|
+
broker_order_id=oid,
|
|
411
|
+
client_order_id=client_oid,
|
|
412
|
+
symbol=_from_qmt_symbol(str(t.get("security") or "")),
|
|
413
|
+
side="buy" if str(t.get("side") or "").upper() == "BUY" else "sell",
|
|
414
|
+
quantity=float(t.get("amount") or 0),
|
|
415
|
+
price=float(t.get("price") or 0.0),
|
|
416
|
+
timestamp_ns=int(t.get("timestamp_ns") or 0),
|
|
417
|
+
position_effect="auto",
|
|
418
|
+
))
|
|
419
|
+
return out
|
|
420
|
+
|
|
421
|
+
# ----- 查询: 账户 -----
|
|
422
|
+
def query_account(self): # type: ignore[override]
|
|
423
|
+
if not self._connected:
|
|
424
|
+
return None
|
|
425
|
+
data = self._http.post("/account", {
|
|
426
|
+
"account_id": self._account_id,
|
|
427
|
+
"account_type": self._account_type,
|
|
428
|
+
})
|
|
429
|
+
ak = _import_akquant_gateway()
|
|
430
|
+
return ak["UnifiedAccount"](
|
|
431
|
+
account_id=str(data.get("account_id") or self._account_id),
|
|
432
|
+
equity=float(data.get("total_value") or 0.0),
|
|
433
|
+
cash=float(data.get("cash") or 0.0),
|
|
434
|
+
available_cash=float(data.get("available_cash") or 0.0),
|
|
435
|
+
timestamp_ns=int(data.get("timestamp_ns") or 0),
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# ----- 查询: 持仓 -----
|
|
439
|
+
def query_positions(self): # type: ignore[override]
|
|
440
|
+
if not self._connected:
|
|
441
|
+
return []
|
|
442
|
+
data = self._http.post("/positions", {
|
|
443
|
+
"account_id": self._account_id,
|
|
444
|
+
"account_type": self._account_type,
|
|
445
|
+
})
|
|
446
|
+
ak = _import_akquant_gateway()
|
|
447
|
+
UnifiedPosition = ak["UnifiedPosition"]
|
|
448
|
+
out: list = []
|
|
449
|
+
for p in (data or {}).get("positions") or []:
|
|
450
|
+
qty = int(p.get("amount") or 0)
|
|
451
|
+
out.append(UnifiedPosition(
|
|
452
|
+
symbol=_from_qmt_symbol(str(p.get("security") or "")),
|
|
453
|
+
quantity=float(qty),
|
|
454
|
+
available_quantity=float(p.get("closeable_amount") or 0),
|
|
455
|
+
direction="long" if qty >= 0 else "short",
|
|
456
|
+
today_quantity=0.0,
|
|
457
|
+
yesterday_quantity=0.0,
|
|
458
|
+
avg_price=float(p.get("avg_cost") or 0.0),
|
|
459
|
+
))
|
|
460
|
+
return out
|
|
461
|
+
|
|
462
|
+
# ----- heartbeat -----
|
|
463
|
+
def heartbeat(self) -> bool:
|
|
464
|
+
"""基础探活: 调 /health (无 body), 不更新 _connected."""
|
|
465
|
+
try:
|
|
466
|
+
self._http.get("/health")
|
|
467
|
+
return True
|
|
468
|
+
except BrokerHTTPError:
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
return QmtTraderGateway
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ============================================================
|
|
475
|
+
# builder + register
|
|
476
|
+
# ============================================================
|
|
477
|
+
def build_qmt(
|
|
478
|
+
feed: Any,
|
|
479
|
+
symbols: list[str],
|
|
480
|
+
use_aggregator: bool,
|
|
481
|
+
**kwargs: Any,
|
|
482
|
+
) -> Any:
|
|
483
|
+
"""akquant run_live(broker="qmt", ...) 的 builder 入口.
|
|
484
|
+
|
|
485
|
+
kwargs:
|
|
486
|
+
qmt_base_url (default "http://127.0.0.1:9000")
|
|
487
|
+
qmt_account_id (必填)
|
|
488
|
+
qmt_account_type (default "stock")
|
|
489
|
+
qmt_timeout (default 5.0 秒)
|
|
490
|
+
qmt_paper (default "1"=paper; 显式真下单设 "0" — 默认安全)
|
|
491
|
+
"""
|
|
492
|
+
base_url = kwargs.get("qmt_base_url") or kwargs.get("base_url") or "http://127.0.0.1:9000"
|
|
493
|
+
account_id = kwargs.get("qmt_account_id") or kwargs.get("account_id") or ""
|
|
494
|
+
account_type = kwargs.get("qmt_account_type") or kwargs.get("account_type") or "stock"
|
|
495
|
+
timeout = float(kwargs.get("qmt_timeout") or kwargs.get("timeout") or 30.0) # ponytail: 跟 _HTTP.timeout 默认 30s 保持一致 (caller 显式传 timeout=5.0 会盖过这里默认, 但 build_qmt 不传 timeout 时必须 30s 才治网络抖动)
|
|
496
|
+
paper = _qmt_paper_default(kwargs.get("qmt_paper"))
|
|
497
|
+
if not account_id:
|
|
498
|
+
raise ValueError(
|
|
499
|
+
"hamuna_qmt_broker: 缺少 qmt_account_id — 透传给 run_live(..., qmt_account_id='...')"
|
|
500
|
+
)
|
|
501
|
+
ak = _import_akquant_gateway()
|
|
502
|
+
QmtTraderGateway = _make_trader_gateway()
|
|
503
|
+
trader = QmtTraderGateway(
|
|
504
|
+
base_url=base_url, account_id=account_id, account_type=account_type,
|
|
505
|
+
timeout=timeout, paper=paper,
|
|
506
|
+
)
|
|
507
|
+
return ak["GatewayBundle"](
|
|
508
|
+
market_gateway=None, # 行情走 akquant DataFeed (无 push)
|
|
509
|
+
trader_gateway=trader,
|
|
510
|
+
trader_capabilities=trader.get_capabilities(),
|
|
511
|
+
metadata={"broker": "qmt", "bridge": "bullettrade_compat", "paper": paper},
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _qmt_paper_default(v: Any) -> bool:
|
|
516
|
+
"""CLI 字符串 → paper bool. 默认 True (安全).
|
|
517
|
+
显式 "0"/"false"/"no"/"off" → False (真下单); 其它 (含 None/""/"1"/"true") → True (paper)."""
|
|
518
|
+
if v is None or v == "":
|
|
519
|
+
return True
|
|
520
|
+
s = str(v).strip().lower()
|
|
521
|
+
if s in ("0", "false", "no", "off"):
|
|
522
|
+
return False
|
|
523
|
+
return True
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# 注册 (side-effect import). akquant 未装时不抛 — 让 --help 仍能跑.
|
|
527
|
+
def _safe_register() -> None:
|
|
528
|
+
try:
|
|
529
|
+
ak = _import_akquant_gateway()
|
|
530
|
+
ak["register_broker"]("qmt", build_qmt)
|
|
531
|
+
except ImportError:
|
|
532
|
+
pass
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
_safe_register()
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ============================================================
|
|
539
|
+
# self-check — 不依赖 akquant, 起本地 mock bridge_server, 验证 HTTP shape
|
|
540
|
+
# ============================================================
|
|
541
|
+
if __name__ == "__main__":
|
|
542
|
+
import threading
|
|
543
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
544
|
+
|
|
545
|
+
class _MockBridge(BaseHTTPRequestHandler):
|
|
546
|
+
def log_message(self, fmt, *args):
|
|
547
|
+
pass # 静音
|
|
548
|
+
|
|
549
|
+
def _read_json(self):
|
|
550
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
551
|
+
return json.loads(self.rfile.read(length).decode("utf-8")) if length else {}
|
|
552
|
+
|
|
553
|
+
def _send(self, code, payload):
|
|
554
|
+
body = json.dumps(payload).encode("utf-8")
|
|
555
|
+
self.send_response(code)
|
|
556
|
+
self.send_header("Content-Type", "application/json")
|
|
557
|
+
self.send_header("Content-Length", str(len(body)))
|
|
558
|
+
self.end_headers()
|
|
559
|
+
self.wfile.write(body)
|
|
560
|
+
|
|
561
|
+
def do_GET(self):
|
|
562
|
+
if self.path.startswith("/health"):
|
|
563
|
+
self._send(200, {"ok": True, "data": {"status": "ok"}})
|
|
564
|
+
return
|
|
565
|
+
if self.path.startswith("/orders"):
|
|
566
|
+
self._send(200, {"ok": True, "data": {"orders": [{
|
|
567
|
+
"order_id": "ORD1", "security": "600000.SH", "raw_status": 6,
|
|
568
|
+
"filled": 100, "price": 10.5, "amount": 100,
|
|
569
|
+
"qmt_user_order_id": "coid1",
|
|
570
|
+
}]}})
|
|
571
|
+
return
|
|
572
|
+
if self.path.startswith("/trades"):
|
|
573
|
+
self._send(200, {"ok": True, "data": {"trades": [{
|
|
574
|
+
"trade_id": "TRD1", "order_id": "ORD1", "security": "600000.SH",
|
|
575
|
+
"amount": 100, "price": 10.5, "side": "BUY",
|
|
576
|
+
"qmt_user_order_id": "coid1",
|
|
577
|
+
}]}})
|
|
578
|
+
return
|
|
579
|
+
self._send(404, {"ok": False, "code": "NOT_FOUND"})
|
|
580
|
+
|
|
581
|
+
def do_POST(self):
|
|
582
|
+
data = self._read_json()
|
|
583
|
+
if "/account" in self.path:
|
|
584
|
+
self._send(200, {"ok": True, "data": {
|
|
585
|
+
"account_id": data.get("account_id"), "account_type": "stock",
|
|
586
|
+
"available_cash": 50000.0, "cash": 100000.0, "total_value": 150000.0,
|
|
587
|
+
}})
|
|
588
|
+
return
|
|
589
|
+
if "/positions" in self.path:
|
|
590
|
+
self._send(200, {"ok": True, "data": {"positions": [{
|
|
591
|
+
"security": "600000.SH", "amount": 100, "closeable_amount": 100,
|
|
592
|
+
"avg_cost": 10.0,
|
|
593
|
+
}]}})
|
|
594
|
+
return
|
|
595
|
+
if "/place_order" in self.path:
|
|
596
|
+
self._send(200, {"ok": True, "data": {
|
|
597
|
+
"order_id": "ORD2", "order_ref": "ORD2", "security": "600000.SH",
|
|
598
|
+
"side": data.get("side"), "amount": data.get("amount"),
|
|
599
|
+
"price": data.get("price"),
|
|
600
|
+
}})
|
|
601
|
+
return
|
|
602
|
+
if "/cancel_order" in self.path:
|
|
603
|
+
self._send(200, {"ok": True, "data": {"cancelled": True}})
|
|
604
|
+
return
|
|
605
|
+
self._send(404, {"ok": False, "code": "NOT_FOUND"})
|
|
606
|
+
|
|
607
|
+
server = HTTPServer(("127.0.0.1", 0), _MockBridge)
|
|
608
|
+
port = server.server_address[1]
|
|
609
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
610
|
+
print(f"[self-check] mock bridge_server at http://127.0.0.1:{port}")
|
|
611
|
+
|
|
612
|
+
# 1. symbol 映射 (无 akquant 依赖)
|
|
613
|
+
assert _to_qmt_symbol("sh600000") == "600000.SH"
|
|
614
|
+
assert _to_qmt_symbol("600000.XSHG") == "600000.SH"
|
|
615
|
+
assert _to_qmt_symbol("sz000001") == "000001.SZ"
|
|
616
|
+
assert _to_qmt_symbol("000001.XSHE") == "000001.SZ"
|
|
617
|
+
assert _from_qmt_symbol("600000.SH") == "600000.XSHG"
|
|
618
|
+
assert _from_qmt_symbol("000001.SZ") == "000001.XSHE"
|
|
619
|
+
print("[self-check] OK symbol 映射")
|
|
620
|
+
|
|
621
|
+
# 2. HTTP client — 6 个 endpoint shape 验证
|
|
622
|
+
http = _HTTP(f"http://127.0.0.1:{port}", timeout=2.0)
|
|
623
|
+
acct = http.post("/account", {"account_id": "ACCT", "account_type": "stock"})
|
|
624
|
+
assert acct["available_cash"] == 50000.0, acct
|
|
625
|
+
pos = http.post("/positions", {"account_id": "ACCT"})
|
|
626
|
+
assert pos["positions"][0]["security"] == "600000.SH", pos
|
|
627
|
+
order = http.post("/place_order", {
|
|
628
|
+
"account_id": "ACCT", "security": "600000.SH", "side": "BUY",
|
|
629
|
+
"amount": 100, "price": 10.0,
|
|
630
|
+
})
|
|
631
|
+
assert order["order_id"] == "ORD2", order
|
|
632
|
+
http.post("/cancel_order", {"account_id": "ACCT", "order_id": "ORD2"})
|
|
633
|
+
orders = http.get("/orders", {"account_id": "ACCT", "order_id": "ORD1"})
|
|
634
|
+
assert orders["orders"][0]["raw_status"] == 6
|
|
635
|
+
trades = http.get("/trades", {"account_id": "ACCT"})
|
|
636
|
+
assert trades["trades"][0]["trade_id"] == "TRD1"
|
|
637
|
+
print("[self-check] OK HTTP client shape")
|
|
638
|
+
|
|
639
|
+
# 3. akquant 协议 — 检查 TraderGatewayBase 子类化 + 方法齐
|
|
640
|
+
try:
|
|
641
|
+
ak = _import_akquant_gateway()
|
|
642
|
+
except ImportError as e:
|
|
643
|
+
print(f"[self-check] akquant 未装 ({e.__cause__ or e}) — 跳过协议层验证")
|
|
644
|
+
else:
|
|
645
|
+
QmtTraderGateway = _make_trader_gateway()
|
|
646
|
+
# 实例化 (不连, 只查 shape)
|
|
647
|
+
gw = QmtTraderGateway(
|
|
648
|
+
base_url=f"http://127.0.0.1:{port}", account_id="ACCT", account_type="stock",
|
|
649
|
+
timeout=2.0,
|
|
650
|
+
)
|
|
651
|
+
for name in (
|
|
652
|
+
"connect", "disconnect", "start", "place_order", "cancel_order",
|
|
653
|
+
"query_order", "query_trades", "query_account", "query_positions",
|
|
654
|
+
"get_capabilities", "heartbeat", "on_order", "on_trade",
|
|
655
|
+
"on_execution_report", "record_broker_order", "client_order_id_for",
|
|
656
|
+
):
|
|
657
|
+
assert hasattr(gw, name), f"missing {name}"
|
|
658
|
+
cap = gw.get_capabilities()
|
|
659
|
+
assert cap.broker_name == "qmt"
|
|
660
|
+
assert "qmt_bridge" in cap.features
|
|
661
|
+
print("[self-check] OK akquant 协议点齐 (TraderGatewayBase 子类)")
|
|
662
|
+
# 4. e2e: 真实 connect / place / cancel / query
|
|
663
|
+
gw.connect()
|
|
664
|
+
a = gw.query_account()
|
|
665
|
+
assert a.account_id == "ACCT" and a.available_cash == 50000.0, a
|
|
666
|
+
ps = gw.query_positions()
|
|
667
|
+
assert len(ps) == 1 and ps[0].symbol == "600000.XSHG", ps
|
|
668
|
+
oid = gw.place_order(ak["UnifiedOrderRequest"](
|
|
669
|
+
client_order_id="coid-test", symbol="600000.XSHG", side="buy",
|
|
670
|
+
quantity=100, price=10.0, order_type="Limit",
|
|
671
|
+
))
|
|
672
|
+
assert oid == "ORD2", oid
|
|
673
|
+
gw.cancel_order(oid)
|
|
674
|
+
snap = gw.query_order("ORD1")
|
|
675
|
+
# raw_status=6 → Filled, 但 query_order 不一定拿到 coid 反查 (mock 返回 ORD1 没在 record 里)
|
|
676
|
+
# 测试 record_broker_order + 反查
|
|
677
|
+
gw.record_broker_order("ORD1", "coid-test")
|
|
678
|
+
snap2 = gw.query_order("ORD1")
|
|
679
|
+
assert snap2 is not None and snap2.symbol == "600000.XSHG", snap2
|
|
680
|
+
print("[self-check] OK e2e connect→place→cancel→query")
|
|
681
|
+
|
|
682
|
+
server.shutdown()
|
|
683
|
+
print("[self-check] ALL PASSED")
|