hyperquant 0.8__py3-none-any.whl → 0.9__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.

Potentially problematic release.


This version of hyperquant might be problematic. Click here for more details.

@@ -0,0 +1,487 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from typing import Any, Literal, Sequence
7
+
8
+ import pybotters
9
+
10
+ from .models.coinw import CoinwFuturesDataStore
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class Coinw:
16
+ """CoinW 永续合约客户端(REST + WebSocket)。"""
17
+
18
+ def __init__(
19
+ self,
20
+ client: pybotters.Client,
21
+ *,
22
+ rest_api: str | None = None,
23
+ ws_url: str | None = None,
24
+ web_api: str | None = None,
25
+ ) -> None:
26
+ self.client = client
27
+ self.store = CoinwFuturesDataStore()
28
+
29
+ self.rest_api = rest_api or "https://api.coinw.com"
30
+ self.ws_url_public = ws_url or "wss://ws.futurescw.com/perpum"
31
+ self.ws_url_private = self.ws_url_public
32
+ self.web_api = web_api or "https://futuresapi.coinw.com"
33
+
34
+ self._ws_private: pybotters.ws.WebSocketApp | None = None
35
+ self._ws_private_ready = asyncio.Event()
36
+ self._ws_headers = {
37
+ "Origin": "https://www.coinw.com",
38
+ "Referer": "https://www.coinw.com/",
39
+ "User-Agent": (
40
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
41
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
42
+ ),
43
+ }
44
+
45
+ async def __aenter__(self) -> "Coinw":
46
+ await self.update("detail")
47
+ return self
48
+
49
+ async def __aexit__(self, exc_type, exc, tb) -> None: # pragma: no cover - symmetry
50
+ return None
51
+
52
+ async def update(
53
+ self,
54
+ update_type: Literal[
55
+ "detail",
56
+ "ticker",
57
+ "orders",
58
+ "position",
59
+ "balance",
60
+ "all",
61
+ ] = "all",
62
+ *,
63
+ position_type: Literal["execute", "plan", "planTrigger"] = "execute",
64
+ page: int | None = None,
65
+ page_size: int | None = None,
66
+ instrument: str | None = None,
67
+ ) -> None:
68
+ """刷新本地缓存,使用 CoinW REST API。
69
+
70
+ - detail: ``GET /v1/perpum/instruments`` (公共)
71
+ - ticker: ``GET /v1/perpumPublic/tickers`` (公共)
72
+ - orders: ``GET /v1/perpum/orders/open`` (私有,需要 ``instrument``)
73
+ - position: ``GET /v1/perpum/positions`` (私有,需要 ``instrument``)
74
+ - balance: ``GET /v1/perpum/account/getUserAssets`` (私有)
75
+ """
76
+
77
+ requests: list[Any] = []
78
+
79
+ include_detail = update_type in {"detail", "all"}
80
+ include_ticker = update_type in {"ticker", "all"}
81
+ include_orders = update_type in {"orders", "all"}
82
+ include_position = update_type in {"position", "all"}
83
+ include_balance = update_type in {"balance", "all"}
84
+
85
+ if include_detail:
86
+ requests.append(self.client.get(f"{self.rest_api}/v1/perpum/instruments"))
87
+
88
+ if include_ticker:
89
+ requests.append(self.client.get(f"{self.rest_api}/v1/perpumPublic/tickers"))
90
+
91
+ if include_orders:
92
+ if not instrument:
93
+ raise ValueError("instrument is required when updating orders")
94
+ params: dict[str, Any] = {
95
+ "instrument": instrument,
96
+ "positionType": position_type,
97
+ }
98
+ if page is not None:
99
+ params["page"] = page
100
+ if page_size is not None:
101
+ params["pageSize"] = page_size
102
+ requests.append(
103
+ self.client.get(
104
+ f"{self.rest_api}/v1/perpum/orders/open",
105
+ params=params,
106
+ )
107
+ )
108
+
109
+ if include_position:
110
+ requests.append(
111
+ self.client.get(f"{self.rest_api}/v1/perpum/positions/all")
112
+ )
113
+
114
+ if include_balance:
115
+ requests.append(
116
+ self.client.get(f"{self.rest_api}/v1/perpum/account/getUserAssets")
117
+ )
118
+
119
+ if not requests:
120
+ raise ValueError(f"update_type err: {update_type}")
121
+
122
+ await self.store.initialize(*requests)
123
+
124
+ async def place_order(
125
+ self,
126
+ instrument: str,
127
+ *,
128
+ direction: Literal["long", "short"],
129
+ leverage: int,
130
+ quantity: float | str,
131
+ quantity_unit: Literal[0, 1, 2, "quote", "contract", "base"] = 0,
132
+ position_model: Literal[0, 1, "isolated", "cross"] = 0,
133
+ position_type: Literal["execute", "plan", "planTrigger"] = "execute",
134
+ price: float | None = None,
135
+ trigger_price: float | None = None,
136
+ trigger_type: Literal[0, 1] | None = None,
137
+ stop_loss_price: float | None = None,
138
+ stop_profit_price: float | None = None,
139
+ third_order_id: str | None = None,
140
+ use_almighty_gold: bool | None = None,
141
+ gold_id: int | None = None,
142
+ ) -> dict[str, Any]:
143
+ """``POST /v1/perpum/order`` 下单。"""
144
+
145
+ payload: dict[str, Any] = {
146
+ "instrument": instrument,
147
+ "direction": self._normalize_direction(direction),
148
+ "leverage": int(leverage),
149
+ "quantityUnit": self._normalize_quantity_unit(quantity_unit),
150
+ "quantity": self._format_quantity(quantity),
151
+ "positionModel": self._normalize_position_model(position_model),
152
+ "positionType": position_type,
153
+ }
154
+
155
+ if price is not None:
156
+ payload["openPrice"] = price
157
+ if trigger_price is not None:
158
+ payload["triggerPrice"] = trigger_price
159
+ if trigger_type is not None:
160
+ payload["triggerType"] = int(trigger_type)
161
+ if stop_loss_price is not None:
162
+ payload["stopLossPrice"] = stop_loss_price
163
+ if stop_profit_price is not None:
164
+ payload["stopProfitPrice"] = stop_profit_price
165
+ if third_order_id:
166
+ payload["thirdOrderId"] = third_order_id
167
+ if use_almighty_gold is not None:
168
+ payload["useAlmightyGold"] = int(bool(use_almighty_gold))
169
+ if gold_id is not None:
170
+ payload["goldId"] = int(gold_id)
171
+
172
+ res = await self.client.post(
173
+ f"{self.rest_api}/v1/perpum/order",
174
+ data=payload,
175
+ )
176
+
177
+ data = await res.json()
178
+ return self._ensure_ok("place_order", data)
179
+
180
+ async def close_position(
181
+ self,
182
+ open_id: str | int,
183
+ *,
184
+ position_type: Literal["plan", "planTrigger", "execute"] = "plan",
185
+ close_num: str | float | int | None = None,
186
+ close_rate: str | float | int | None = None,
187
+ order_price: str | float | None = None,
188
+ instrument: str | None = None,
189
+ ) -> dict[str, Any]:
190
+ """关闭单个仓位(``DELETE /v1/perpum/positions``)。
191
+
192
+ Params
193
+ ------
194
+ open_id: ``openId`` / 持仓唯一 ID。
195
+ position_type: 订单类型 ``plan`` / ``planTrigger`` / ``execute``。
196
+ close_num: 按合约数量平仓(与 ``close_rate`` 至少指定其一)。
197
+ close_rate: 按比例平仓(0-1)。
198
+ order_price: 限价平仓时指定价格。
199
+ instrument: 交易品种(部分情况下需要传入,例如限价单)。
200
+ """
201
+
202
+ if close_num is None and close_rate is None:
203
+ raise ValueError("close_num or close_rate must be provided")
204
+
205
+ payload: dict[str, Any] = {
206
+ "id": str(open_id),
207
+ "positionType": position_type,
208
+ }
209
+ if close_num is not None:
210
+ payload["closeNum"] = str(close_num)
211
+ if close_rate is not None:
212
+ payload["closeRate"] = str(close_rate)
213
+ if order_price is not None:
214
+ payload["orderPrice"] = str(order_price)
215
+ if instrument is not None:
216
+ payload["instrument"] = instrument
217
+
218
+ res = await self.client.delete(
219
+ f"{self.rest_api}/v1/perpum/positions",
220
+ data=payload,
221
+ )
222
+ data = await res.json()
223
+ return self._ensure_ok("close_position", data)
224
+
225
+ async def place_order_web(
226
+ self,
227
+ instrument: str,
228
+ *,
229
+ direction: Literal["long", "short"],
230
+ leverage: int | str,
231
+ quantity_unit: Literal[0, 1, 2],
232
+ quantity: str | float | int,
233
+ position_model: Literal[0, 1] = 1,
234
+ position_type: Literal["plan", "planTrigger", "execute"] = 'plan',
235
+ open_price: str | float | None = None,
236
+ contract_type: int = 1,
237
+ data_type: str = "trade_take",
238
+ device_id: str,
239
+ token: str,
240
+ headers: dict[str, str] | None = None,
241
+ ) -> dict[str, Any]:
242
+ """使用 Web 前端接口下单,绕过部分 API 频控策略。
243
+
244
+ 注意此接口需要传入真实浏览器参数,如 ``device_id`` 与 ``token``。
245
+ """
246
+
247
+ if not device_id or not token:
248
+ raise ValueError("device_id and token are required for place_order_web")
249
+
250
+ url = f"{self.web_api}/v1/futuresc/thirdClient/trade/{instrument}/open"
251
+
252
+ payload: dict[str, Any] = {
253
+ "instrument": instrument,
254
+ "direction": direction,
255
+ "leverage": str(leverage),
256
+ "quantityUnit": quantity_unit,
257
+ "quantity": str(quantity),
258
+ "positionModel": position_model,
259
+ "positionType": position_type,
260
+ "contractType": contract_type,
261
+ "dataType": data_type,
262
+ }
263
+ if open_price is not None:
264
+ payload["openPrice"] = str(open_price)
265
+
266
+ base_headers = {
267
+ "accept": "application/json, text/plain, */*",
268
+ "accept-language": "zh_CN",
269
+ "appversion": "100.100.100",
270
+ "cache-control": "no-cache",
271
+ "clienttag": "web",
272
+ "content-type": "application/json",
273
+ "cwdeviceid": device_id,
274
+ "deviceid": device_id,
275
+ "devicename": "Chrome V141.0.0.0 (macOS)",
276
+ "language": "zh_CN",
277
+ "logintoken": token,
278
+ "origin": "https://www.coinw.com",
279
+ "pragma": "no-cache",
280
+ "priority": "u=1, i",
281
+ "referer": "https://www.coinw.com/",
282
+ "sec-ch-ua": '"Microsoft Edge";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
283
+ "sec-ch-ua-mobile": "?0",
284
+ "sec-ch-ua-platform": '"macOS"',
285
+ "sec-fetch-dest": "empty",
286
+ "sec-fetch-mode": "cors",
287
+ "sec-fetch-site": "same-site",
288
+ "selecttype": "USD",
289
+ "systemversion": "macOS 10.15.7",
290
+ "thirdappid": "coinw",
291
+ "thirdapptoken": token,
292
+ "token": token,
293
+ "user-agent": (
294
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
295
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 "
296
+ "Safari/537.36 Edg/141.0.0.0"
297
+ ),
298
+ "withcredentials": "true",
299
+ "x-authorization": token,
300
+ "x-language": "zh_CN",
301
+ "x-locale": "zh_CN",
302
+ "x-requested-with": "XMLHttpRequest",
303
+ }
304
+ if headers:
305
+ base_headers.update(headers)
306
+
307
+ res = await self.client.post(
308
+ url,
309
+ json=payload,
310
+ headers=base_headers,
311
+ auth=None,
312
+ )
313
+ return await res.json()
314
+
315
+ async def cancel_order(self, order_id: str | int) -> dict[str, Any]:
316
+ """``DELETE /v1/perpum/order`` 取消单个订单。"""
317
+
318
+ res = await self.client.delete(
319
+ f"{self.rest_api}/v1/perpum/order",
320
+ data={"id": str(order_id)},
321
+ )
322
+ data = await res.json()
323
+ return self._ensure_ok("cancel_order", data)
324
+
325
+ async def sub_personal(self) -> None:
326
+ """订阅订单、持仓、资产私有频道。"""
327
+
328
+ ws_app = await self._ensure_private_ws()
329
+ payloads = [
330
+ {"event": "sub", "params": {"biz": "futures", "type": "order"}},
331
+ # {"event": "sub", "params": {"biz": "futures", "type": "position"}},
332
+ {"event": "sub", "params": {"biz": "futures", "type": "position_change"}},
333
+ {"event": "sub", "params": {"biz": "futures", "type": "assets"}},
334
+ ]
335
+ for payload in payloads:
336
+ if ws_app.current_ws.closed:
337
+ raise ConnectionError("CoinW private websocket closed before subscription.")
338
+ await ws_app.current_ws.send_json(payload)
339
+ await asyncio.sleep(0.05)
340
+
341
+ async def sub_orderbook(
342
+ self,
343
+ pair_codes: Sequence[str] | str,
344
+ *,
345
+ depth_limit: int | None = 1,
346
+ biz: str = "futures",
347
+ stale_timeout: float = 5,
348
+ ) -> pybotters.ws.WebSocketApp:
349
+ """订阅 ``type=depth`` 订单簿数据,批量控制发送频率。"""
350
+
351
+ if isinstance(pair_codes, str):
352
+ pair_codes = [pair_codes]
353
+
354
+ pair_list = [code for code in pair_codes if code]
355
+ if not pair_list:
356
+ raise ValueError("pair_codes must not be empty")
357
+
358
+ self.store.book.limit = depth_limit
359
+
360
+ subscriptions = [
361
+ {"event": "sub", "params": {"biz": biz, "type": "depth", "pairCode": code}}
362
+ for code in pair_list
363
+ ]
364
+
365
+ ws_app = self.client.ws_connect(
366
+ self.ws_url_public,
367
+ hdlr_json=self.store.onmessage,
368
+ headers=self._ws_headers,
369
+ )
370
+ await ws_app._event.wait()
371
+
372
+ chunk_size = 10
373
+
374
+ async def send_subs(target: pybotters.ws.WebSocketApp) -> None:
375
+ for idx in range(0, len(subscriptions), chunk_size):
376
+ batch = subscriptions[idx : idx + chunk_size]
377
+ for msg in batch:
378
+ await target.current_ws.send_json(msg)
379
+ if idx + chunk_size < len(subscriptions):
380
+ await asyncio.sleep(2.05)
381
+
382
+ await send_subs(ws_app)
383
+
384
+ ws_ref: dict[str, pybotters.ws.WebSocketApp] = {"app": ws_app}
385
+
386
+ async def monitor() -> None:
387
+ poll_interval = 1.0
388
+ while True:
389
+ await asyncio.sleep(poll_interval)
390
+ last_update = self.store.book.last_update
391
+ if not last_update:
392
+ continue
393
+ if time.time() - last_update < stale_timeout:
394
+ continue
395
+
396
+ logger.warning(f"CoinW订单簿超过{stale_timeout:.1f}秒未更新,正在重连。")
397
+ try:
398
+ current = ws_ref["app"]
399
+ if current.current_ws and not current.current_ws.closed:
400
+ await current.current_ws.close()
401
+ except Exception:
402
+ logger.exception("Error closing stale CoinW orderbook websocket")
403
+
404
+ try:
405
+ new_ws = self.client.ws_connect(
406
+ self.ws_url_public,
407
+ hdlr_json=self.store.onmessage,
408
+ headers=self._ws_headers,
409
+ )
410
+ await new_ws._event.wait()
411
+ await send_subs(new_ws)
412
+ ws_ref["app"] = new_ws
413
+ except Exception:
414
+ logger.exception("Failed to reconnect CoinW orderbook websocket")
415
+
416
+ asyncio.create_task(monitor())
417
+
418
+ return ws_app
419
+
420
+ async def _ensure_private_ws(self) -> pybotters.ws.WebSocketApp:
421
+
422
+ ws_app = self.client.ws_connect(
423
+ self.ws_url_private,
424
+ hdlr_json=self.store.onmessage,
425
+ headers=self._ws_headers,
426
+ )
427
+ await ws_app._event.wait()
428
+ await ws_app.current_ws._wait_authtask()
429
+
430
+ return ws_app
431
+
432
+ @staticmethod
433
+ def _normalize_direction(direction: str) -> str:
434
+ allowed = {"long", "short"}
435
+ value = str(direction).lower()
436
+ if value not in allowed:
437
+ raise ValueError(f"Unsupported direction: {direction}")
438
+ return value
439
+
440
+ @staticmethod
441
+ def _normalize_quantity_unit(
442
+ unit: Literal[0, 1, 2, "quote", "contract", "base"],
443
+ ) -> int:
444
+ mapping = {
445
+ 0: 0,
446
+ 1: 1,
447
+ 2: 2,
448
+ "quote": 0,
449
+ "contract": 1,
450
+ "base": 2,
451
+ }
452
+ try:
453
+ return mapping[unit] # type: ignore[index]
454
+ except KeyError as exc: # pragma: no cover - guard
455
+ raise ValueError(f"Unsupported quantity_unit: {unit}") from exc
456
+
457
+ @staticmethod
458
+ def _normalize_position_model(
459
+ model: Literal[0, 1, "isolated", "cross"],
460
+ ) -> int:
461
+ mapping = {
462
+ 0: 0,
463
+ 1: 1,
464
+ "isolated": 0,
465
+ "cross": 1,
466
+ }
467
+ try:
468
+ return mapping[model] # type: ignore[index]
469
+ except KeyError as exc: # pragma: no cover - guard
470
+ raise ValueError(f"Unsupported position_model: {model}") from exc
471
+
472
+ @staticmethod
473
+ def _format_quantity(quantity: float | str) -> str:
474
+ if isinstance(quantity, str):
475
+ return quantity
476
+ return str(quantity)
477
+
478
+ @staticmethod
479
+ def _ensure_ok(operation: str, data: Any) -> dict[str, Any]:
480
+ """CoinW REST 成功时返回 ``{'code': 0, ...}``。"""
481
+
482
+ if not isinstance(data, dict) or data.get("code") != 0:
483
+ raise RuntimeError(f"{operation} failed: {data}")
484
+ payload = data.get("data")
485
+ if isinstance(payload, dict):
486
+ return payload
487
+ return {"data": payload}
@@ -496,6 +496,79 @@ class Lbank:
496
496
  raise RuntimeError(f"{operation} failed: {data}")
497
497
  return data.get("data") or {}
498
498
 
499
+ # https://uuapi.rerrkvifj.com/cfd/agg/v1/sendQryAll
500
+ # {
501
+ # "productGroup": "SwapU",
502
+ # "instrumentID": "BTCUSDT",
503
+ # "asset": "USDT"
504
+ # }
505
+
506
+ async def query_all(self, symbol:str):
507
+ """查询资产信息
508
+
509
+ .. code:: json
510
+
511
+ {
512
+ "fundingRateTimestamp": 28800,
513
+ "isMarketAcount": 0,
514
+ "longMaxVolume": 10000000000000000,
515
+ "role": 2,
516
+ "openingTime": 1609545600000,
517
+ "isCrossMargin": 1,
518
+ "longLeverage": 25,
519
+ "shortLastVolume": 10000000000000000,
520
+ "longLastVolume": 10000000000000000,
521
+ "onTime": 1609459200000,
522
+ "shortMaintenanceMarginRate": "0.0025",
523
+ "state": 3,
524
+ "markedPrice": "111031.3",
525
+ "assetBalance": {
526
+ "reserveAvailable": "0.0",
527
+ "balance": "22.79163408",
528
+ "frozenMargin": "0.0",
529
+ "reserveMode": "0",
530
+ "totalCloseProfit": "-15.982736",
531
+ "available": "22.79163408",
532
+ "crossMargin": "0.0",
533
+ "reserve": "0.0",
534
+ "frozenFee": "0.0",
535
+ "marginAble": "0.0",
536
+ "realAvailable": "22.79163408"
537
+ },
538
+ "longMaxLeverage": 200,
539
+ "shortMaintenanceMarginQuickAmount": "0",
540
+ "shortLastAmount": "10798590",
541
+ "unrealProfitCalType": "2",
542
+ "longLastAmount": "10798590",
543
+ "shortMaxVolume": 10000000000000000,
544
+ "shortLeverage": 25,
545
+ "calMarkedPrice": "111031.3",
546
+ "longMaintenanceMarginRate": "0.0025",
547
+ "wsToken": "fa1d5e0ad94ede6efab6ced66ea5367cfe68c81173863424dc6e8d846d7e723b",
548
+ "shortMaxLeverage": 200,
549
+ "nextFundingRateTimestamp": 1760976000000,
550
+ "longMaintenanceMarginQuickAmount": "0",
551
+ "forbidTrade": false,
552
+ "defaultPositionType": "2",
553
+ "lastPrice": "111027.9",
554
+ "fundingRate": "0.00003598"
555
+ }
556
+
557
+ """
558
+
559
+ payload = {
560
+ "productGroup": "SwapU",
561
+ "instrumentID": symbol,
562
+ "asset": "USDT"
563
+ }
564
+ res = await self.client.post(
565
+ f"{self.front_api}/cfd/agg/v1/sendQryAll",
566
+ json=payload,
567
+ headers=self._rest_headers,
568
+ )
569
+ data = await res.json()
570
+ return self._ensure_ok("query_all", data)
571
+
499
572
 
500
573
  async def set_position_mode(self, mode: Literal["hedge", "oneway"] = "oneway") -> dict[str, Any]:
501
574
  """设置持仓模式到单向持仓或对冲持仓"""