hyperquant 0.6__py3-none-any.whl → 0.7__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.
@@ -0,0 +1,354 @@
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
+ def _resolve_instrument(self) -> str | None:
140
+ detail_entries = self.store.detail.find()
141
+ if detail_entries:
142
+ return detail_entries[0].get("symbol")
143
+ return None
144
+
145
+ def _get_detail_entry(self, symbol: str) -> dict[str, Any]:
146
+ detail = self.store.detail.get({"symbol": symbol})
147
+ if not detail:
148
+ raise ValueError(f"Unknown LBank instrument: {symbol}")
149
+ return detail
150
+
151
+ @staticmethod
152
+ def _format_with_step(value: float, step: Any) -> str:
153
+ try:
154
+ step_float = float(step)
155
+ except (TypeError, ValueError): # pragma: no cover - defensive guard
156
+ step_float = 0.0
157
+
158
+ if step_float <= 0:
159
+ return str(value)
160
+
161
+ return fmt_value(value, step_float)
162
+
163
+ async def update_finish_order(
164
+ self,
165
+ *,
166
+ product_group: str = "SwapU",
167
+ page_index: int = 1,
168
+ page_size: int = 200,
169
+ start_time: int | None = None,
170
+ end_time: int | None = None,
171
+ instrument_id: str | None = None,
172
+ ) -> None:
173
+ """Fetch finished orders within the specified time window (default: last hour)."""
174
+
175
+ now_ms = int(time.time() * 1000)
176
+ if end_time is None:
177
+ end_time = now_ms
178
+ if start_time is None:
179
+ start_time = end_time - 60 * 60 * 1000
180
+ if start_time >= end_time:
181
+ raise ValueError("start_time must be earlier than end_time")
182
+
183
+ params: dict[str, Any] = {
184
+ "ProductGroup": product_group,
185
+ "pageIndex": page_index,
186
+ "pageSize": page_size,
187
+ "startTime": start_time,
188
+ "endTime": end_time,
189
+ }
190
+ if instrument_id:
191
+ params["InstrumentID"] = instrument_id
192
+
193
+ await self.store.initialize(
194
+ self.client.get(
195
+ f"{self.front_api}/cfd/cff/v1/FinishOrder",
196
+ params=params,
197
+ headers=self._rest_headers,
198
+ )
199
+ )
200
+
201
+ async def place_order(
202
+ self,
203
+ symbol: str,
204
+ *,
205
+ direction: Literal["buy", "sell", "0", "1"],
206
+ volume: float,
207
+ price: float | None = None,
208
+ order_type: Literal["market", "limit_ioc", "limit_gtc"] = "market",
209
+ offset_flag: Literal["open", "close", "0", "1"] = "open",
210
+ exchange_id: str = "Exchange",
211
+ product_group: str = "SwapU",
212
+ order_proportion: str = "0.0000",
213
+ client_order_id: str | None = None,
214
+ ) -> dict[str, Any]:
215
+ """Create an order using documented REST parameters."""
216
+
217
+ direction_code = self._normalize_direction(direction)
218
+ offset_code = self._normalize_offset(offset_flag)
219
+ price_type_code, order_type_code = self._resolve_order_type(order_type)
220
+
221
+ detail_entry = self._get_detail_entry(symbol)
222
+ volume_str = self._format_with_step(volume, detail_entry.get("step_size"))
223
+ price_str: str | None = None
224
+ if price_type_code == "0":
225
+ if price is None:
226
+ raise ValueError("price is required for limit orders")
227
+ price_str = self._format_with_step(price, detail_entry.get("tick_size"))
228
+
229
+ payload: dict[str, Any] = {
230
+ # "ProductGroup": product_group,
231
+ "InstrumentID": symbol,
232
+ "ExchangeID": exchange_id,
233
+ "Direction": direction_code,
234
+ "OffsetFlag": offset_code,
235
+ "OrderPriceType": price_type_code,
236
+ "OrderType": order_type_code,
237
+ "Volume": volume_str,
238
+ "orderProportion": order_proportion,
239
+ }
240
+
241
+ if price_type_code == "0":
242
+ payload["Price"] = price_str
243
+ elif price is not None:
244
+ # logger.warning("Price is ignored for market orders")
245
+ pass
246
+
247
+ # if client_order_id:
248
+ # payload["LocalID"] = client_order_id
249
+ print(payload)
250
+ res = await self.client.post(
251
+ f"{self.front_api}/cfd/cff/v1/SendOrderInsert",
252
+ json=payload,
253
+ headers=self._rest_headers,
254
+ )
255
+ data = await res.json()
256
+ return self._ensure_ok("place_order", data)
257
+
258
+ async def cancel_order(
259
+ self,
260
+ order_sys_id: str,
261
+ *,
262
+ action_flag: str | int = "1",
263
+ ) -> dict[str, Any]:
264
+ """Cancel an order by OrderSysID."""
265
+
266
+ payload = {"OrderSysID": order_sys_id, "ActionFlag": str(action_flag)}
267
+ res = await self.client.post(
268
+ f"{self.front_api}/cfd/action/v1.0/SendOrderAction",
269
+ json=payload,
270
+ headers=self._rest_headers,
271
+ )
272
+ data = await res.json()
273
+ return self._ensure_ok("cancel_order", data)
274
+
275
+ @staticmethod
276
+ def _ensure_ok(operation: str, data: Any) -> dict[str, Any]:
277
+ if not isinstance(data, dict) or data.get("code") != 200:
278
+ raise RuntimeError(f"{operation} failed: {data}")
279
+ return data.get("data") or {}
280
+
281
+ @staticmethod
282
+ def _normalize_direction(direction: str) -> str:
283
+ mapping = {
284
+ "buy": "0",
285
+ "long": "0",
286
+ "sell": "1",
287
+ "short": "1",
288
+ }
289
+ return mapping.get(str(direction).lower(), str(direction))
290
+
291
+ @staticmethod
292
+ def _normalize_offset(offset: str) -> str:
293
+ mapping = {
294
+ "open": "0",
295
+ "close": "1",
296
+ }
297
+ return mapping.get(str(offset).lower(), str(offset))
298
+
299
+ @staticmethod
300
+ def _resolve_order_type(order_type: str) -> tuple[str, str]:
301
+ mapping = {
302
+ "market": ("4", "1"),
303
+ "limit_ioc": ("0", "1"),
304
+ "limit_gtc": ("0", "0"),
305
+ }
306
+ try:
307
+ return mapping[str(order_type).lower()]
308
+ except KeyError as exc: # pragma: no cover - guard
309
+ raise ValueError(f"Unsupported order_type: {order_type}") from exc
310
+
311
+
312
+ async def sub_orderbook(self, symbols: list[str], limit: int | None = None) -> None:
313
+ """订阅指定交易对的订单簿(遵循 LBank 协议)。
314
+ """
315
+
316
+ async def sub(payload):
317
+ wsapp = self.client.ws_connect(
318
+ self.ws_url,
319
+ hdlr_bytes=self.store.onmessage,
320
+ send_json=payload,
321
+ )
322
+ await wsapp._event.wait()
323
+
324
+ send_jsons = []
325
+ y = 3000000001
326
+ if limit:
327
+ self.store.book.limit = limit
328
+
329
+ for symbol in symbols:
330
+
331
+ info = self.store.detail.get({"symbol": symbol})
332
+ if not info:
333
+ raise ValueError(f"Unknown LBank symbol: {symbol}")
334
+
335
+ tick_size = info['tick_size']
336
+ sub_i = symbol + "_" + str(tick_size) + "_25"
337
+ send_jsons.append(
338
+ {
339
+ "x": 3,
340
+ "y": str(y),
341
+ "a": {"i": sub_i},
342
+ "z": 1,
343
+ }
344
+ )
345
+
346
+ self.store.register_book_channel(str(y), symbol)
347
+ y += 1
348
+
349
+ # Rate limit: max 5 subscriptions per second
350
+ for i in range(0, len(send_jsons), 5):
351
+ batch = send_jsons[i:i+5]
352
+ await asyncio.gather(*(sub(send_json) for send_json in batch))
353
+ if i + 5 < len(send_jsons):
354
+ await asyncio.sleep(0.1)