hyperquant 0.6__py3-none-any.whl → 0.8__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.
- hyperquant/broker/auth.py +144 -9
- hyperquant/broker/bitget.py +311 -0
- hyperquant/broker/edgex.py +331 -14
- hyperquant/broker/lbank.py +588 -0
- hyperquant/broker/lib/edgex_sign.py +455 -0
- hyperquant/broker/lib/util.py +22 -0
- hyperquant/broker/models/bitget.py +359 -0
- hyperquant/broker/models/edgex.py +545 -5
- hyperquant/broker/models/lbank.py +557 -0
- hyperquant/broker/ws.py +21 -3
- {hyperquant-0.6.dist-info → hyperquant-0.8.dist-info}/METADATA +1 -1
- {hyperquant-0.6.dist-info → hyperquant-0.8.dist-info}/RECORD +13 -7
- {hyperquant-0.6.dist-info → hyperquant-0.8.dist-info}/WHEEL +0 -0
@@ -0,0 +1,588 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import itertools
|
5
|
+
import logging
|
6
|
+
import time
|
7
|
+
from typing import Any, Iterable, Literal
|
8
|
+
|
9
|
+
import pybotters
|
10
|
+
|
11
|
+
from .models.lbank import LbankDataStore
|
12
|
+
from .lib.util import fmt_value
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
# https://ccapi.rerrkvifj.com 似乎是spot的api
|
17
|
+
# https://uuapi.rerrkvifj.com 似乎是合约的api
|
18
|
+
|
19
|
+
|
20
|
+
class Lbank:
|
21
|
+
"""LBank public market-data client (REST + WS)."""
|
22
|
+
|
23
|
+
def __init__(
|
24
|
+
self,
|
25
|
+
client: pybotters.Client,
|
26
|
+
*,
|
27
|
+
front_api: str | None = None,
|
28
|
+
rest_api: str | None = None,
|
29
|
+
ws_url: str | None = None,
|
30
|
+
) -> None:
|
31
|
+
self.client = client
|
32
|
+
self.store = LbankDataStore()
|
33
|
+
self.front_api = front_api or "https://uuapi.rerrkvifj.com"
|
34
|
+
self.rest_api = rest_api or "https://api.lbkex.com"
|
35
|
+
self.ws_url = ws_url or "wss://uuws.rerrkvifj.com/ws/v3"
|
36
|
+
self._req_id = itertools.count(int(time.time() * 1000))
|
37
|
+
self._ws_app = None
|
38
|
+
self._rest_headers = {"source": "4", "versionflage": "true"}
|
39
|
+
|
40
|
+
async def __aenter__(self) -> "Lbank":
|
41
|
+
await self.update("detail")
|
42
|
+
return self
|
43
|
+
|
44
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
45
|
+
pass
|
46
|
+
|
47
|
+
async def update(
|
48
|
+
self,
|
49
|
+
update_type: Literal["detail", "balance", "position", "orders", "orders_finish", "all"] = "all",
|
50
|
+
*,
|
51
|
+
product_group: str = "SwapU",
|
52
|
+
exchange_id: str = "Exchange",
|
53
|
+
asset: str = "USDT",
|
54
|
+
instrument_id: str | None = None,
|
55
|
+
page_index: int = 1,
|
56
|
+
page_size: int = 1000,
|
57
|
+
) -> None:
|
58
|
+
"""Refresh local caches via REST endpoints.
|
59
|
+
|
60
|
+
Parameters mirror the documented REST API default arguments.
|
61
|
+
"""
|
62
|
+
|
63
|
+
requests: list[Any] = []
|
64
|
+
|
65
|
+
include_detail = update_type in {"detail", "all"}
|
66
|
+
include_orders = update_type in {"orders", "all"}
|
67
|
+
include_position = update_type in {"position", "all"}
|
68
|
+
include_balance = update_type in {"balance", "all"}
|
69
|
+
|
70
|
+
if update_type == "orders_finish":
|
71
|
+
await self.update_finish_order(
|
72
|
+
product_group=product_group,
|
73
|
+
page_index=page_index,
|
74
|
+
page_size=page_size,
|
75
|
+
)
|
76
|
+
return
|
77
|
+
|
78
|
+
if include_detail:
|
79
|
+
requests.append(
|
80
|
+
self.client.post(
|
81
|
+
f"{self.front_api}/cfd/agg/v1/instrument",
|
82
|
+
json={"ProductGroup": product_group},
|
83
|
+
headers=self._rest_headers,
|
84
|
+
)
|
85
|
+
)
|
86
|
+
|
87
|
+
if include_orders:
|
88
|
+
requests.append(
|
89
|
+
self.client.get(
|
90
|
+
f"{self.front_api}/cfd/query/v1.0/Order",
|
91
|
+
params={
|
92
|
+
"ProductGroup": product_group,
|
93
|
+
"ExchangeID": exchange_id,
|
94
|
+
"pageIndex": page_index,
|
95
|
+
"pageSize": page_size,
|
96
|
+
},
|
97
|
+
headers=self._rest_headers,
|
98
|
+
)
|
99
|
+
)
|
100
|
+
|
101
|
+
if include_position:
|
102
|
+
requests.append(
|
103
|
+
self.client.get(
|
104
|
+
f"{self.front_api}/cfd/query/v1.0/Position",
|
105
|
+
params={
|
106
|
+
"ProductGroup": product_group,
|
107
|
+
"Valid": 1,
|
108
|
+
"pageIndex": page_index,
|
109
|
+
"pageSize": page_size,
|
110
|
+
},
|
111
|
+
headers=self._rest_headers,
|
112
|
+
)
|
113
|
+
)
|
114
|
+
|
115
|
+
if include_balance:
|
116
|
+
resolved_instrument = instrument_id or self._resolve_instrument()
|
117
|
+
if not resolved_instrument:
|
118
|
+
raise ValueError(
|
119
|
+
"instrument_id is required to query balance; call update('detail') first or provide instrument_id explicitly."
|
120
|
+
)
|
121
|
+
self.store.balance.set_asset(asset)
|
122
|
+
requests.append(
|
123
|
+
self.client.post(
|
124
|
+
f"{self.front_api}/cfd/agg/v1/sendQryAll",
|
125
|
+
json={
|
126
|
+
"productGroup": product_group,
|
127
|
+
"instrumentID": resolved_instrument,
|
128
|
+
"asset": asset,
|
129
|
+
},
|
130
|
+
headers=self._rest_headers,
|
131
|
+
)
|
132
|
+
)
|
133
|
+
|
134
|
+
if not requests:
|
135
|
+
raise ValueError(f"update_type err: {update_type}")
|
136
|
+
|
137
|
+
await self.store.initialize(*requests)
|
138
|
+
|
139
|
+
async def query_trade(
|
140
|
+
self,
|
141
|
+
order_id: str | None = None,
|
142
|
+
*,
|
143
|
+
product_group: str = "SwapU",
|
144
|
+
page_index: int = 1,
|
145
|
+
page_size: int = 20,
|
146
|
+
) -> list[dict[str, Any]]:
|
147
|
+
"""Fetch trade executions linked to a given OrderSysID.
|
148
|
+
|
149
|
+
Example response payload::
|
150
|
+
|
151
|
+
[
|
152
|
+
{
|
153
|
+
"TradeUnitID": "e1b03fb1-6849-464f-a",
|
154
|
+
"ProductGroup": "SwapU",
|
155
|
+
"CloseProfit": 0,
|
156
|
+
"BusinessNo": 1001770339345505,
|
157
|
+
"TradeID": "1000162046503720",
|
158
|
+
"PositionID": "1000632926272299",
|
159
|
+
"DeriveSource": "0",
|
160
|
+
"OrderID": "",
|
161
|
+
"Direction": "0",
|
162
|
+
"InstrumentID": "SOLUSDT",
|
163
|
+
"OffsetFlag": "0",
|
164
|
+
"Remark": "def",
|
165
|
+
"DdlnTime": "0",
|
166
|
+
"UseMargin": 0.054213,
|
167
|
+
"Currency": "USDT",
|
168
|
+
"Turnover": 5.4213,
|
169
|
+
"SettlementGroup": "SwapU",
|
170
|
+
"Leverage": 100,
|
171
|
+
"OrderSysID": "1000632948114584",
|
172
|
+
"ExchangeID": "Exchange",
|
173
|
+
"AccountID": "e1b03fb1-6849-464f-a986-94b9a6e625e6",
|
174
|
+
"TradeTime": 1760161085,
|
175
|
+
"Fee": 0.00325278,
|
176
|
+
"OrderPrice": 180.89,
|
177
|
+
"InsertTime": 1760161085,
|
178
|
+
"MemberID": "e1b03fb1-6849-464f-a986-94b9a6e625e6",
|
179
|
+
"MatchRole": "1",
|
180
|
+
"ClearCurrency": "USDT",
|
181
|
+
"Price": 180.71,
|
182
|
+
"Volume": 0.03,
|
183
|
+
"OpenPrice": 182.94,
|
184
|
+
"MasterAccountID": "",
|
185
|
+
"PriceCurrency": "USDT",
|
186
|
+
"FeeCurrency": "USDT"
|
187
|
+
}
|
188
|
+
]
|
189
|
+
"""
|
190
|
+
|
191
|
+
if not order_id:
|
192
|
+
raise ValueError("order_id is required to query order executions")
|
193
|
+
|
194
|
+
params = {
|
195
|
+
"ProductGroup": product_group,
|
196
|
+
"OrderSysID": order_id,
|
197
|
+
"pageIndex": page_index,
|
198
|
+
"pageSize": page_size,
|
199
|
+
}
|
200
|
+
|
201
|
+
res = await self.client.get(
|
202
|
+
f"{self.front_api}/cfd/query/v1.0/Trade",
|
203
|
+
params=params,
|
204
|
+
headers=self._rest_headers,
|
205
|
+
)
|
206
|
+
data = await res.json()
|
207
|
+
payload = self._ensure_ok("query_trade", data)
|
208
|
+
|
209
|
+
if isinstance(payload, dict):
|
210
|
+
rows = payload.get("data")
|
211
|
+
if isinstance(rows, list):
|
212
|
+
return rows
|
213
|
+
elif isinstance(payload, list): # pragma: no cover - defensive fallback
|
214
|
+
return payload
|
215
|
+
|
216
|
+
return []
|
217
|
+
|
218
|
+
async def query_order(
|
219
|
+
self,
|
220
|
+
order_id: str | None = None,
|
221
|
+
*,
|
222
|
+
product_group: str = "SwapU",
|
223
|
+
page_index: int = 1,
|
224
|
+
page_size: int = 20,
|
225
|
+
) -> dict[str, Any]:
|
226
|
+
"""
|
227
|
+
返回值示例:
|
228
|
+
|
229
|
+
.. code:: json
|
230
|
+
|
231
|
+
{
|
232
|
+
"order_id": "1000632478428573",
|
233
|
+
"instrument_id": "SOLUSDT",
|
234
|
+
"position_id": "1000632478428573",
|
235
|
+
"direction": "0",
|
236
|
+
"offset_flag": "0",
|
237
|
+
"trade_time": 1760123456,
|
238
|
+
"avg_price": 182.5,
|
239
|
+
"volume": 0.03,
|
240
|
+
"turnover": 5.475,
|
241
|
+
"fee": 0.003285,
|
242
|
+
"trade_count": 1
|
243
|
+
}
|
244
|
+
|
245
|
+
如果没有订单成交返回
|
246
|
+
{
|
247
|
+
"order_id": "1000632478428573",
|
248
|
+
"trade_count": 0
|
249
|
+
}
|
250
|
+
"""
|
251
|
+
|
252
|
+
if not order_id:
|
253
|
+
raise ValueError("order_id is required to query order statistics")
|
254
|
+
|
255
|
+
trades = await self.query_trade(
|
256
|
+
order_id,
|
257
|
+
product_group=product_group,
|
258
|
+
page_index=page_index,
|
259
|
+
page_size=page_size,
|
260
|
+
)
|
261
|
+
|
262
|
+
if not trades:
|
263
|
+
return {
|
264
|
+
"order_id": order_id,
|
265
|
+
"trade_count": 0,
|
266
|
+
}
|
267
|
+
|
268
|
+
def _to_float(value: Any) -> float:
|
269
|
+
try:
|
270
|
+
return float(value)
|
271
|
+
except (TypeError, ValueError):
|
272
|
+
return 0.0
|
273
|
+
|
274
|
+
total_volume = sum(_to_float(trade.get("Volume")) for trade in trades)
|
275
|
+
total_turnover = sum(_to_float(trade.get("Turnover")) for trade in trades)
|
276
|
+
total_fee = sum(_to_float(trade.get("Fee")) for trade in trades)
|
277
|
+
|
278
|
+
avg_price = total_turnover / total_volume if total_volume else None
|
279
|
+
last_trade = trades[-1]
|
280
|
+
|
281
|
+
return {
|
282
|
+
"order_id": order_id,
|
283
|
+
"instrument_id": last_trade.get("InstrumentID"),
|
284
|
+
"position_id": last_trade.get("PositionID"),
|
285
|
+
"direction": last_trade.get("Direction"),
|
286
|
+
"offset_flag": last_trade.get("OffsetFlag"),
|
287
|
+
"trade_time": last_trade.get("TradeTime"),
|
288
|
+
"avg_price": avg_price,
|
289
|
+
"volume": total_volume,
|
290
|
+
"turnover": total_turnover,
|
291
|
+
"fee": total_fee,
|
292
|
+
"trade_count": len(trades),
|
293
|
+
}
|
294
|
+
|
295
|
+
def _resolve_instrument(self) -> str | None:
|
296
|
+
detail_entries = self.store.detail.find()
|
297
|
+
if detail_entries:
|
298
|
+
return detail_entries[0].get("symbol")
|
299
|
+
return None
|
300
|
+
|
301
|
+
def _get_detail_entry(self, symbol: str) -> dict[str, Any]:
|
302
|
+
detail = self.store.detail.get({"symbol": symbol})
|
303
|
+
if not detail:
|
304
|
+
raise ValueError(f"Unknown LBank instrument: {symbol}")
|
305
|
+
return detail
|
306
|
+
|
307
|
+
@staticmethod
|
308
|
+
def _format_with_step(value: float, step: Any) -> str:
|
309
|
+
try:
|
310
|
+
step_float = float(step)
|
311
|
+
except (TypeError, ValueError): # pragma: no cover - defensive guard
|
312
|
+
step_float = 0.0
|
313
|
+
|
314
|
+
if step_float <= 0:
|
315
|
+
return str(value)
|
316
|
+
|
317
|
+
return fmt_value(value, step_float)
|
318
|
+
|
319
|
+
async def update_finish_order(
|
320
|
+
self,
|
321
|
+
*,
|
322
|
+
product_group: str = "SwapU",
|
323
|
+
page_index: int = 1,
|
324
|
+
page_size: int = 200,
|
325
|
+
start_time: int | None = None,
|
326
|
+
end_time: int | None = None,
|
327
|
+
instrument_id: str | None = None,
|
328
|
+
) -> None:
|
329
|
+
"""Fetch finished orders within the specified time window (default: last hour)."""
|
330
|
+
|
331
|
+
now_ms = int(time.time() * 1000)
|
332
|
+
if end_time is None:
|
333
|
+
end_time = now_ms
|
334
|
+
if start_time is None:
|
335
|
+
start_time = end_time - 60 * 60 * 1000
|
336
|
+
if start_time >= end_time:
|
337
|
+
raise ValueError("start_time must be earlier than end_time")
|
338
|
+
|
339
|
+
params: dict[str, Any] = {
|
340
|
+
"ProductGroup": product_group,
|
341
|
+
"pageIndex": page_index,
|
342
|
+
"pageSize": page_size,
|
343
|
+
"startTime": start_time,
|
344
|
+
"endTime": end_time,
|
345
|
+
}
|
346
|
+
if instrument_id:
|
347
|
+
params["InstrumentID"] = instrument_id
|
348
|
+
|
349
|
+
await self.store.initialize(
|
350
|
+
self.client.get(
|
351
|
+
f"{self.front_api}/cfd/cff/v1/FinishOrder",
|
352
|
+
params=params,
|
353
|
+
headers=self._rest_headers,
|
354
|
+
)
|
355
|
+
)
|
356
|
+
|
357
|
+
async def place_order(
|
358
|
+
self,
|
359
|
+
symbol: str,
|
360
|
+
*,
|
361
|
+
direction: Literal["buy", "sell", "0", "1"],
|
362
|
+
volume: float,
|
363
|
+
price: float | None = None,
|
364
|
+
order_type: Literal["market", "limit_ioc", "limit_gtc"] = "market",
|
365
|
+
offset_flag: Literal["open", "close", "0", "1"] = "open",
|
366
|
+
exchange_id: str = "Exchange",
|
367
|
+
product_group: str = "SwapU",
|
368
|
+
order_proportion: str = "0.0000",
|
369
|
+
client_order_id: str | None = None,
|
370
|
+
) -> dict[str, Any]:
|
371
|
+
"""Create an order using documented REST parameters.
|
372
|
+
|
373
|
+
返回示例:
|
374
|
+
|
375
|
+
.. code:: json
|
376
|
+
|
377
|
+
{
|
378
|
+
"offsetFlag": "5",
|
379
|
+
"orderType": "1",
|
380
|
+
"reserveMode": "0",
|
381
|
+
"fee": "0.0066042",
|
382
|
+
"frozenFee": "0",
|
383
|
+
"ddlnTime": "0",
|
384
|
+
"userID": "lbank_exchange_user",
|
385
|
+
"masterAccountID": "",
|
386
|
+
"exchangeID": "Exchange",
|
387
|
+
"accountID": "e1b03fb1-6849-464f-a986-94b9a6e625e6",
|
388
|
+
"orderSysID": "1000633129818889",
|
389
|
+
"volumeRemain": "0",
|
390
|
+
"price": "183.36",
|
391
|
+
"businessValue": "1760183423813",
|
392
|
+
"frozenMargin": "0",
|
393
|
+
"instrumentID": "SOLUSDT",
|
394
|
+
"posiDirection": "2",
|
395
|
+
"volumeMode": "1",
|
396
|
+
"volume": "0.06",
|
397
|
+
"insertTime": "1760183423",
|
398
|
+
"copyMemberID": "",
|
399
|
+
"position": "0.06",
|
400
|
+
"tradePrice": "183.45",
|
401
|
+
"leverage": "100",
|
402
|
+
"businessResult": "",
|
403
|
+
"availableUse": "0",
|
404
|
+
"orderStatus": "1",
|
405
|
+
"openPrice": "182.94",
|
406
|
+
"frozenMoney": "0",
|
407
|
+
"remark": "def",
|
408
|
+
"reserveUse": "0",
|
409
|
+
"sessionNo": "41",
|
410
|
+
"isCrossMargin": "1",
|
411
|
+
"closeProfit": "0.0306",
|
412
|
+
"businessNo": "1001770756852986", # 订单有成交会并入仓位 businessNo
|
413
|
+
"relatedOrderSysID": "",
|
414
|
+
"positionID": "1000632926272299",
|
415
|
+
"mockResp": false,
|
416
|
+
"deriveSource": "0",
|
417
|
+
"copyOrderID": "",
|
418
|
+
"currency": "USDT",
|
419
|
+
"turnover": "11.007",
|
420
|
+
"frontNo": "-68",
|
421
|
+
"direction": "1",
|
422
|
+
"orderPriceType": "4",
|
423
|
+
"volumeCancled": "0",
|
424
|
+
"updateTime": "1760183423",
|
425
|
+
"localID": "1000633129818889",
|
426
|
+
"volumeTraded": "0.06",
|
427
|
+
"appid": "WEB",
|
428
|
+
"tradeUnitID": "e1b03fb1-6849-464f-a",
|
429
|
+
"businessType": "P",
|
430
|
+
"memberID": "e1b03fb1-6849-464f-a986-94b9a6e625e6",
|
431
|
+
"timeCondition": "0",
|
432
|
+
"copyProfit": "0"
|
433
|
+
}
|
434
|
+
|
435
|
+
"""
|
436
|
+
|
437
|
+
direction_code = self._normalize_direction(direction)
|
438
|
+
offset_code = self._normalize_offset(offset_flag)
|
439
|
+
price_type_code, order_type_code = self._resolve_order_type(order_type)
|
440
|
+
|
441
|
+
detail_entry = self._get_detail_entry(symbol)
|
442
|
+
volume_str = self._format_with_step(volume, detail_entry.get("step_size"))
|
443
|
+
price_str: str | None = None
|
444
|
+
if price_type_code == "0":
|
445
|
+
if price is None:
|
446
|
+
raise ValueError("price is required for limit orders")
|
447
|
+
price_str = self._format_with_step(price, detail_entry.get("tick_size"))
|
448
|
+
|
449
|
+
payload: dict[str, Any] = {
|
450
|
+
# "ProductGroup": product_group,
|
451
|
+
"InstrumentID": symbol,
|
452
|
+
"ExchangeID": exchange_id,
|
453
|
+
"Direction": direction_code,
|
454
|
+
"OffsetFlag": offset_code,
|
455
|
+
"OrderPriceType": price_type_code,
|
456
|
+
"OrderType": order_type_code,
|
457
|
+
"Volume": volume_str,
|
458
|
+
"orderProportion": order_proportion,
|
459
|
+
}
|
460
|
+
|
461
|
+
if price_type_code == "0":
|
462
|
+
payload["Price"] = price_str
|
463
|
+
elif price is not None:
|
464
|
+
# logger.warning("Price is ignored for market orders")
|
465
|
+
pass
|
466
|
+
|
467
|
+
|
468
|
+
res = await self.client.post(
|
469
|
+
f"{self.front_api}/cfd/cff/v1/SendOrderInsert",
|
470
|
+
json=payload,
|
471
|
+
headers=self._rest_headers,
|
472
|
+
)
|
473
|
+
data = await res.json()
|
474
|
+
return self._ensure_ok("place_order", data)
|
475
|
+
|
476
|
+
async def cancel_order(
|
477
|
+
self,
|
478
|
+
order_sys_id: str,
|
479
|
+
*,
|
480
|
+
action_flag: str | int = "1",
|
481
|
+
) -> dict[str, Any]:
|
482
|
+
"""Cancel an order by OrderSysID."""
|
483
|
+
|
484
|
+
payload = {"OrderSysID": order_sys_id, "ActionFlag": str(action_flag)}
|
485
|
+
res = await self.client.post(
|
486
|
+
f"{self.front_api}/cfd/action/v1.0/SendOrderAction",
|
487
|
+
json=payload,
|
488
|
+
headers=self._rest_headers,
|
489
|
+
)
|
490
|
+
data = await res.json()
|
491
|
+
return self._ensure_ok("cancel_order", data)
|
492
|
+
|
493
|
+
@staticmethod
|
494
|
+
def _ensure_ok(operation: str, data: Any) -> dict[str, Any]:
|
495
|
+
if not isinstance(data, dict) or data.get("code") != 200:
|
496
|
+
raise RuntimeError(f"{operation} failed: {data}")
|
497
|
+
return data.get("data") or {}
|
498
|
+
|
499
|
+
|
500
|
+
async def set_position_mode(self, mode: Literal["hedge", "oneway"] = "oneway") -> dict[str, Any]:
|
501
|
+
"""设置持仓模式到单向持仓或对冲持仓"""
|
502
|
+
|
503
|
+
mode_code = "2" if mode == "oneway" else "1"
|
504
|
+
payload = {
|
505
|
+
"PositionType": mode_code,
|
506
|
+
}
|
507
|
+
res = await self.client.post(
|
508
|
+
f"{self.front_api}/cfd/action/v1.0/SendMemberAction",
|
509
|
+
json=payload,
|
510
|
+
headers=self._rest_headers,
|
511
|
+
)
|
512
|
+
data = await res.json()
|
513
|
+
return self._ensure_ok("set_position_mode", data)
|
514
|
+
|
515
|
+
@staticmethod
|
516
|
+
def _normalize_direction(direction: str) -> str:
|
517
|
+
mapping = {
|
518
|
+
"buy": "0",
|
519
|
+
"long": "0",
|
520
|
+
"sell": "1",
|
521
|
+
"short": "1",
|
522
|
+
}
|
523
|
+
return mapping.get(str(direction).lower(), str(direction))
|
524
|
+
|
525
|
+
@staticmethod
|
526
|
+
def _normalize_offset(offset: str) -> str:
|
527
|
+
mapping = {
|
528
|
+
"open": "0",
|
529
|
+
"close": "1",
|
530
|
+
}
|
531
|
+
return mapping.get(str(offset).lower(), str(offset))
|
532
|
+
|
533
|
+
@staticmethod
|
534
|
+
def _resolve_order_type(order_type: str) -> tuple[str, str]:
|
535
|
+
mapping = {
|
536
|
+
"market": ("4", "1"),
|
537
|
+
"limit_ioc": ("0", "1"),
|
538
|
+
"limit_gtc": ("0", "0"),
|
539
|
+
}
|
540
|
+
try:
|
541
|
+
return mapping[str(order_type).lower()]
|
542
|
+
except KeyError as exc: # pragma: no cover - guard
|
543
|
+
raise ValueError(f"Unsupported order_type: {order_type}") from exc
|
544
|
+
|
545
|
+
|
546
|
+
async def sub_orderbook(self, symbols: list[str], limit: int | None = None) -> None:
|
547
|
+
"""订阅指定交易对的订单簿(遵循 LBank 协议)。
|
548
|
+
"""
|
549
|
+
|
550
|
+
async def sub(payload):
|
551
|
+
wsapp = self.client.ws_connect(
|
552
|
+
self.ws_url,
|
553
|
+
hdlr_bytes=self.store.onmessage,
|
554
|
+
send_json=payload,
|
555
|
+
)
|
556
|
+
await wsapp._event.wait()
|
557
|
+
|
558
|
+
send_jsons = []
|
559
|
+
y = 3000000001
|
560
|
+
if limit:
|
561
|
+
self.store.book.limit = limit
|
562
|
+
|
563
|
+
for symbol in symbols:
|
564
|
+
|
565
|
+
info = self.store.detail.get({"symbol": symbol})
|
566
|
+
if not info:
|
567
|
+
raise ValueError(f"Unknown LBank symbol: {symbol}")
|
568
|
+
|
569
|
+
tick_size = info['tick_size']
|
570
|
+
sub_i = symbol + "_" + str(tick_size) + "_25"
|
571
|
+
send_jsons.append(
|
572
|
+
{
|
573
|
+
"x": 3,
|
574
|
+
"y": str(y),
|
575
|
+
"a": {"i": sub_i},
|
576
|
+
"z": 1,
|
577
|
+
}
|
578
|
+
)
|
579
|
+
|
580
|
+
self.store.register_book_channel(str(y), symbol)
|
581
|
+
y += 1
|
582
|
+
|
583
|
+
# Rate limit: max 5 subscriptions per second
|
584
|
+
for i in range(0, len(send_jsons), 5):
|
585
|
+
batch = send_jsons[i:i+5]
|
586
|
+
await asyncio.gather(*(sub(send_json) for send_json in batch))
|
587
|
+
if i + 5 < len(send_jsons):
|
588
|
+
await asyncio.sleep(0.3)
|