hyperquant 0.65__py3-none-any.whl → 0.67__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/edgex.py +331 -14
- hyperquant/broker/lbank.py +235 -17
- hyperquant/broker/lib/edgex_sign.py +455 -0
- hyperquant/broker/lib/util.py +9 -0
- hyperquant/broker/models/edgex.py +540 -5
- hyperquant/broker/models/lbank.py +342 -0
- hyperquant/broker/ws.py +4 -3
- {hyperquant-0.65.dist-info → hyperquant-0.67.dist-info}/METADATA +1 -1
- {hyperquant-0.65.dist-info → hyperquant-0.67.dist-info}/RECORD +11 -9
- {hyperquant-0.65.dist-info → hyperquant-0.67.dist-info}/WHEEL +0 -0
hyperquant/broker/auth.py
CHANGED
@@ -1,7 +1,10 @@
|
|
1
|
+
import base64
|
2
|
+
import hmac
|
3
|
+
import urllib.parse
|
1
4
|
import time
|
2
5
|
import hashlib
|
3
6
|
from typing import Any
|
4
|
-
from aiohttp import ClientWebSocketResponse
|
7
|
+
from aiohttp import ClientWebSocketResponse, FormData, JsonPayload
|
5
8
|
from multidict import CIMultiDict
|
6
9
|
from yarl import URL
|
7
10
|
import pybotters
|
@@ -13,8 +16,106 @@ def md5_hex(s: str) -> str:
|
|
13
16
|
return hashlib.md5(s.encode("utf-8")).hexdigest()
|
14
17
|
|
15
18
|
|
19
|
+
|
20
|
+
def serialize(obj, prefix=''):
|
21
|
+
"""
|
22
|
+
Python 版 UK/v:递归排序 + urlencode + 展平
|
23
|
+
"""
|
24
|
+
def _serialize(obj, prefix=''):
|
25
|
+
if obj is None:
|
26
|
+
return []
|
27
|
+
if isinstance(obj, dict):
|
28
|
+
items = []
|
29
|
+
for k in sorted(obj.keys()):
|
30
|
+
v = obj[k]
|
31
|
+
n = f"{prefix}[{k}]" if prefix else k
|
32
|
+
items.extend(_serialize(v, n))
|
33
|
+
return items
|
34
|
+
elif isinstance(obj, list):
|
35
|
+
# JS style: output key once, then join values by &
|
36
|
+
values = []
|
37
|
+
for v in obj:
|
38
|
+
if isinstance(v, dict):
|
39
|
+
# Recursively serialize dict, but drop key= part (just use value part)
|
40
|
+
sub = _serialize(v, prefix)
|
41
|
+
# sub is a list of key=value, but we want only value part
|
42
|
+
for s in sub:
|
43
|
+
# s is like 'key=value', need only value
|
44
|
+
parts = s.split('=', 1)
|
45
|
+
if len(parts) == 2:
|
46
|
+
values.append(parts[1])
|
47
|
+
else:
|
48
|
+
values.append(parts[0])
|
49
|
+
else:
|
50
|
+
# Handle booleans and empty strings
|
51
|
+
if isinstance(v, bool):
|
52
|
+
val = "true" if v else "false"
|
53
|
+
elif v == "":
|
54
|
+
val = ""
|
55
|
+
else:
|
56
|
+
val = str(v)
|
57
|
+
values.append(val)
|
58
|
+
return [f"{urllib.parse.quote(str(prefix))}={'&'.join(values)}"]
|
59
|
+
else:
|
60
|
+
# Handle booleans and empty strings
|
61
|
+
if isinstance(obj, bool):
|
62
|
+
val = "true" if obj else "false"
|
63
|
+
elif obj == "":
|
64
|
+
val = ""
|
65
|
+
else:
|
66
|
+
val = str(obj)
|
67
|
+
return [f"{urllib.parse.quote(str(prefix))}={val}"]
|
68
|
+
return "&".join(_serialize(obj, prefix))
|
69
|
+
|
16
70
|
# 🔑 Ourbit 的鉴权函数
|
17
71
|
class Auth:
|
72
|
+
@staticmethod
|
73
|
+
def edgex(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
|
74
|
+
method: str = args[0]
|
75
|
+
url: URL = args[1]
|
76
|
+
data = kwargs.get("data") or {}
|
77
|
+
headers: CIMultiDict = kwargs["headers"]
|
78
|
+
|
79
|
+
session = kwargs["session"]
|
80
|
+
api_key:str = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][0]
|
81
|
+
secret = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][1]
|
82
|
+
passphrase:str = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][2]
|
83
|
+
passphrase = passphrase.split("-")[0]
|
84
|
+
timestamp = str(int(time.time() * 1000))
|
85
|
+
# timestamp = "1758535055061"
|
86
|
+
|
87
|
+
raw_body = ""
|
88
|
+
if data and method.upper() in ["POST", "PUT", "PATCH"] and data:
|
89
|
+
raw_body = serialize(data)
|
90
|
+
else:
|
91
|
+
raw_body = serialize(dict(url.query.items()))
|
92
|
+
|
93
|
+
|
94
|
+
secret_quoted = urllib.parse.quote(secret, safe="")
|
95
|
+
b64_secret = base64.b64encode(secret_quoted.encode("utf-8")).decode()
|
96
|
+
message = f"{timestamp}{method.upper()}{url.raw_path}{raw_body}"
|
97
|
+
sign = hmac.new(b64_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
98
|
+
|
99
|
+
sigh_header = {
|
100
|
+
"X-edgeX-Api-Key": api_key,
|
101
|
+
"X-edgeX-Passphrase": passphrase,
|
102
|
+
"X-edgeX-Signature": sign,
|
103
|
+
"X-edgeX-Timestamp": timestamp,
|
104
|
+
}
|
105
|
+
# ws单独进行签名
|
106
|
+
if headers.get("Upgrade") == "websocket":
|
107
|
+
json_str = pyjson.dumps(sigh_header, separators=(",", ":"))
|
108
|
+
b64_str = base64.b64encode(json_str.encode("utf-8")).decode("utf-8")
|
109
|
+
b64_str.replace("=", "")
|
110
|
+
headers.update({"Sec-WebSocket-Protocol": b64_str})
|
111
|
+
else:
|
112
|
+
headers.update(sigh_header)
|
113
|
+
|
114
|
+
if data:
|
115
|
+
kwargs.update({"data": JsonPayload(data)})
|
116
|
+
|
117
|
+
return args
|
118
|
+
|
18
119
|
@staticmethod
|
19
120
|
def ourbit(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
|
20
121
|
method: str = args[0]
|
@@ -67,18 +168,35 @@ class Auth:
|
|
67
168
|
token = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][0]
|
68
169
|
cookie = f"uc_token={token}; u_id={token}; "
|
69
170
|
headers.update({"cookie": cookie})
|
171
|
+
return args
|
70
172
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
# url = url.with_query(q)
|
173
|
+
@staticmethod
|
174
|
+
def lbank(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
|
175
|
+
method: str = args[0]
|
176
|
+
url: URL = args[1]
|
177
|
+
data = kwargs.get("data") or {}
|
178
|
+
headers: CIMultiDict = kwargs["headers"]
|
78
179
|
|
180
|
+
# 从 session 里取 api_key & secret
|
181
|
+
session = kwargs["session"]
|
182
|
+
token = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][0]
|
79
183
|
|
80
|
-
return args
|
81
184
|
|
185
|
+
# 设置 headers
|
186
|
+
headers.update(
|
187
|
+
{
|
188
|
+
"ex-language": 'zh-TW',
|
189
|
+
"ex-token": token,
|
190
|
+
"source": "4",
|
191
|
+
"versionflage": "true",
|
192
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0"
|
193
|
+
}
|
194
|
+
)
|
195
|
+
|
196
|
+
# 更新 kwargs.body,保证发出去的与签名一致
|
197
|
+
# kwargs.update({"data": raw_body_for_sign})
|
198
|
+
|
199
|
+
return args
|
82
200
|
|
83
201
|
pybotters.auth.Hosts.items["futures.ourbit.com"] = pybotters.auth.Item(
|
84
202
|
"ourbit", Auth.ourbit
|
@@ -86,3 +204,20 @@ pybotters.auth.Hosts.items["futures.ourbit.com"] = pybotters.auth.Item(
|
|
86
204
|
pybotters.auth.Hosts.items["www.ourbit.com"] = pybotters.auth.Item(
|
87
205
|
"ourbit", Auth.ourbit_spot
|
88
206
|
)
|
207
|
+
|
208
|
+
pybotters.auth.Hosts.items["www.ourbit.com"] = pybotters.auth.Item(
|
209
|
+
"ourbit", Auth.ourbit_spot
|
210
|
+
)
|
211
|
+
|
212
|
+
pybotters.auth.Hosts.items["pro.edgex.exchange"] = pybotters.auth.Item(
|
213
|
+
"edgex", Auth.edgex
|
214
|
+
)
|
215
|
+
|
216
|
+
|
217
|
+
pybotters.auth.Hosts.items["quote.edgex.exchange"] = pybotters.auth.Item(
|
218
|
+
"edgex", Auth.edgex
|
219
|
+
)
|
220
|
+
|
221
|
+
pybotters.auth.Hosts.items["uuapi.rerrkvifj.com"] = pybotters.auth.Item(
|
222
|
+
"lbank", Auth.lbank
|
223
|
+
)
|
hyperquant/broker/edgex.py
CHANGED
@@ -1,12 +1,43 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import asyncio
|
4
|
+
from decimal import Decimal
|
5
|
+
import hashlib
|
6
|
+
import math
|
7
|
+
import random
|
8
|
+
import re
|
3
9
|
import time
|
4
10
|
from typing import Any, Iterable, Literal
|
5
11
|
|
6
12
|
import pybotters
|
7
13
|
|
8
14
|
from .models.edgex import EdgexDataStore
|
9
|
-
|
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
|
10
41
|
|
11
42
|
class Edgex:
|
12
43
|
"""
|
@@ -35,11 +66,47 @@ class Edgex:
|
|
35
66
|
# 公共端点可能因环境/地区不同而变化,允许外部覆盖。
|
36
67
|
self.api_url = api_url or "https://pro.edgex.exchange"
|
37
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]
|
38
76
|
|
39
77
|
async def __aenter__(self) -> "Edgex":
|
40
78
|
# 初始化基础合约元数据,便于后续使用 tickSize 等字段。
|
41
79
|
await self.update_detail()
|
80
|
+
await self.sync_user()
|
42
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}×tamp=" + str(int(time.time() * 1000)),
|
108
|
+
hdlr_json=self.store.onmessage,
|
109
|
+
)
|
43
110
|
|
44
111
|
async def __aexit__(
|
45
112
|
self,
|
@@ -53,20 +120,58 @@ class Edgex:
|
|
53
120
|
async def update_detail(self) -> dict[str, Any]:
|
54
121
|
"""Fetch and cache contract metadata via the public REST endpoint."""
|
55
122
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
data = await res.json()
|
123
|
+
await self.store.initialize(
|
124
|
+
self.client.get(f'{self.api_url}/api/v1/public/meta/getMetaData'),
|
125
|
+
)
|
60
126
|
|
61
|
-
|
62
|
-
|
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))
|
63
173
|
|
64
|
-
self.store._apply_metadata(data)
|
65
|
-
return data
|
66
174
|
|
67
|
-
def _resolve_api_path(self, path: str) -> str:
|
68
|
-
base = (self.api_url or "").rstrip("/")
|
69
|
-
return f"{base}{path}"
|
70
175
|
|
71
176
|
async def sub_orderbook(
|
72
177
|
self,
|
@@ -168,10 +273,222 @@ class Edgex:
|
|
168
273
|
|
169
274
|
url = ws_url or f"{self.ws_url}/api/v1/public/ws?timestamp=" + str(int(time.time() * 1000))
|
170
275
|
payload = [{"type": "subscribe", "channel": ch} for ch in channels]
|
171
|
-
|
276
|
+
print(payload)
|
172
277
|
wsapp = self.client.ws_connect(url, send_json=payload, hdlr_json=self.store.onmessage)
|
173
278
|
await wsapp._event.wait()
|
174
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
|
+
|
175
492
|
|
176
493
|
async def __aexit__(
|
177
494
|
self,
|
@@ -180,4 +497,4 @@ class Edgex:
|
|
180
497
|
tb: BaseException | None,
|
181
498
|
) -> None:
|
182
499
|
# Edgex 当前没有需要关闭的资源;保持接口与 Ourbit 等类一致。
|
183
|
-
return None
|
500
|
+
return None
|