hyperquant 0.5__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,500 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from decimal import Decimal
5
+ import hashlib
6
+ import math
7
+ import random
8
+ import re
9
+ import time
10
+ from typing import Any, Iterable, Literal
11
+
12
+ import pybotters
13
+
14
+ from .models.edgex import EdgexDataStore
15
+ from .lib.edgex_sign import LimitOrderMessage, LimitOrderSigner
16
+ from .lib.util import fmt_value
17
+
18
+ def gen_client_id():
19
+ # 1. 生成 [0,1) 的浮点数
20
+ r = random.random()
21
+ # 2. 转成字符串
22
+ s = str(r) # e.g. "0.123456789"
23
+ # 3. 去掉 "0."
24
+ digits = s[2:]
25
+ # 4. 去掉前导 0
26
+ digits = re.sub(r"^0+", "", digits)
27
+ return digits
28
+
29
+
30
+ def calc_nonce(client_order_id: str) -> int:
31
+ digest = hashlib.sha256(client_order_id.encode()).hexdigest()
32
+ return int(digest[:8], 16)
33
+
34
+ def bignumber_to_string(x: Decimal) -> str:
35
+ # normalize 去掉尾随零,然后用 f 格式避免科学计数法
36
+ s = format(x.normalize(), "f")
37
+ # 去掉小数点后多余的 0
38
+ if "." in s:
39
+ s = s.rstrip("0").rstrip(".")
40
+ return s
41
+
42
+ class Edgex:
43
+ """
44
+ Edgex 公共 API (HTTP/WS) 封装。
45
+
46
+ 说明
47
+ - 当前仅包含公共行情数据(不包含私有接口)。
48
+ - 订单簿频道命名规则:``depth.{contractId}.{level}``。
49
+ 成功订阅后,服务器会先推送一次完整快照(depthType=SNAPSHOT),之后持续推送增量(depthType=CHANGED)。
50
+ 解析后的结果存入 ``EdgexDataStore.book``。
51
+
52
+ 参数
53
+ - client: ``pybotters.Client`` 实例
54
+ - api_url: REST 基地址;默认使用 Edgex 官方 testnet 站点
55
+ - ws_url: WebSocket 基地址;如不提供,则默认使用官方文档地址。
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ client: pybotters.Client,
61
+ *,
62
+ api_url: str | None = None,
63
+ ) -> None:
64
+ self.client = client
65
+ self.store = EdgexDataStore()
66
+ # 公共端点可能因环境/地区不同而变化,允许外部覆盖。
67
+ self.api_url = api_url or "https://pro.edgex.exchange"
68
+ self.ws_url = "wss://quote.edgex.exchange"
69
+ self.userid = None
70
+ self.eth_address = None
71
+ self.l2key = None
72
+
73
+ api = self.client._session.__dict__['_apis'].get("edgex") # type: ignore
74
+ if api:
75
+ self.l2key = api[2].split("-")[1]
76
+
77
+ async def __aenter__(self) -> "Edgex":
78
+ # 初始化基础合约元数据,便于后续使用 tickSize 等字段。
79
+ await self.update_detail()
80
+ await self.sync_user()
81
+ return self
82
+
83
+ async def sync_user(self) -> dict[str, Any]:
84
+ # https://pro.edgex.exchange/api/v1/private/user/getUserInfo
85
+ # https://pro.edgex.exchange/api/v1/private/account/getAccountPage?size=100
86
+ # url = self._resolve_api_path("/api/v1/private/user/getUserInfo")
87
+ # url = self._resolve_api_path("/api/v1/private/account/getAccountPage")
88
+
89
+ res = await self.client.get(f'{self.api_url}/api/v1/private/account/getAccountPage?size=100')
90
+
91
+ data = await res.json()
92
+
93
+ # 重新取 userId ethAddress accountId
94
+ data = data.get("data", {})
95
+ accounts = data.get("dataList", [])
96
+ if accounts:
97
+ account = accounts[0]
98
+ self.userid = account.get("userId")
99
+ self.eth_address = account.get("ethAddress")
100
+ self.accountid = account.get("id")
101
+ else:
102
+ raise ValueError("No account data found in response")
103
+
104
+ async def sub_personal(self) -> None:
105
+ """订阅用户相关的私有频道(需要登录)。"""
106
+ await self.client.ws_connect(
107
+ f"{self.ws_url}/api/v1/private/ws?accountId={self.accountid}&timestamp=" + str(int(time.time() * 1000)),
108
+ hdlr_json=self.store.onmessage,
109
+ )
110
+
111
+ async def __aexit__(
112
+ self,
113
+ exc_type: type[BaseException] | None,
114
+ exc: BaseException | None,
115
+ tb: BaseException | None,
116
+ ) -> None:
117
+ # Edgex 当前没有需要关闭的资源;保持接口与 Ourbit 等类一致。
118
+ return None
119
+
120
+ async def update_detail(self) -> dict[str, Any]:
121
+ """Fetch and cache contract metadata via the public REST endpoint."""
122
+
123
+ await self.store.initialize(
124
+ self.client.get(f'{self.api_url}/api/v1/public/meta/getMetaData'),
125
+ )
126
+
127
+ async def update(
128
+ self,
129
+ update_type: Literal["balance", "position", "orders", "ticker", "all"] = "all",
130
+ *,
131
+ contract_id: str | None = None,
132
+ ) -> None:
133
+ """使用 REST 刷新本地缓存的账户资产、持仓、活动订单与 24h 行情。"""
134
+
135
+ requires_account = {"balance", "position", "orders", "all"}
136
+ if update_type in requires_account and not getattr(self, "accountid", None):
137
+ raise ValueError("accountid not set; call sync_user() before update().")
138
+
139
+ account_asset_url = None
140
+ active_orders_url = None
141
+ if update_type in requires_account:
142
+ account_asset_url = (
143
+ f"{self.api_url}/api/v1/private/account/getAccountAsset"
144
+ f"?accountId={self.accountid}"
145
+ )
146
+ active_orders_url = (
147
+ f"{self.api_url}/api/v1/private/order/getActiveOrderPage"
148
+ f"?accountId={self.accountid}&size=200"
149
+ )
150
+
151
+ ticker_url = f"{self.api_url}/api/v1/public/quote/getTicker"
152
+ if contract_id:
153
+ ticker_url = f"{ticker_url}?contractId={contract_id}"
154
+
155
+ url_map: dict[str, list[str]] = {
156
+ "balance": [account_asset_url] if account_asset_url else [],
157
+ "position": [account_asset_url] if account_asset_url else [],
158
+ "orders": [active_orders_url] if active_orders_url else [],
159
+ "ticker": [ticker_url],
160
+ "all": [
161
+ *(url for url in (account_asset_url, active_orders_url) if url),
162
+ ticker_url,
163
+ ],
164
+ }
165
+
166
+ try:
167
+ urls = url_map[update_type]
168
+ except KeyError:
169
+ raise ValueError(f"update_type err: {update_type}")
170
+
171
+ # 直接传协程进去,initialize 会自己 await
172
+ await self.store.initialize(*(self.client.get(url) for url in urls))
173
+
174
+
175
+
176
+ async def sub_orderbook(
177
+ self,
178
+ contract_ids: str | Iterable[str] | None = None,
179
+ *,
180
+ symbols: str | Iterable[str] | None = None,
181
+ level: int = 15,
182
+ ws_url: str | None = None,
183
+ ) -> None:
184
+ """订阅指定合约 ID 或交易对名的订单簿(遵循 Edgex 协议)。
185
+
186
+ 规范
187
+ - 默认 WS 端点:wss://quote.edgex.exchange(可通过参数/实例覆盖)
188
+ - 每个频道的订阅报文:
189
+ {"type": "subscribe", "channel": "depth.{contractId}.{level}"}
190
+ - 服务端在订阅成功后,会先推送一次快照,再持续推送增量。
191
+ """
192
+
193
+ ids: list[str] = []
194
+ if contract_ids is not None:
195
+ if isinstance(contract_ids, str):
196
+ ids.extend([contract_ids])
197
+ else:
198
+ ids.extend(contract_ids)
199
+
200
+ if symbols is not None:
201
+ if isinstance(symbols, str):
202
+ lookup_symbols = [symbols]
203
+ else:
204
+ lookup_symbols = list(symbols)
205
+
206
+ for symbol in lookup_symbols:
207
+ matches = self.store.detail.find({"contractName": symbol})
208
+ if not matches:
209
+ raise ValueError(f"Unknown Edgex symbol: {symbol}")
210
+ ids.append(str(matches[0]["contractId"]))
211
+
212
+ if not ids:
213
+ raise ValueError("contract_ids or symbols must be provided")
214
+
215
+ channels = [f"depth.{cid}.{level}" for cid in ids]
216
+
217
+ # 优先使用参数 ws_url,其次使用实例的 ws_url,最后使用默认地址。
218
+ url = f"{self.ws_url}/api/v1/public/ws?timestamp=" + str(int(time.time() * 1000))
219
+
220
+ # 根据文档:每个频道一条订阅指令,允许一次发送多个订阅对象。
221
+ payload = [{"type": "subscribe", "channel": ch} for ch in channels]
222
+
223
+ wsapp = self.client.ws_connect(url, send_json=payload, hdlr_json=self.store.onmessage)
224
+ # 等待 WS 完成握手再返回,确保订阅报文成功发送。
225
+ await wsapp._event.wait()
226
+
227
+ async def sub_ticker(
228
+ self,
229
+ contract_ids: str | Iterable[str] | None = None,
230
+ *,
231
+ symbols: str | Iterable[str] | None = None,
232
+ all_contracts: bool = False,
233
+ periodic: bool = False,
234
+ ws_url: str | None = None,
235
+ ) -> None:
236
+ """订阅 24 小时行情推送。
237
+
238
+ 参数
239
+ - contract_ids / symbols: 指定单个或多个合约;二者至少提供一个。
240
+ - all_contracts: 订阅 ``ticker.all``(或 ``ticker.all.1s``)。
241
+ - periodic: 与 ``all_contracts`` 配合,true 则订阅 ``ticker.all.1s``。
242
+ """
243
+
244
+ channels: list[str] = []
245
+
246
+ if all_contracts:
247
+ channel = "ticker.all.1s" if periodic else "ticker.all"
248
+ channels.append(channel)
249
+ else:
250
+ ids: list[str] = []
251
+ if contract_ids is not None:
252
+ if isinstance(contract_ids, str):
253
+ ids.append(contract_ids)
254
+ else:
255
+ ids.extend(contract_ids)
256
+
257
+ if symbols is not None:
258
+ if isinstance(symbols, str):
259
+ lookup_symbols = [symbols]
260
+ else:
261
+ lookup_symbols = list(symbols)
262
+
263
+ for symbol in lookup_symbols:
264
+ matches = self.store.detail.find({"contractName": symbol})
265
+ if not matches:
266
+ raise ValueError(f"Unknown Edgex symbol: {symbol}")
267
+ ids.append(str(matches[0]["contractId"]))
268
+
269
+ if not ids:
270
+ raise ValueError("Provide contract_ids/symbols or set all_contracts=True")
271
+
272
+ channels.extend(f"ticker.{cid}" for cid in ids)
273
+
274
+ url = ws_url or f"{self.ws_url}/api/v1/public/ws?timestamp=" + str(int(time.time() * 1000))
275
+ payload = [{"type": "subscribe", "channel": ch} for ch in channels]
276
+ print(payload)
277
+ wsapp = self.client.ws_connect(url, send_json=payload, hdlr_json=self.store.onmessage)
278
+ await wsapp._event.wait()
279
+
280
+ def _fmt_price(self, symbol: str, price: float) -> str:
281
+ o = self.store.detail.get({"contractName": symbol})
282
+ if not o:
283
+ raise ValueError(f"Unknown Edgex symbol: {symbol}")
284
+ tick = float(o.get("tickSize"))
285
+ return fmt_value(price, float(tick))
286
+
287
+ def _fmt_size(self, symbol: str, size: float) -> str:
288
+ o = self.store.detail.get({"contractName": symbol})
289
+ if not o:
290
+ raise ValueError(f"Unknown Edgex symbol: {symbol}")
291
+ step = float(o.get("stepSize"))
292
+ return fmt_value(size, float(step))
293
+
294
+
295
+ async def place_order(
296
+ self,
297
+ symbol: str,
298
+ side: Literal["buy", "sell"],
299
+ price: float = None,
300
+ quantity: float = None,
301
+ order_type: Literal["market", "limit_ioc", "limit_gtc"] = "limit_ioc",
302
+ usdt_amount: float = None,
303
+ ):
304
+ """下单接口(私有 REST)。
305
+ 返回值order_id: str
306
+ """
307
+
308
+ # 前端请求模板
309
+ args = {
310
+ "price": "210.00",
311
+ "size": "1.0",
312
+ "type": "LIMIT",
313
+ "timeInForce": "GOOD_TIL_CANCEL",
314
+ "reduceOnly": False,
315
+ "isPositionTpsl": False,
316
+ "isSetOpenTp": False,
317
+ "isSetOpenSl": False,
318
+ "accountId": "663528067938910372",
319
+ "contractId": "10000003",
320
+ "side": "BUY",
321
+ "triggerPrice": "",
322
+ "triggerPriceType": "LAST_PRICE",
323
+ "clientOrderId": "39299826149407513",
324
+ "expireTime": "1760352231536",
325
+ "l2Nonce": "1872301",
326
+ "l2Value": "210",
327
+ "l2Size": "1.0",
328
+ "l2LimitFee": "1",
329
+ "l2ExpireTime": "1761129831536",
330
+ "l2Signature": "03c4d84c30586b12ab9fec939a875201e58dac9a0391f15eb6118ab2fb50464804ce38b19cc5e07c973fc66b449bec0274058ea2d012c1c7a580f805d2c7a1d3",
331
+ "extraType": "",
332
+ "extraDataJson": "",
333
+ "symbol": "SOLUSD",
334
+ "showEqualValInput": False,
335
+ "maxSellQTY": 1, # 不需要特别计算, 服务器不校验
336
+ "maxBuyQTY": 1 # 不需要特别计算, 服务器不校验
337
+ }
338
+
339
+ try:
340
+ size = Decimal(self._fmt_size(symbol, quantity))
341
+ price = Decimal(self._fmt_price(symbol, price))
342
+ except (ValueError, TypeError):
343
+ raise ValueError("failed to parse size or price")
344
+
345
+ if 'gtc' in order_type:
346
+ args['timeInForce'] = "GOOD_TIL_CANCEL"
347
+ if 'ioc' in order_type:
348
+ args['timeInForce'] = "IMMEDIATE_OR_CANCEL"
349
+ if 'limit' in order_type:
350
+ args['type'] = "LIMIT"
351
+ if 'market' in order_type:
352
+ args['type'] = "MARKET"
353
+
354
+ if side == 'buy':
355
+ price = price * 10
356
+ else:
357
+ tick_size = self.store.detail.get({'contractName': symbol}).get("tickSize")
358
+ price = Decimal(tick_size)
359
+
360
+ if not self.l2key or not self.userid:
361
+ raise ValueError("L2 key or userId is not set. Ensure API keys are correctly configured.")
362
+
363
+
364
+ collateral_coin = self.store.app.get({'appName': 'edgeX'})
365
+
366
+ c = self.store.detail.get({'contractName': symbol})
367
+ if not c:
368
+ raise ValueError(f"Unknown Edgex symbol: {symbol}")
369
+ hex_resolution = c.get("starkExResolution", "0x0")
370
+ hex_resolution = hex_resolution.replace("0x", "")
371
+
372
+ try:
373
+ resolution_int = int(hex_resolution, 16)
374
+ resolution = Decimal(resolution_int)
375
+ except (ValueError, TypeError):
376
+ raise ValueError("failed to parse hex resolution")
377
+
378
+ client_order_id = gen_client_id()
379
+
380
+ # Calculate values
381
+ value_dm:Decimal = price * size
382
+
383
+ amount_synthetic = int(size * resolution)
384
+ amount_collateral = int(value_dm * Decimal("1000000")) # Shift 6 decimal places
385
+
386
+
387
+ # Calculate fee based on order type (maker/taker)
388
+ try:
389
+ fee_rate = Decimal(c.get("defaultTakerFeeRate", "0"))
390
+ except (ValueError, TypeError):
391
+ raise ValueError("failed to parse fee rate")
392
+
393
+ # Calculate fee amount in decimal with ceiling to integer
394
+ amount_fee_dm = Decimal(str(math.ceil(float(value_dm * fee_rate))))
395
+ amount_fee_str = str(amount_fee_dm)
396
+
397
+ # Convert to the required integer format for the protocol
398
+ amount_fee = int(amount_fee_dm * Decimal("1000000")) # Shift 6 decimal places
399
+
400
+ nonce = calc_nonce(client_order_id)
401
+ now = int(time.time() * 1000)
402
+ l2_expire_time = int(now + 2592e6) # 30 天后
403
+ expireTime = int(l2_expire_time - 7776e5) # 提前 9 天
404
+
405
+
406
+ # Calculate signature using asset IDs from metadata
407
+ expire_time_unix = int(l2_expire_time // (60 * 60 * 1000))
408
+
409
+ asset_id_synthetic = c.get("starkExSyntheticAssetId")
410
+
411
+ act_id = self.accountid
412
+
413
+ message = LimitOrderMessage(
414
+ asset_id_synthetic=asset_id_synthetic, # SOLUSD
415
+ asset_id_collateral=collateral_coin.get("starkExCollateralStarkExAssetId"), # USDT
416
+ asset_id_fee=collateral_coin.get("starkExCollateralStarkExAssetId"),
417
+ is_buy= side=='buy', # isBuyingSynthetic
418
+ amount_synthetic=amount_synthetic, # quantumsAmountSynthetic
419
+ amount_collateral=amount_collateral, # quantumsAmountCollateral
420
+ amount_fee=amount_fee, # quantumsAmountFee
421
+ nonce=int(nonce), # nonce
422
+ position_id=int(act_id), # positionId
423
+ expiration_epoch_hours=int(expire_time_unix), # 此处也比较重要 # TODO: 计算
424
+ )
425
+
426
+ # 取 L2 私钥
427
+
428
+
429
+ signer = LimitOrderSigner(self.l2key)
430
+ hash_hex, signature_hex = signer.sign(message)
431
+ value_str = bignumber_to_string(value_dm)
432
+
433
+ price_str = str(price) if 'limit' in order_type else "0"
434
+
435
+ args.update({
436
+ 'price': price_str,
437
+ 'size': str(float(size)),
438
+ 'side': side.upper(),
439
+ 'accountId': str(act_id),
440
+ 'contractId': str(c.get("contractId")),
441
+ 'clientOrderId': client_order_id,
442
+ 'expireTime': str(expireTime),
443
+ 'l2ExpireTime': str(l2_expire_time),
444
+ 'l2Nonce': str(nonce),
445
+ 'l2Value': value_str,
446
+ 'l2Size': str(float(size)),
447
+ 'l2LimitFee': amount_fee_str,
448
+ 'l2Signature': signature_hex,
449
+ 'symbol': symbol
450
+ })
451
+
452
+
453
+
454
+ res = await self.client.post(
455
+ f'{self.api_url}/api/v1/private/order/createOrder',
456
+ data=args
457
+ )
458
+
459
+ data:dict = await res.json()
460
+ if data.get("code") != "SUCCESS": # pragma: no cover - defensive guard
461
+ raise RuntimeError(f"Failed to place Edgex order: {data}")
462
+
463
+ latency = int(data.get("responseTime",0)) - int(data.get("requestTime",0))
464
+ print(latency)
465
+ order_id = data.get("data", {}).get("orderId")
466
+ return order_id
467
+
468
+ async def cancel_orders(self, order_ids: list[str]) -> dict[str, Any]:
469
+ """
470
+ 批量撤单接口(私有 REST)。
471
+
472
+ .. code:: json
473
+ {
474
+ "665186247567737508": "SUCCESS"
475
+ }
476
+ """
477
+
478
+ args = {
479
+ "orderIdList": order_ids,
480
+ "accountId": str(self.accountid),
481
+ }
482
+ res = await self.client.post(
483
+ f'{self.api_url}/api/v1/private/order/cancelOrderById',
484
+ data=args
485
+ )
486
+ data: dict = await res.json()
487
+ print(data)
488
+ if data.get("code") != "SUCCESS": # pragma: no cover - defensive guard
489
+ raise RuntimeError(f"Failed to cancel Edgex orders: {data}")
490
+ return data.get("data", {}).get("cancelResultMap", {})
491
+
492
+
493
+ async def __aexit__(
494
+ self,
495
+ exc_type: type[BaseException] | None,
496
+ exc: BaseException | None,
497
+ tb: BaseException | None,
498
+ ) -> None:
499
+ # Edgex 当前没有需要关闭的资源;保持接口与 Ourbit 等类一致。
500
+ return None