hyperquant 0.81__py3-none-any.whl → 0.83__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.
- hyperquant/broker/auth.py +74 -2
- hyperquant/broker/coinw.py +405 -0
- hyperquant/broker/lbank.py +0 -1
- hyperquant/broker/models/coinw.py +612 -0
- hyperquant/broker/ws.py +53 -4
- {hyperquant-0.81.dist-info → hyperquant-0.83.dist-info}/METADATA +1 -1
- {hyperquant-0.81.dist-info → hyperquant-0.83.dist-info}/RECORD +8 -6
- {hyperquant-0.81.dist-info → hyperquant-0.83.dist-info}/WHEEL +0 -0
hyperquant/broker/auth.py
CHANGED
|
@@ -192,13 +192,81 @@ class Auth:
|
|
|
192
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
193
|
}
|
|
194
194
|
)
|
|
195
|
-
print(headers)
|
|
196
195
|
|
|
197
196
|
# 更新 kwargs.body,保证发出去的与签名一致
|
|
198
197
|
# kwargs.update({"data": raw_body_for_sign})
|
|
199
198
|
|
|
200
199
|
return args
|
|
201
200
|
|
|
201
|
+
@staticmethod
|
|
202
|
+
def coinw(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
|
|
203
|
+
method: str = args[0]
|
|
204
|
+
url: URL = args[1]
|
|
205
|
+
headers: CIMultiDict = kwargs["headers"]
|
|
206
|
+
|
|
207
|
+
session = kwargs["session"]
|
|
208
|
+
try:
|
|
209
|
+
api_key, secret, _ = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name]
|
|
210
|
+
except (KeyError, ValueError):
|
|
211
|
+
raise RuntimeError("CoinW credentials (api_key, secret) are required")
|
|
212
|
+
|
|
213
|
+
timestamp = str(int(time.time() * 1000))
|
|
214
|
+
method_upper = method.upper()
|
|
215
|
+
|
|
216
|
+
params = kwargs.get("params")
|
|
217
|
+
query_string = ""
|
|
218
|
+
if isinstance(params, dict) and params:
|
|
219
|
+
query_items = [
|
|
220
|
+
f"{key}={value}"
|
|
221
|
+
for key, value in params.items()
|
|
222
|
+
if value is not None
|
|
223
|
+
]
|
|
224
|
+
query_string = "&".join(query_items)
|
|
225
|
+
elif url.query_string:
|
|
226
|
+
query_string = url.query_string
|
|
227
|
+
|
|
228
|
+
body_str = ""
|
|
229
|
+
|
|
230
|
+
if method_upper == "GET":
|
|
231
|
+
body = None
|
|
232
|
+
data = None
|
|
233
|
+
else:
|
|
234
|
+
body = kwargs.get("json")
|
|
235
|
+
data = kwargs.get("data")
|
|
236
|
+
payload = body if body is not None else data
|
|
237
|
+
if isinstance(payload, (dict, list)):
|
|
238
|
+
body_str = pyjson.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
239
|
+
kwargs["data"] = body_str
|
|
240
|
+
kwargs.pop("json", None)
|
|
241
|
+
elif payload is not None:
|
|
242
|
+
body_str = str(payload)
|
|
243
|
+
kwargs["data"] = body_str
|
|
244
|
+
kwargs.pop("json", None)
|
|
245
|
+
|
|
246
|
+
if query_string:
|
|
247
|
+
path = f"{url.raw_path}?{query_string}"
|
|
248
|
+
else:
|
|
249
|
+
path = url.raw_path
|
|
250
|
+
|
|
251
|
+
message = f"{timestamp}{method_upper}{path}{body_str}"
|
|
252
|
+
signature = hmac.new(
|
|
253
|
+
secret, message.encode("utf-8"), hashlib.sha256
|
|
254
|
+
).digest()
|
|
255
|
+
signature_b64 = base64.b64encode(signature).decode("ascii")
|
|
256
|
+
|
|
257
|
+
headers.update(
|
|
258
|
+
{
|
|
259
|
+
"sign": signature_b64,
|
|
260
|
+
"api_key": api_key,
|
|
261
|
+
"timestamp": timestamp,
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if method_upper in {"POST", "PUT", "PATCH", "DELETE"} and "data" in kwargs:
|
|
266
|
+
headers.setdefault("Content-Type", "application/json")
|
|
267
|
+
|
|
268
|
+
return args
|
|
269
|
+
|
|
202
270
|
pybotters.auth.Hosts.items["futures.ourbit.com"] = pybotters.auth.Item(
|
|
203
271
|
"ourbit", Auth.ourbit
|
|
204
272
|
)
|
|
@@ -221,4 +289,8 @@ pybotters.auth.Hosts.items["quote.edgex.exchange"] = pybotters.auth.Item(
|
|
|
221
289
|
|
|
222
290
|
pybotters.auth.Hosts.items["uuapi.rerrkvifj.com"] = pybotters.auth.Item(
|
|
223
291
|
"lbank", Auth.lbank
|
|
224
|
-
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
pybotters.auth.Hosts.items["api.coinw.com"] = pybotters.auth.Item(
|
|
295
|
+
"coinw", Auth.coinw
|
|
296
|
+
)
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Literal, Sequence
|
|
6
|
+
|
|
7
|
+
import pybotters
|
|
8
|
+
|
|
9
|
+
from .models.coinw import CoinwFuturesDataStore
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Coinw:
|
|
15
|
+
"""CoinW 永续合约客户端(REST + WebSocket)。"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
client: pybotters.Client,
|
|
20
|
+
*,
|
|
21
|
+
rest_api: str | None = None,
|
|
22
|
+
ws_url: str | None = None,
|
|
23
|
+
web_api: str | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.client = client
|
|
26
|
+
self.store = CoinwFuturesDataStore()
|
|
27
|
+
|
|
28
|
+
self.rest_api = rest_api or "https://api.coinw.com"
|
|
29
|
+
self.ws_url_public = ws_url or "wss://ws.futurescw.com/perpum"
|
|
30
|
+
self.ws_url_private = self.ws_url_public
|
|
31
|
+
self.web_api = web_api or "https://futuresapi.coinw.com"
|
|
32
|
+
|
|
33
|
+
self._ws_private: pybotters.ws.WebSocketApp | None = None
|
|
34
|
+
self._ws_private_ready = asyncio.Event()
|
|
35
|
+
self._ws_headers = {
|
|
36
|
+
"Origin": "https://www.coinw.com",
|
|
37
|
+
"Referer": "https://www.coinw.com/",
|
|
38
|
+
"User-Agent": (
|
|
39
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
40
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async def __aenter__(self) -> "Coinw":
|
|
45
|
+
await self.update("detail")
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
async def __aexit__(self, exc_type, exc, tb) -> None: # pragma: no cover - symmetry
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
async def update(
|
|
52
|
+
self,
|
|
53
|
+
update_type: Literal[
|
|
54
|
+
"detail",
|
|
55
|
+
"orders",
|
|
56
|
+
"position",
|
|
57
|
+
"balance",
|
|
58
|
+
"all",
|
|
59
|
+
] = "all",
|
|
60
|
+
*,
|
|
61
|
+
instrument: str | None = None,
|
|
62
|
+
position_type: Literal["execute", "plan", "planTrigger"] = "execute",
|
|
63
|
+
page: int | None = None,
|
|
64
|
+
page_size: int | None = None,
|
|
65
|
+
open_ids: str | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""刷新本地缓存,使用 CoinW REST API。
|
|
68
|
+
|
|
69
|
+
- detail: ``GET /v1/perpum/instruments`` (公共)
|
|
70
|
+
- orders: ``GET /v1/perpum/orders/open`` (私有,需要 ``instrument``)
|
|
71
|
+
- position: ``GET /v1/perpum/positions`` (私有,需要 ``instrument``)
|
|
72
|
+
- balance: ``GET /v1/perpum/account/getUserAssets`` (私有)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
requests: list[Any] = []
|
|
76
|
+
|
|
77
|
+
include_detail = update_type in {"detail", "all"}
|
|
78
|
+
include_orders = update_type in {"orders", "all"}
|
|
79
|
+
include_position = update_type in {"position", "all"}
|
|
80
|
+
include_balance = update_type in {"balance", "all"}
|
|
81
|
+
|
|
82
|
+
if include_detail:
|
|
83
|
+
requests.append(self.client.get(f"{self.rest_api}/v1/perpum/instruments"))
|
|
84
|
+
|
|
85
|
+
if include_orders:
|
|
86
|
+
if not instrument:
|
|
87
|
+
raise ValueError("instrument is required when updating orders")
|
|
88
|
+
params: dict[str, Any] = {
|
|
89
|
+
"instrument": instrument,
|
|
90
|
+
"positionType": position_type,
|
|
91
|
+
}
|
|
92
|
+
if page is not None:
|
|
93
|
+
params["page"] = page
|
|
94
|
+
if page_size is not None:
|
|
95
|
+
params["pageSize"] = page_size
|
|
96
|
+
requests.append(
|
|
97
|
+
self.client.get(
|
|
98
|
+
f"{self.rest_api}/v1/perpum/orders/open",
|
|
99
|
+
params=params,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if include_position:
|
|
104
|
+
if not instrument:
|
|
105
|
+
raise ValueError("instrument is required when updating positions")
|
|
106
|
+
params = {"instrument": instrument}
|
|
107
|
+
if open_ids:
|
|
108
|
+
params["openIds"] = open_ids
|
|
109
|
+
requests.append(
|
|
110
|
+
self.client.get(
|
|
111
|
+
f"{self.rest_api}/v1/perpum/positions",
|
|
112
|
+
params=params,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if include_balance:
|
|
117
|
+
requests.append(
|
|
118
|
+
self.client.get(f"{self.rest_api}/v1/perpum/account/getUserAssets")
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if not requests:
|
|
122
|
+
raise ValueError(f"update_type err: {update_type}")
|
|
123
|
+
|
|
124
|
+
await self.store.initialize(*requests)
|
|
125
|
+
|
|
126
|
+
async def place_order(
|
|
127
|
+
self,
|
|
128
|
+
instrument: str,
|
|
129
|
+
*,
|
|
130
|
+
direction: Literal["long", "short"],
|
|
131
|
+
leverage: int,
|
|
132
|
+
quantity: float | str,
|
|
133
|
+
quantity_unit: Literal[0, 1, 2, "quote", "contract", "base"] = 0,
|
|
134
|
+
position_model: Literal[0, 1, "isolated", "cross"] = 0,
|
|
135
|
+
position_type: Literal["execute", "plan", "planTrigger"] = "execute",
|
|
136
|
+
price: float | None = None,
|
|
137
|
+
trigger_price: float | None = None,
|
|
138
|
+
trigger_type: Literal[0, 1] | None = None,
|
|
139
|
+
stop_loss_price: float | None = None,
|
|
140
|
+
stop_profit_price: float | None = None,
|
|
141
|
+
third_order_id: str | None = None,
|
|
142
|
+
use_almighty_gold: bool | None = None,
|
|
143
|
+
gold_id: int | None = None,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
"""``POST /v1/perpum/order`` 下单。"""
|
|
146
|
+
|
|
147
|
+
payload: dict[str, Any] = {
|
|
148
|
+
"instrument": instrument,
|
|
149
|
+
"direction": self._normalize_direction(direction),
|
|
150
|
+
"leverage": int(leverage),
|
|
151
|
+
"quantityUnit": self._normalize_quantity_unit(quantity_unit),
|
|
152
|
+
"quantity": self._format_quantity(quantity),
|
|
153
|
+
"positionModel": self._normalize_position_model(position_model),
|
|
154
|
+
"positionType": position_type,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if price is not None:
|
|
158
|
+
payload["openPrice"] = price
|
|
159
|
+
if trigger_price is not None:
|
|
160
|
+
payload["triggerPrice"] = trigger_price
|
|
161
|
+
if trigger_type is not None:
|
|
162
|
+
payload["triggerType"] = int(trigger_type)
|
|
163
|
+
if stop_loss_price is not None:
|
|
164
|
+
payload["stopLossPrice"] = stop_loss_price
|
|
165
|
+
if stop_profit_price is not None:
|
|
166
|
+
payload["stopProfitPrice"] = stop_profit_price
|
|
167
|
+
if third_order_id:
|
|
168
|
+
payload["thirdOrderId"] = third_order_id
|
|
169
|
+
if use_almighty_gold is not None:
|
|
170
|
+
payload["useAlmightyGold"] = int(bool(use_almighty_gold))
|
|
171
|
+
if gold_id is not None:
|
|
172
|
+
payload["goldId"] = int(gold_id)
|
|
173
|
+
|
|
174
|
+
res = await self.client.post(
|
|
175
|
+
f"{self.rest_api}/v1/perpum/order",
|
|
176
|
+
data=payload,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
data = await res.json()
|
|
180
|
+
return self._ensure_ok("place_order", data)
|
|
181
|
+
|
|
182
|
+
async def place_order_web(
|
|
183
|
+
self,
|
|
184
|
+
instrument: str,
|
|
185
|
+
*,
|
|
186
|
+
direction: Literal["long", "short"],
|
|
187
|
+
leverage: int | str,
|
|
188
|
+
quantity_unit: Literal[0, 1, 2],
|
|
189
|
+
quantity: str | float | int,
|
|
190
|
+
position_model: Literal[0, 1] = 1,
|
|
191
|
+
position_type: Literal["plan", "planTrigger", "execute"] = 'plan',
|
|
192
|
+
open_price: str | float | None = None,
|
|
193
|
+
contract_type: int = 1,
|
|
194
|
+
data_type: str = "trade_take",
|
|
195
|
+
device_id: str,
|
|
196
|
+
token: str,
|
|
197
|
+
headers: dict[str, str] | None = None,
|
|
198
|
+
) -> dict[str, Any]:
|
|
199
|
+
"""使用 Web 前端接口下单,绕过部分 API 频控策略。
|
|
200
|
+
|
|
201
|
+
注意此接口需要传入真实浏览器参数,如 ``device_id`` 与 ``token``。
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
if not device_id or not token:
|
|
205
|
+
raise ValueError("device_id and token are required for place_order_web")
|
|
206
|
+
|
|
207
|
+
url = f"{self.web_api}/v1/futuresc/thirdClient/trade/{instrument}/open"
|
|
208
|
+
|
|
209
|
+
payload: dict[str, Any] = {
|
|
210
|
+
"instrument": instrument,
|
|
211
|
+
"direction": direction,
|
|
212
|
+
"leverage": str(leverage),
|
|
213
|
+
"quantityUnit": quantity_unit,
|
|
214
|
+
"quantity": str(quantity),
|
|
215
|
+
"positionModel": position_model,
|
|
216
|
+
"positionType": position_type,
|
|
217
|
+
"contractType": contract_type,
|
|
218
|
+
"dataType": data_type,
|
|
219
|
+
}
|
|
220
|
+
if open_price is not None:
|
|
221
|
+
payload["openPrice"] = str(open_price)
|
|
222
|
+
|
|
223
|
+
base_headers = {
|
|
224
|
+
"accept": "application/json, text/plain, */*",
|
|
225
|
+
"accept-language": "zh_CN",
|
|
226
|
+
"appversion": "100.100.100",
|
|
227
|
+
"cache-control": "no-cache",
|
|
228
|
+
"clienttag": "web",
|
|
229
|
+
"content-type": "application/json",
|
|
230
|
+
"cwdeviceid": device_id,
|
|
231
|
+
"deviceid": device_id,
|
|
232
|
+
"devicename": "Chrome V141.0.0.0 (macOS)",
|
|
233
|
+
"language": "zh_CN",
|
|
234
|
+
"logintoken": token,
|
|
235
|
+
"origin": "https://www.coinw.com",
|
|
236
|
+
"pragma": "no-cache",
|
|
237
|
+
"priority": "u=1, i",
|
|
238
|
+
"referer": "https://www.coinw.com/",
|
|
239
|
+
"sec-ch-ua": '"Microsoft Edge";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
|
|
240
|
+
"sec-ch-ua-mobile": "?0",
|
|
241
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
242
|
+
"sec-fetch-dest": "empty",
|
|
243
|
+
"sec-fetch-mode": "cors",
|
|
244
|
+
"sec-fetch-site": "same-site",
|
|
245
|
+
"selecttype": "USD",
|
|
246
|
+
"systemversion": "macOS 10.15.7",
|
|
247
|
+
"thirdappid": "coinw",
|
|
248
|
+
"thirdapptoken": token,
|
|
249
|
+
"token": token,
|
|
250
|
+
"user-agent": (
|
|
251
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
252
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 "
|
|
253
|
+
"Safari/537.36 Edg/141.0.0.0"
|
|
254
|
+
),
|
|
255
|
+
"withcredentials": "true",
|
|
256
|
+
"x-authorization": token,
|
|
257
|
+
"x-language": "zh_CN",
|
|
258
|
+
"x-locale": "zh_CN",
|
|
259
|
+
"x-requested-with": "XMLHttpRequest",
|
|
260
|
+
}
|
|
261
|
+
if headers:
|
|
262
|
+
base_headers.update(headers)
|
|
263
|
+
|
|
264
|
+
res = await self.client.post(
|
|
265
|
+
url,
|
|
266
|
+
json=payload,
|
|
267
|
+
headers=base_headers,
|
|
268
|
+
auth=None,
|
|
269
|
+
)
|
|
270
|
+
return await res.json()
|
|
271
|
+
|
|
272
|
+
async def cancel_order(self, order_id: str | int) -> dict[str, Any]:
|
|
273
|
+
"""``DELETE /v1/perpum/order`` 取消单个订单。"""
|
|
274
|
+
|
|
275
|
+
res = await self.client.delete(
|
|
276
|
+
f"{self.rest_api}/v1/perpum/order",
|
|
277
|
+
data={"id": str(order_id)},
|
|
278
|
+
)
|
|
279
|
+
data = await res.json()
|
|
280
|
+
return self._ensure_ok("cancel_order", data)
|
|
281
|
+
|
|
282
|
+
async def sub_personal(self) -> None:
|
|
283
|
+
"""订阅订单、持仓、资产私有频道。"""
|
|
284
|
+
|
|
285
|
+
ws_app = await self._ensure_private_ws()
|
|
286
|
+
payloads = [
|
|
287
|
+
{"event": "sub", "params": {"biz": "futures", "type": "order"}},
|
|
288
|
+
{"event": "sub", "params": {"biz": "futures", "type": "position"}},
|
|
289
|
+
{"event": "sub", "params": {"biz": "futures", "type": "position_change"}},
|
|
290
|
+
{"event": "sub", "params": {"biz": "futures", "type": "assets"}},
|
|
291
|
+
]
|
|
292
|
+
for payload in payloads:
|
|
293
|
+
if ws_app.current_ws.closed:
|
|
294
|
+
raise ConnectionError("CoinW private websocket closed before subscription.")
|
|
295
|
+
await ws_app.current_ws.send_json(payload)
|
|
296
|
+
await asyncio.sleep(0.05)
|
|
297
|
+
|
|
298
|
+
async def sub_orderbook(
|
|
299
|
+
self,
|
|
300
|
+
pair_codes: Sequence[str] | str,
|
|
301
|
+
*,
|
|
302
|
+
depth_limit: int | None = None,
|
|
303
|
+
biz: str = "futures",
|
|
304
|
+
) -> pybotters.ws.WebSocketApp:
|
|
305
|
+
"""订阅 ``type=depth`` 订单簿数据,批量控制发送频率。"""
|
|
306
|
+
|
|
307
|
+
if isinstance(pair_codes, str):
|
|
308
|
+
pair_codes = [pair_codes]
|
|
309
|
+
|
|
310
|
+
pair_list = [code for code in pair_codes if code]
|
|
311
|
+
if not pair_list:
|
|
312
|
+
raise ValueError("pair_codes must not be empty")
|
|
313
|
+
|
|
314
|
+
self.store.book.limit = depth_limit
|
|
315
|
+
|
|
316
|
+
subscriptions = [
|
|
317
|
+
{"event": "sub", "params": {"biz": biz, "type": "depth", "pairCode": code}}
|
|
318
|
+
for code in pair_list
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
ws_app = self.client.ws_connect(
|
|
322
|
+
self.ws_url_public,
|
|
323
|
+
hdlr_json=self.store.onmessage,
|
|
324
|
+
headers=self._ws_headers,
|
|
325
|
+
)
|
|
326
|
+
await ws_app._event.wait()
|
|
327
|
+
|
|
328
|
+
chunk_size = 10
|
|
329
|
+
for idx in range(0, len(subscriptions), chunk_size):
|
|
330
|
+
batch = subscriptions[idx : idx + chunk_size]
|
|
331
|
+
for msg in batch:
|
|
332
|
+
await ws_app.current_ws.send_json(msg)
|
|
333
|
+
if idx + chunk_size < len(subscriptions):
|
|
334
|
+
await asyncio.sleep(2.05)
|
|
335
|
+
|
|
336
|
+
return ws_app
|
|
337
|
+
|
|
338
|
+
async def _ensure_private_ws(self) -> pybotters.ws.WebSocketApp:
|
|
339
|
+
|
|
340
|
+
ws_app = self.client.ws_connect(
|
|
341
|
+
self.ws_url_private,
|
|
342
|
+
hdlr_json=self.store.onmessage,
|
|
343
|
+
headers=self._ws_headers,
|
|
344
|
+
)
|
|
345
|
+
await ws_app._event.wait()
|
|
346
|
+
await ws_app.current_ws._wait_authtask()
|
|
347
|
+
|
|
348
|
+
return ws_app
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _normalize_direction(direction: str) -> str:
|
|
352
|
+
allowed = {"long", "short"}
|
|
353
|
+
value = str(direction).lower()
|
|
354
|
+
if value not in allowed:
|
|
355
|
+
raise ValueError(f"Unsupported direction: {direction}")
|
|
356
|
+
return value
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def _normalize_quantity_unit(
|
|
360
|
+
unit: Literal[0, 1, 2, "quote", "contract", "base"],
|
|
361
|
+
) -> int:
|
|
362
|
+
mapping = {
|
|
363
|
+
0: 0,
|
|
364
|
+
1: 1,
|
|
365
|
+
2: 2,
|
|
366
|
+
"quote": 0,
|
|
367
|
+
"contract": 1,
|
|
368
|
+
"base": 2,
|
|
369
|
+
}
|
|
370
|
+
try:
|
|
371
|
+
return mapping[unit] # type: ignore[index]
|
|
372
|
+
except KeyError as exc: # pragma: no cover - guard
|
|
373
|
+
raise ValueError(f"Unsupported quantity_unit: {unit}") from exc
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def _normalize_position_model(
|
|
377
|
+
model: Literal[0, 1, "isolated", "cross"],
|
|
378
|
+
) -> int:
|
|
379
|
+
mapping = {
|
|
380
|
+
0: 0,
|
|
381
|
+
1: 1,
|
|
382
|
+
"isolated": 0,
|
|
383
|
+
"cross": 1,
|
|
384
|
+
}
|
|
385
|
+
try:
|
|
386
|
+
return mapping[model] # type: ignore[index]
|
|
387
|
+
except KeyError as exc: # pragma: no cover - guard
|
|
388
|
+
raise ValueError(f"Unsupported position_model: {model}") from exc
|
|
389
|
+
|
|
390
|
+
@staticmethod
|
|
391
|
+
def _format_quantity(quantity: float | str) -> str:
|
|
392
|
+
if isinstance(quantity, str):
|
|
393
|
+
return quantity
|
|
394
|
+
return str(quantity)
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
def _ensure_ok(operation: str, data: Any) -> dict[str, Any]:
|
|
398
|
+
"""CoinW REST 成功时返回 ``{'code': 0, ...}``。"""
|
|
399
|
+
|
|
400
|
+
if not isinstance(data, dict) or data.get("code") != 0:
|
|
401
|
+
raise RuntimeError(f"{operation} failed: {data}")
|
|
402
|
+
payload = data.get("data")
|
|
403
|
+
if isinstance(payload, dict):
|
|
404
|
+
return payload
|
|
405
|
+
return {"data": payload}
|
hyperquant/broker/lbank.py
CHANGED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Awaitable
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
from pybotters.store import DataStore, DataStoreCollection
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pybotters.typedefs import Item
|
|
11
|
+
from pybotters.ws import ClientWebSocketResponse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Book(DataStore):
|
|
15
|
+
"""CoinW 合约订单簿数据存储。
|
|
16
|
+
|
|
17
|
+
WebSocket 频道: futures/depth
|
|
18
|
+
|
|
19
|
+
消息示例(来源: https://www.coinw.com/api-doc/futures-trading/market/subscribe-order-book)
|
|
20
|
+
|
|
21
|
+
.. code:: json
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
"biz": "futures",
|
|
25
|
+
"pairCode": "BTC",
|
|
26
|
+
"type": "depth",
|
|
27
|
+
"data": {
|
|
28
|
+
"asks": [{"p": "95640.3", "m": "0.807"}, ...],
|
|
29
|
+
"bids": [{"p": "95640.2", "m": "0.068"}, ...]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_KEYS = ["s", "S", "p", "q"]
|
|
35
|
+
|
|
36
|
+
def _init(self) -> None:
|
|
37
|
+
self.limit: int | None = None
|
|
38
|
+
|
|
39
|
+
def _on_message(self, msg: dict[str, Any]) -> None:
|
|
40
|
+
data = msg.get("data")
|
|
41
|
+
if not isinstance(data, dict):
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
asks = data.get("asks") or []
|
|
45
|
+
bids = data.get("bids") or []
|
|
46
|
+
if not asks and not bids:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
symbol = (
|
|
50
|
+
msg.get("pairCode")
|
|
51
|
+
or data.get("pairCode")
|
|
52
|
+
or msg.get("symbol")
|
|
53
|
+
or data.get("symbol")
|
|
54
|
+
)
|
|
55
|
+
if not symbol:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if self.limit is not None:
|
|
59
|
+
asks = asks[: self.limit]
|
|
60
|
+
bids = bids[: self.limit]
|
|
61
|
+
|
|
62
|
+
entries: list[dict[str, Any]] = []
|
|
63
|
+
for side, levels in (("a", asks), ("b", bids)):
|
|
64
|
+
for level in levels:
|
|
65
|
+
price = level.get("p") or level.get("price")
|
|
66
|
+
size = level.get("m") or level.get("q") or level.get("size")
|
|
67
|
+
if price is None or size is None:
|
|
68
|
+
continue
|
|
69
|
+
entries.append(
|
|
70
|
+
{
|
|
71
|
+
"s": str(symbol),
|
|
72
|
+
"S": side,
|
|
73
|
+
"p": str(price),
|
|
74
|
+
"q": str(size),
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if not entries:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
self._find_and_delete({"s": str(symbol)})
|
|
82
|
+
self._insert(entries)
|
|
83
|
+
|
|
84
|
+
def sorted(
|
|
85
|
+
self, query: Item | None = None, limit: int | None = None
|
|
86
|
+
) -> dict[str, list[Item]]:
|
|
87
|
+
return self._sorted(
|
|
88
|
+
item_key="S",
|
|
89
|
+
item_asc_key="a",
|
|
90
|
+
item_desc_key="b",
|
|
91
|
+
sort_key="p",
|
|
92
|
+
query=query,
|
|
93
|
+
limit=limit,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Detail(DataStore):
|
|
98
|
+
"""CoinW 合约信息数据存储。
|
|
99
|
+
|
|
100
|
+
文档: https://www.coinw.com/api-doc/futures-trading/market/get-instrument-information
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
_KEYS = ["name"]
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def _transform(entry: dict[str, Any]) -> dict[str, Any] | None:
|
|
107
|
+
if not entry:
|
|
108
|
+
return None
|
|
109
|
+
transformed = dict(entry)
|
|
110
|
+
base = entry.get("name") or entry.get("base")
|
|
111
|
+
quote = entry.get("quote")
|
|
112
|
+
pricePrecision = entry.get("pricePrecision")
|
|
113
|
+
transformed['tick_size'] = 10 ** (-int(pricePrecision))
|
|
114
|
+
transformed['step_size'] = entry.get("oneLotSize")
|
|
115
|
+
if base and quote:
|
|
116
|
+
transformed.setdefault(
|
|
117
|
+
"symbol", f"{str(base).upper()}_{str(quote).upper()}"
|
|
118
|
+
)
|
|
119
|
+
return transformed
|
|
120
|
+
|
|
121
|
+
def _onresponse(self, data: Any) -> None:
|
|
122
|
+
if data is None:
|
|
123
|
+
self._clear()
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
entries: list[dict[str, Any]]
|
|
127
|
+
if isinstance(data, dict):
|
|
128
|
+
entries = data.get("data") or []
|
|
129
|
+
else:
|
|
130
|
+
entries = data
|
|
131
|
+
|
|
132
|
+
items: list[dict[str, Any]] = []
|
|
133
|
+
for entry in entries:
|
|
134
|
+
if not isinstance(entry, dict):
|
|
135
|
+
continue
|
|
136
|
+
transformed = self._transform(entry)
|
|
137
|
+
if transformed:
|
|
138
|
+
items.append(transformed)
|
|
139
|
+
|
|
140
|
+
self._clear()
|
|
141
|
+
if items:
|
|
142
|
+
self._insert(items)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Orders(DataStore):
|
|
146
|
+
"""CoinW 当前订单数据存储。"""
|
|
147
|
+
|
|
148
|
+
_KEYS = ["id"]
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _normalize(entry: dict[str, Any]) -> dict[str, Any] | None:
|
|
152
|
+
order_id = entry.get("id")
|
|
153
|
+
if order_id is None:
|
|
154
|
+
return None
|
|
155
|
+
normalized = dict(entry)
|
|
156
|
+
normalized["id"] = str(order_id)
|
|
157
|
+
return normalized
|
|
158
|
+
|
|
159
|
+
def _onresponse(self, data: Any) -> None:
|
|
160
|
+
payload = []
|
|
161
|
+
if isinstance(data, dict):
|
|
162
|
+
inner = data.get("data")
|
|
163
|
+
if isinstance(inner, dict):
|
|
164
|
+
payload = inner.get("rows") or []
|
|
165
|
+
elif isinstance(inner, list):
|
|
166
|
+
payload = inner
|
|
167
|
+
elif isinstance(data, list):
|
|
168
|
+
payload = data
|
|
169
|
+
|
|
170
|
+
items: list[dict[str, Any]] = []
|
|
171
|
+
for entry in payload or []:
|
|
172
|
+
if not isinstance(entry, dict):
|
|
173
|
+
continue
|
|
174
|
+
normalized = self._normalize(entry)
|
|
175
|
+
if normalized:
|
|
176
|
+
items.append(normalized)
|
|
177
|
+
|
|
178
|
+
self._clear()
|
|
179
|
+
if items:
|
|
180
|
+
self._insert(items)
|
|
181
|
+
|
|
182
|
+
def _on_message(self, msg: dict[str, Any]) -> None:
|
|
183
|
+
data = msg.get("data")
|
|
184
|
+
if isinstance(data, dict) and data.get("result") is not None:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
entries: list[dict[str, Any]] = []
|
|
188
|
+
if isinstance(data, list):
|
|
189
|
+
entries = data
|
|
190
|
+
elif isinstance(data, dict):
|
|
191
|
+
entries = data.get("rows") or data.get("data") or []
|
|
192
|
+
|
|
193
|
+
if not entries:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
to_insert: list[dict[str, Any]] = []
|
|
197
|
+
to_delete: list[dict[str, Any]] = []
|
|
198
|
+
|
|
199
|
+
for entry in entries:
|
|
200
|
+
if not isinstance(entry, dict):
|
|
201
|
+
continue
|
|
202
|
+
normalized = self._normalize(entry)
|
|
203
|
+
if not normalized:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
status = str(normalized.get("status") or "").lower()
|
|
207
|
+
order_status = str(normalized.get("orderStatus") or "").lower()
|
|
208
|
+
remove = status in {"close", "cancel", "canceled"} or order_status in {
|
|
209
|
+
"finish",
|
|
210
|
+
"cancel",
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# query = {"id": normalized["id"]}
|
|
214
|
+
to_delete.append(normalized)
|
|
215
|
+
if not remove:
|
|
216
|
+
to_insert.append(normalized)
|
|
217
|
+
|
|
218
|
+
if to_delete:
|
|
219
|
+
self._delete(to_delete)
|
|
220
|
+
if to_insert:
|
|
221
|
+
self._insert(to_insert)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Position(DataStore):
|
|
225
|
+
"""CoinW 当前持仓数据存储。"""
|
|
226
|
+
|
|
227
|
+
_KEYS = ["id"]
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def _normalize(entry: dict[str, Any]) -> dict[str, Any] | None:
|
|
231
|
+
position_id = entry.get("id")
|
|
232
|
+
if position_id is None:
|
|
233
|
+
return None
|
|
234
|
+
normalized = dict(entry)
|
|
235
|
+
normalized["id"] = str(position_id)
|
|
236
|
+
return normalized
|
|
237
|
+
|
|
238
|
+
def _onresponse(self, data: Any) -> None:
|
|
239
|
+
payload = []
|
|
240
|
+
if isinstance(data, dict):
|
|
241
|
+
payload = data.get("data") or []
|
|
242
|
+
elif isinstance(data, list):
|
|
243
|
+
payload = data
|
|
244
|
+
|
|
245
|
+
items: list[dict[str, Any]] = []
|
|
246
|
+
for entry in payload or []:
|
|
247
|
+
if not isinstance(entry, dict):
|
|
248
|
+
continue
|
|
249
|
+
normalized = self._normalize(entry)
|
|
250
|
+
if normalized:
|
|
251
|
+
items.append(normalized)
|
|
252
|
+
|
|
253
|
+
self._clear()
|
|
254
|
+
if items:
|
|
255
|
+
self._insert(items)
|
|
256
|
+
|
|
257
|
+
def _on_message(self, msg: dict[str, Any]) -> None:
|
|
258
|
+
data = msg.get("data")
|
|
259
|
+
if isinstance(data, dict) and data.get("result") is not None:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
entries: list[dict[str, Any]] = []
|
|
263
|
+
if isinstance(data, list):
|
|
264
|
+
entries = data
|
|
265
|
+
elif isinstance(data, dict):
|
|
266
|
+
entries = data.get("rows") or data.get("data") or []
|
|
267
|
+
|
|
268
|
+
if not entries:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
to_insert: list[dict[str, Any]] = []
|
|
272
|
+
to_delete: list[dict[str, Any]] = []
|
|
273
|
+
|
|
274
|
+
for entry in entries:
|
|
275
|
+
if not isinstance(entry, dict):
|
|
276
|
+
continue
|
|
277
|
+
normalized = self._normalize(entry)
|
|
278
|
+
if not normalized:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
status = normalized.get("status")
|
|
282
|
+
normalized_status = str(status).lower() if status is not None else ""
|
|
283
|
+
remove = normalized_status in {"close", "closed", "1", "2"}
|
|
284
|
+
|
|
285
|
+
query = {"id": normalized["id"]}
|
|
286
|
+
to_delete.append(query)
|
|
287
|
+
if not remove:
|
|
288
|
+
to_insert.append(normalized)
|
|
289
|
+
|
|
290
|
+
if to_delete:
|
|
291
|
+
self._delete(to_delete)
|
|
292
|
+
if to_insert:
|
|
293
|
+
self._insert(to_insert)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class Balance(DataStore):
|
|
297
|
+
"""CoinW 合约账户资产数据存储。"""
|
|
298
|
+
|
|
299
|
+
_KEYS = ["currency"]
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _normalize_rest(entry: dict[str, Any]) -> dict[str, Any]:
|
|
303
|
+
currency = "USDT"
|
|
304
|
+
normalized = {
|
|
305
|
+
"currency": currency,
|
|
306
|
+
"availableMargin": entry.get("availableMargin"),
|
|
307
|
+
"availableUsdt": entry.get("availableUsdt"),
|
|
308
|
+
"almightyGold": entry.get("almightyGold"),
|
|
309
|
+
"alMargin": entry.get("alMargin"),
|
|
310
|
+
"alFreeze": entry.get("alFreeze"),
|
|
311
|
+
"time": entry.get("time"),
|
|
312
|
+
"userId": entry.get("userId"),
|
|
313
|
+
}
|
|
314
|
+
if "available" not in normalized:
|
|
315
|
+
normalized["available"] = entry.get("availableUsdt")
|
|
316
|
+
normalized["availableMargin"] = entry.get("availableMargin")
|
|
317
|
+
normalized["margin"] = entry.get("alMargin")
|
|
318
|
+
normalized["freeze"] = entry.get("alFreeze")
|
|
319
|
+
return {k: v for k, v in normalized.items() if v is not None}
|
|
320
|
+
|
|
321
|
+
@staticmethod
|
|
322
|
+
def _normalize_ws(entry: dict[str, Any]) -> dict[str, Any] | None:
|
|
323
|
+
currency = entry.get("currency")
|
|
324
|
+
if not currency:
|
|
325
|
+
return None
|
|
326
|
+
currency_str = str(currency).upper()
|
|
327
|
+
normalized = dict(entry)
|
|
328
|
+
normalized["currency"] = currency_str
|
|
329
|
+
# 对齐 REST 字段
|
|
330
|
+
if "availableUsdt" not in normalized and "available" in normalized:
|
|
331
|
+
normalized["availableUsdt"] = normalized["available"]
|
|
332
|
+
if "alMargin" not in normalized and "margin" in normalized:
|
|
333
|
+
normalized["alMargin"] = normalized["margin"]
|
|
334
|
+
if "alFreeze" not in normalized and "freeze" in normalized:
|
|
335
|
+
normalized["alFreeze"] = normalized["freeze"]
|
|
336
|
+
return normalized
|
|
337
|
+
|
|
338
|
+
def _onresponse(self, data: Any) -> None:
|
|
339
|
+
entry = None
|
|
340
|
+
if isinstance(data, dict):
|
|
341
|
+
entry = data.get("data")
|
|
342
|
+
if not isinstance(entry, dict):
|
|
343
|
+
entry = {}
|
|
344
|
+
|
|
345
|
+
self._clear()
|
|
346
|
+
normalized = self._normalize_rest(entry)
|
|
347
|
+
self._insert([normalized])
|
|
348
|
+
|
|
349
|
+
def _on_message(self, msg: dict[str, Any]) -> None:
|
|
350
|
+
data = msg.get("data")
|
|
351
|
+
if isinstance(data, dict) and data.get("result") is not None:
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
entries: list[dict[str, Any]] = []
|
|
355
|
+
if isinstance(data, list):
|
|
356
|
+
entries = data
|
|
357
|
+
elif isinstance(data, dict):
|
|
358
|
+
entries = data.get("rows") or data.get("data") or []
|
|
359
|
+
|
|
360
|
+
if not entries:
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
normalized_items: list[dict[str, Any]] = []
|
|
364
|
+
for entry in entries:
|
|
365
|
+
if not isinstance(entry, dict):
|
|
366
|
+
continue
|
|
367
|
+
normalized = self._normalize_ws(entry)
|
|
368
|
+
if normalized:
|
|
369
|
+
normalized_items.append(normalized)
|
|
370
|
+
|
|
371
|
+
if not normalized_items:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
currencies = [{"currency": item["currency"]} for item in normalized_items]
|
|
375
|
+
self._delete(currencies)
|
|
376
|
+
self._insert(normalized_items)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class CoinwFuturesDataStore(DataStoreCollection):
|
|
380
|
+
"""CoinW 合约交易 DataStoreCollection。
|
|
381
|
+
|
|
382
|
+
- REST: https://api.coinw.com/v1/perpum/instruments
|
|
383
|
+
- WebSocket: wss://ws.futurescw.com/perpum (depth)
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
def _init(self) -> None:
|
|
387
|
+
self._create("book", datastore_class=Book)
|
|
388
|
+
self._create("detail", datastore_class=Detail)
|
|
389
|
+
self._create("orders", datastore_class=Orders)
|
|
390
|
+
self._create("position", datastore_class=Position)
|
|
391
|
+
self._create("balance", datastore_class=Balance)
|
|
392
|
+
|
|
393
|
+
def onmessage(self, msg: Item, ws: ClientWebSocketResponse | None = None) -> None:
|
|
394
|
+
msg_type = msg.get("type")
|
|
395
|
+
if msg_type == "depth":
|
|
396
|
+
self.book._on_message(msg)
|
|
397
|
+
elif msg_type == "order":
|
|
398
|
+
self.orders._on_message(msg)
|
|
399
|
+
elif msg_type == "position":
|
|
400
|
+
self.position._on_message(msg)
|
|
401
|
+
elif msg_type == "assets":
|
|
402
|
+
self.balance._on_message(msg)
|
|
403
|
+
|
|
404
|
+
async def initialize(self, *aws: Awaitable[aiohttp.ClientResponse]) -> None:
|
|
405
|
+
for fut in asyncio.as_completed(aws):
|
|
406
|
+
res = await fut
|
|
407
|
+
data = await res.json()
|
|
408
|
+
if res.url.path == "/v1/perpum/instruments":
|
|
409
|
+
self.detail._onresponse(data)
|
|
410
|
+
elif res.url.path == "/v1/perpum/orders/open":
|
|
411
|
+
self.orders._onresponse(data)
|
|
412
|
+
elif res.url.path == "/v1/perpum/positions":
|
|
413
|
+
self.position._onresponse(data)
|
|
414
|
+
elif res.url.path == "/v1/perpum/account/getUserAssets":
|
|
415
|
+
self.balance._onresponse(data)
|
|
416
|
+
|
|
417
|
+
@property
|
|
418
|
+
def book(self) -> Book:
|
|
419
|
+
"""订单簿深度数据流。
|
|
420
|
+
|
|
421
|
+
数据来源:
|
|
422
|
+
- WebSocket: ``type == "depth"`` (参考 https://www.coinw.com/api-doc/futures-trading/market/subscribe-order-book)
|
|
423
|
+
|
|
424
|
+
数据结构(节选)::
|
|
425
|
+
|
|
426
|
+
{
|
|
427
|
+
"s": "BTC",
|
|
428
|
+
"S": "a", # 卖单
|
|
429
|
+
"p": "95640.3",
|
|
430
|
+
"q": "0.807"
|
|
431
|
+
}
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
return self._get("book", Book)
|
|
435
|
+
|
|
436
|
+
@property
|
|
437
|
+
def detail(self) -> Detail:
|
|
438
|
+
"""合约基础信息数据流。
|
|
439
|
+
|
|
440
|
+
响应示例(节选):
|
|
441
|
+
|
|
442
|
+
.. code:: json
|
|
443
|
+
|
|
444
|
+
{
|
|
445
|
+
"base": "btc",
|
|
446
|
+
"closeSpread": 0.0002,
|
|
447
|
+
"commissionRate": 0.0006,
|
|
448
|
+
"configBo": {
|
|
449
|
+
"margins": {
|
|
450
|
+
"100": 0.075,
|
|
451
|
+
"5": 0.00375,
|
|
452
|
+
"50": 0.0375,
|
|
453
|
+
"20": 0.015,
|
|
454
|
+
"10": 0.0075
|
|
455
|
+
},
|
|
456
|
+
"simulatedMargins": {
|
|
457
|
+
"5": 0.00375,
|
|
458
|
+
"20": 0.015,
|
|
459
|
+
"10": 0.0075
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
"createdDate": 1548950400000,
|
|
463
|
+
"defaultLeverage": 20,
|
|
464
|
+
"defaultStopLossRate": 0.99,
|
|
465
|
+
"defaultStopProfitRate": 100,
|
|
466
|
+
"depthPrecision": "0.1,1,10,50,100",
|
|
467
|
+
"iconUrl": "https://hkto-prod.oss-accelerate.aliyuncs.com/4dfca512e957e14f05da07751a96061cf4bfd5df438504f65287fa0a8c3cadb6.svg",
|
|
468
|
+
"id": 1,
|
|
469
|
+
"indexId": 1,
|
|
470
|
+
"leverage": [
|
|
471
|
+
5,
|
|
472
|
+
10,
|
|
473
|
+
20,
|
|
474
|
+
50,
|
|
475
|
+
100,
|
|
476
|
+
125,
|
|
477
|
+
200
|
|
478
|
+
],
|
|
479
|
+
"makerFee": "0.0001",
|
|
480
|
+
"maxLeverage": 200,
|
|
481
|
+
"maxPosition": 20000,
|
|
482
|
+
"minLeverage": 1,
|
|
483
|
+
"minSize": 1,
|
|
484
|
+
"name": "BTC",
|
|
485
|
+
"oneLotMargin": 1,
|
|
486
|
+
"oneLotSize": 0.001,
|
|
487
|
+
"oneMaxPosition": 15000,
|
|
488
|
+
"openSpread": 0.0003,
|
|
489
|
+
"orderLimitMaxRate": 0.05,
|
|
490
|
+
"orderLimitMinRate": 0.05,
|
|
491
|
+
"orderMarketLimitAmount": 10,
|
|
492
|
+
"orderPlanLimitAmount": 30,
|
|
493
|
+
"partitionIds": "2013,2011",
|
|
494
|
+
"platform": 0,
|
|
495
|
+
"pricePrecision": 1,
|
|
496
|
+
"quote": "usdt",
|
|
497
|
+
"selected": 0,
|
|
498
|
+
"settledAt": 1761062400000,
|
|
499
|
+
"settledPeriod": 8,
|
|
500
|
+
"settlementRate": 0.0004,
|
|
501
|
+
"sort": 1,
|
|
502
|
+
"status": "online",
|
|
503
|
+
"stopCrossPositionRate": 0.1,
|
|
504
|
+
"stopSurplusRate": 0.01,
|
|
505
|
+
"takerFee": "0.0006",
|
|
506
|
+
"updatedDate": 1752040118000,
|
|
507
|
+
"symbol": "BTC_USDT",
|
|
508
|
+
"tick_size": 1.0,
|
|
509
|
+
"step_size": 0.001
|
|
510
|
+
}
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
return self._get("detail", Detail)
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def orders(self) -> Orders:
|
|
517
|
+
"""当前订单数据流。
|
|
518
|
+
|
|
519
|
+
数据来源:
|
|
520
|
+
- REST: ``GET /v1/perpum/orders/open``
|
|
521
|
+
- WebSocket: ``type == "order"``
|
|
522
|
+
|
|
523
|
+
数据结构(节选)::
|
|
524
|
+
|
|
525
|
+
{
|
|
526
|
+
"currentPiece": "1",
|
|
527
|
+
"leverage": "50",
|
|
528
|
+
"originalType": "plan",
|
|
529
|
+
"processStatus": 0,
|
|
530
|
+
"contractType": 1,
|
|
531
|
+
"frozenFee": "0",
|
|
532
|
+
"openPrice": "175",
|
|
533
|
+
"orderStatus": "unFinish",
|
|
534
|
+
"instrument": "SOL",
|
|
535
|
+
"quantityUnit": 1,
|
|
536
|
+
"source": "web",
|
|
537
|
+
"updatedDate": 1761109078404,
|
|
538
|
+
"positionModel": 1,
|
|
539
|
+
"posType": "plan",
|
|
540
|
+
"baseSize": "0.1",
|
|
541
|
+
"quote": "usdt",
|
|
542
|
+
"liquidateBy": "manual",
|
|
543
|
+
"makerFee": "0.0001",
|
|
544
|
+
"totalPiece": "1",
|
|
545
|
+
"tradePiece": "0",
|
|
546
|
+
"orderPrice": "175",
|
|
547
|
+
"id": "33309055657317395",
|
|
548
|
+
"direction": "long",
|
|
549
|
+
"margin": "0.35",
|
|
550
|
+
"indexPrice": "185.68",
|
|
551
|
+
"quantity": "1",
|
|
552
|
+
"takerFee": "0.0006",
|
|
553
|
+
"userId": "1757458",
|
|
554
|
+
"cancelPiece": "0",
|
|
555
|
+
"createdDate": 1761109078404,
|
|
556
|
+
"positionMargin": "0.35",
|
|
557
|
+
"base": "sol",
|
|
558
|
+
"status": "open"
|
|
559
|
+
}
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
return self._get("orders", Orders)
|
|
563
|
+
|
|
564
|
+
@property
|
|
565
|
+
def position(self) -> Position:
|
|
566
|
+
"""当前持仓数据流。
|
|
567
|
+
|
|
568
|
+
数据来源:
|
|
569
|
+
- REST: ``GET /v1/perpum/positions``
|
|
570
|
+
- WebSocket: ``type == "position"``
|
|
571
|
+
|
|
572
|
+
数据结构(节选)::
|
|
573
|
+
|
|
574
|
+
{
|
|
575
|
+
"id": 2435521222631982507,
|
|
576
|
+
"instrument": "BTC",
|
|
577
|
+
"direction": "short",
|
|
578
|
+
"openPrice": 88230.5,
|
|
579
|
+
"currentPiece": 1,
|
|
580
|
+
"profitUnreal": 0.0086,
|
|
581
|
+
"status": "open"
|
|
582
|
+
}
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
return self._get("position", Position)
|
|
586
|
+
|
|
587
|
+
@property
|
|
588
|
+
def balance(self) -> Balance:
|
|
589
|
+
"""合约账户资产数据流。
|
|
590
|
+
|
|
591
|
+
数据来源:
|
|
592
|
+
- REST: ``GET /v1/perpum/account/getUserAssets``
|
|
593
|
+
- WebSocket: ``type == "assets"``
|
|
594
|
+
|
|
595
|
+
.. code:: json
|
|
596
|
+
|
|
597
|
+
{
|
|
598
|
+
"currency": "USDT",
|
|
599
|
+
"availableMargin": 0.0,
|
|
600
|
+
"availableUsdt": 0,
|
|
601
|
+
"almightyGold": 0.0,
|
|
602
|
+
"alMargin": 0,
|
|
603
|
+
"alFreeze": 0,
|
|
604
|
+
"time": 1761055905797,
|
|
605
|
+
"userId": 1757458,
|
|
606
|
+
"available": 0,
|
|
607
|
+
"margin": 0,
|
|
608
|
+
"freeze": 0
|
|
609
|
+
}
|
|
610
|
+
"""
|
|
611
|
+
|
|
612
|
+
return self._get("balance", Balance)
|
hyperquant/broker/ws.py
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import base64
|
|
3
2
|
import time
|
|
4
3
|
from typing import Any
|
|
5
4
|
|
|
6
5
|
import pybotters
|
|
6
|
+
from aiohttp import WSMsgType
|
|
7
7
|
from pybotters.ws import ClientWebSocketResponse, logger
|
|
8
|
-
from pybotters.auth import Hosts
|
|
9
|
-
import urllib
|
|
10
|
-
import yarl
|
|
11
8
|
|
|
12
9
|
|
|
13
10
|
class Heartbeat:
|
|
@@ -34,11 +31,18 @@ class Heartbeat:
|
|
|
34
31
|
while not ws.closed:
|
|
35
32
|
await ws.send_str('ping')
|
|
36
33
|
await asyncio.sleep(6)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
async def coinw(ws: ClientWebSocketResponse):
|
|
37
|
+
while not ws.closed:
|
|
38
|
+
await ws.send_json({"event": "ping"})
|
|
39
|
+
await asyncio.sleep(10.0)
|
|
37
40
|
|
|
38
41
|
pybotters.ws.HeartbeatHosts.items['futures.ourbit.com'] = Heartbeat.ourbit
|
|
39
42
|
pybotters.ws.HeartbeatHosts.items['www.ourbit.com'] = Heartbeat.ourbit_spot
|
|
40
43
|
pybotters.ws.HeartbeatHosts.items['quote.edgex.exchange'] = Heartbeat.edgex
|
|
41
44
|
pybotters.ws.HeartbeatHosts.items['uuws.rerrkvifj.com'] = Heartbeat.lbank
|
|
45
|
+
pybotters.ws.HeartbeatHosts.items['ws.futurescw.com'] = Heartbeat.coinw
|
|
42
46
|
|
|
43
47
|
class WssAuth:
|
|
44
48
|
@staticmethod
|
|
@@ -63,4 +67,49 @@ class WssAuth:
|
|
|
63
67
|
else:
|
|
64
68
|
logger.warning(f"WebSocket login failed: {data}")
|
|
65
69
|
|
|
70
|
+
@staticmethod
|
|
71
|
+
async def coinw(ws: ClientWebSocketResponse):
|
|
72
|
+
creds = ws._response._session.__dict__["_apis"].get(
|
|
73
|
+
pybotters.ws.AuthHosts.items[ws._response.url.host].name
|
|
74
|
+
)
|
|
75
|
+
if not creds:
|
|
76
|
+
raise RuntimeError("CoinW credentials are required for websocket login.")
|
|
77
|
+
if isinstance(creds, dict):
|
|
78
|
+
raise RuntimeError("CoinW credentials must be a sequence, not a dict.")
|
|
79
|
+
if len(creds) < 1:
|
|
80
|
+
raise RuntimeError("CoinW credentials are incomplete.")
|
|
81
|
+
|
|
82
|
+
api_key = creds[0]
|
|
83
|
+
secret = creds[1] if len(creds) > 1 else ""
|
|
84
|
+
|
|
85
|
+
await ws.send_json(
|
|
86
|
+
{
|
|
87
|
+
"event": "login",
|
|
88
|
+
"params": {
|
|
89
|
+
"api_key": api_key,
|
|
90
|
+
"passphrase": secret.decode(),
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async for msg in ws:
|
|
96
|
+
if msg.type != WSMsgType.TEXT:
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
data:dict = msg.json()
|
|
100
|
+
except Exception: # pragma: no cover - defensive
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
channel = data.get("channel")
|
|
104
|
+
event_type = data.get("type")
|
|
105
|
+
if channel == "login" or event_type == "login":
|
|
106
|
+
result = data.get("data", {}).get("result")
|
|
107
|
+
if result is not True:
|
|
108
|
+
raise RuntimeError(f"CoinW WebSocket login failed: {data}")
|
|
109
|
+
break
|
|
110
|
+
if data.get("event") == "pong":
|
|
111
|
+
# ignore heartbeat responses while waiting
|
|
112
|
+
continue
|
|
113
|
+
|
|
66
114
|
pybotters.ws.AuthHosts.items['futures.ourbit.com'] = pybotters.auth.Item("ourbit", WssAuth.ourbit)
|
|
115
|
+
pybotters.ws.AuthHosts.items['ws.futurescw.com'] = pybotters.auth.Item("coinw", WssAuth.coinw)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperquant
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.83
|
|
4
4
|
Summary: A minimal yet hyper-efficient backtesting framework for quantitative trading
|
|
5
5
|
Project-URL: Homepage, https://github.com/yourusername/hyperquant
|
|
6
6
|
Project-URL: Issues, https://github.com/yourusername/hyperquant/issues
|
|
@@ -4,18 +4,20 @@ hyperquant/db.py,sha256=i2TjkCbmH4Uxo7UTDvOYBfy973gLcGexdzuT_YcSeIE,6678
|
|
|
4
4
|
hyperquant/draw.py,sha256=up_lQ3pHeVLoNOyh9vPjgNwjD0M-6_IetSGviQUgjhY,54624
|
|
5
5
|
hyperquant/logkit.py,sha256=nUo7nx5eONvK39GOhWwS41zNRL756P2J7-5xGzwXnTY,8462
|
|
6
6
|
hyperquant/notikit.py,sha256=x5yAZ_tAvLQRXcRbcg-VabCaN45LUhvlTZnUqkIqfAA,3596
|
|
7
|
-
hyperquant/broker/auth.py,sha256=
|
|
7
|
+
hyperquant/broker/auth.py,sha256=xNZEQP0LRRV9BkT2uXBJ-vFfeahUnRVq1bjIT6YbQu8,10089
|
|
8
8
|
hyperquant/broker/bitget.py,sha256=X_S0LKZ7FZAEb6oEMr1vdGP1fondzK74BhmNTpRDSEA,9488
|
|
9
|
+
hyperquant/broker/coinw.py,sha256=iNAQQ562KeIBbADgyAarTMDwCXdA9qO3-LwFBPvxH-U,14023
|
|
9
10
|
hyperquant/broker/edgex.py,sha256=TqUO2KRPLN_UaxvtLL6HnA9dAQXC1sGxOfqTHd6W5k8,18378
|
|
10
11
|
hyperquant/broker/hyperliquid.py,sha256=7MxbI9OyIBcImDelPJu-8Nd53WXjxPB5TwE6gsjHbto,23252
|
|
11
|
-
hyperquant/broker/lbank.py,sha256=
|
|
12
|
+
hyperquant/broker/lbank.py,sha256=98M5wmSoeHwbBYMA3rh25zqLb6fQKVaEmwqALF5nOvY,22181
|
|
12
13
|
hyperquant/broker/ourbit.py,sha256=NUcDSIttf-HGWzoW1uBTrGLPHlkuemMjYCm91MigTno,18228
|
|
13
|
-
hyperquant/broker/ws.py,sha256=
|
|
14
|
+
hyperquant/broker/ws.py,sha256=Ozr5gBzVQ7cnxajT0MdpyKNyVgoCUu6LLjErU9fZxgc,4080
|
|
14
15
|
hyperquant/broker/lib/edgex_sign.py,sha256=lLUCmY8HHRLfLKyGrlTJYaBlSHPsIMWg3EZnQJKcmyk,95785
|
|
15
16
|
hyperquant/broker/lib/hpstore.py,sha256=LnLK2zmnwVvhEbLzYI-jz_SfYpO1Dv2u2cJaRAb84D8,8296
|
|
16
17
|
hyperquant/broker/lib/hyper_types.py,sha256=HqjjzjUekldjEeVn6hxiWA8nevAViC2xHADOzDz9qyw,991
|
|
17
18
|
hyperquant/broker/lib/util.py,sha256=iMU1qF0CHj5zzlIMEQGwjz-qtEVosEe7slXOCuB7Rcw,566
|
|
18
19
|
hyperquant/broker/models/bitget.py,sha256=0RwDY75KrJb-c-oYoMxbqxWfsILe-n_Npojz4UFUq7c,11389
|
|
20
|
+
hyperquant/broker/models/coinw.py,sha256=iEBMTiZ_UjjlwhUGA74e6vvR2pXCl1ZoGKSyBPgqTsw,18658
|
|
19
21
|
hyperquant/broker/models/edgex.py,sha256=vPAkceal44cjTYKQ_0BoNAskOpmkno_Yo1KxgMLPc6Y,33954
|
|
20
22
|
hyperquant/broker/models/hyperliquid.py,sha256=c4r5739ibZfnk69RxPjQl902AVuUOwT8RNvKsMtwXBY,9459
|
|
21
23
|
hyperquant/broker/models/lbank.py,sha256=vHkNKxIMzpoC_EwcZnEOPOupizF92yGWi9GKxvYYFUQ,19181
|
|
@@ -24,6 +26,6 @@ hyperquant/datavison/_util.py,sha256=92qk4vO856RqycO0YqEIHJlEg-W9XKapDVqAMxe6rbw
|
|
|
24
26
|
hyperquant/datavison/binance.py,sha256=3yNKTqvt_vUQcxzeX4ocMsI5k6Q6gLZrvgXxAEad6Kc,5001
|
|
25
27
|
hyperquant/datavison/coinglass.py,sha256=PEjdjISP9QUKD_xzXNzhJ9WFDTlkBrRQlVL-5pxD5mo,10482
|
|
26
28
|
hyperquant/datavison/okx.py,sha256=yg8WrdQ7wgWHNAInIgsWPM47N3Wkfr253169IPAycAY,6898
|
|
27
|
-
hyperquant-0.
|
|
28
|
-
hyperquant-0.
|
|
29
|
-
hyperquant-0.
|
|
29
|
+
hyperquant-0.83.dist-info/METADATA,sha256=SlHTbSAsclL15RMQjveqABInk7gil66sZljrdp0IUlk,4317
|
|
30
|
+
hyperquant-0.83.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
31
|
+
hyperquant-0.83.dist-info/RECORD,,
|
|
File without changes
|