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.
- hyperquant/broker/auth.py +144 -9
- hyperquant/broker/bitget.py +101 -0
- hyperquant/broker/edgex.py +500 -0
- hyperquant/broker/lbank.py +354 -0
- hyperquant/broker/lib/edgex_sign.py +455 -0
- hyperquant/broker/lib/util.py +22 -0
- hyperquant/broker/models/bitget.py +283 -0
- hyperquant/broker/models/edgex.py +1053 -0
- hyperquant/broker/models/lbank.py +547 -0
- hyperquant/broker/models/ourbit.py +184 -135
- hyperquant/broker/ourbit.py +16 -5
- hyperquant/broker/ws.py +21 -3
- hyperquant/core.py +3 -0
- {hyperquant-0.5.dist-info → hyperquant-0.7.dist-info}/METADATA +1 -1
- hyperquant-0.7.dist-info/RECORD +29 -0
- hyperquant-0.5.dist-info/RECORD +0 -21
- {hyperquant-0.5.dist-info → hyperquant-0.7.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1053 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
from typing import Any, Awaitable, TYPE_CHECKING
|
5
|
+
|
6
|
+
from aiohttp import ClientResponse
|
7
|
+
import aiohttp
|
8
|
+
from pybotters.store import DataStore, DataStoreCollection
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from pybotters.typedefs import Item
|
12
|
+
from pybotters.ws import ClientWebSocketResponse
|
13
|
+
|
14
|
+
|
15
|
+
class Book(DataStore):
|
16
|
+
"""Order book data store for the Edgex websocket feed."""
|
17
|
+
|
18
|
+
_KEYS = ["c", "S", "p"]
|
19
|
+
|
20
|
+
def _init(self) -> None:
|
21
|
+
self._version: int | str | None = None
|
22
|
+
self.limit: int | None = None
|
23
|
+
|
24
|
+
def _on_message(self, msg: dict[str, Any]) -> None:
|
25
|
+
content = msg.get("content") or {}
|
26
|
+
entries = content.get("data") or []
|
27
|
+
data_type = (content.get("dataType") or "").lower()
|
28
|
+
|
29
|
+
for entry in entries:
|
30
|
+
contract_id = entry.get("contractId")
|
31
|
+
if contract_id is None:
|
32
|
+
continue
|
33
|
+
|
34
|
+
contract_name = entry.get("contractName")
|
35
|
+
end_version = entry.get("endVersion")
|
36
|
+
depth_type = (entry.get("depthType") or "").lower()
|
37
|
+
|
38
|
+
is_snapshot = data_type == "snapshot" or depth_type == "snapshot"
|
39
|
+
|
40
|
+
if is_snapshot:
|
41
|
+
self._handle_snapshot(
|
42
|
+
contract_id,
|
43
|
+
contract_name,
|
44
|
+
entry,
|
45
|
+
)
|
46
|
+
else:
|
47
|
+
self._handle_delta(
|
48
|
+
contract_id,
|
49
|
+
contract_name,
|
50
|
+
entry,
|
51
|
+
)
|
52
|
+
|
53
|
+
if end_version is not None:
|
54
|
+
self._version = self._normalize_version(end_version)
|
55
|
+
|
56
|
+
def _handle_snapshot(
|
57
|
+
self,
|
58
|
+
contract_id: str,
|
59
|
+
contract_name: str | None,
|
60
|
+
entry: dict[str, Any],
|
61
|
+
) -> None:
|
62
|
+
asks = entry.get("asks") or []
|
63
|
+
bids = entry.get("bids") or []
|
64
|
+
|
65
|
+
self._find_and_delete({"c": contract_id})
|
66
|
+
|
67
|
+
payload: list[dict[str, Any]] = []
|
68
|
+
payload.extend(
|
69
|
+
self._build_items(
|
70
|
+
contract_id,
|
71
|
+
contract_name,
|
72
|
+
"a",
|
73
|
+
asks,
|
74
|
+
)
|
75
|
+
)
|
76
|
+
payload.extend(
|
77
|
+
self._build_items(
|
78
|
+
contract_id,
|
79
|
+
contract_name,
|
80
|
+
"b",
|
81
|
+
bids,
|
82
|
+
)
|
83
|
+
)
|
84
|
+
|
85
|
+
if payload:
|
86
|
+
self._insert(payload)
|
87
|
+
self._trim(contract_id, contract_name)
|
88
|
+
|
89
|
+
def _handle_delta(
|
90
|
+
self,
|
91
|
+
contract_id: str,
|
92
|
+
contract_name: str | None,
|
93
|
+
entry: dict[str, Any],
|
94
|
+
) -> None:
|
95
|
+
updates: list[dict[str, Any]] = []
|
96
|
+
deletes: list[dict[str, Any]] = []
|
97
|
+
|
98
|
+
asks = entry.get("asks") or []
|
99
|
+
bids = entry.get("bids") or []
|
100
|
+
|
101
|
+
for side, levels in (("a", asks), ("b", bids)):
|
102
|
+
for row in levels:
|
103
|
+
price, size = self._extract_price_size(row)
|
104
|
+
criteria = {"c": contract_id, "S": side, "p": price}
|
105
|
+
|
106
|
+
if not size or float(size) == 0.0:
|
107
|
+
deletes.append(criteria)
|
108
|
+
continue
|
109
|
+
|
110
|
+
updates.append(
|
111
|
+
{
|
112
|
+
"c": contract_id,
|
113
|
+
"S": side,
|
114
|
+
"p": price,
|
115
|
+
"q": size,
|
116
|
+
"s": self._symbol(contract_id, contract_name),
|
117
|
+
}
|
118
|
+
)
|
119
|
+
|
120
|
+
if deletes:
|
121
|
+
self._delete(deletes)
|
122
|
+
if updates:
|
123
|
+
self._update(updates)
|
124
|
+
self._trim(contract_id, contract_name)
|
125
|
+
|
126
|
+
def _build_items(
|
127
|
+
self,
|
128
|
+
contract_id: str,
|
129
|
+
contract_name: str | None,
|
130
|
+
side: str,
|
131
|
+
rows: list[dict[str, Any]],
|
132
|
+
) -> list[dict[str, Any]]:
|
133
|
+
items: list[dict[str, Any]] = []
|
134
|
+
for row in rows:
|
135
|
+
price, size = self._extract_price_size(row)
|
136
|
+
if not size or float(size) == 0.0:
|
137
|
+
continue
|
138
|
+
items.append(
|
139
|
+
{
|
140
|
+
"c": contract_id,
|
141
|
+
"S": side,
|
142
|
+
"p": price,
|
143
|
+
"q": size,
|
144
|
+
"s": self._symbol(contract_id, contract_name),
|
145
|
+
}
|
146
|
+
)
|
147
|
+
return items
|
148
|
+
|
149
|
+
@staticmethod
|
150
|
+
def _normalize_version(value: Any) -> int | str:
|
151
|
+
if value is None:
|
152
|
+
return value
|
153
|
+
try:
|
154
|
+
return int(value)
|
155
|
+
except (TypeError, ValueError):
|
156
|
+
return str(value)
|
157
|
+
|
158
|
+
@staticmethod
|
159
|
+
def _to_str(value: Any) -> str | None:
|
160
|
+
if value is None:
|
161
|
+
return None
|
162
|
+
return str(value)
|
163
|
+
|
164
|
+
@staticmethod
|
165
|
+
def _extract_price_size(row: dict[str, Any]) -> tuple[str, str]:
|
166
|
+
return str(row["price"]), str(row["size"])
|
167
|
+
|
168
|
+
def _trim(self, contract_id: str, contract_name: str | None) -> None:
|
169
|
+
if self.limit is None:
|
170
|
+
return
|
171
|
+
|
172
|
+
query: dict[str, Any]
|
173
|
+
symbol = self._symbol(contract_id, contract_name)
|
174
|
+
if symbol:
|
175
|
+
query = {"s": symbol}
|
176
|
+
else:
|
177
|
+
query = {"c": contract_id}
|
178
|
+
|
179
|
+
sort_data = self.sorted(query, self.limit)
|
180
|
+
asks = sort_data.get("a", [])
|
181
|
+
bids = sort_data.get("b", [])
|
182
|
+
|
183
|
+
self._find_and_delete(query)
|
184
|
+
|
185
|
+
trimmed = asks + bids
|
186
|
+
if trimmed:
|
187
|
+
self._insert(trimmed)
|
188
|
+
|
189
|
+
@staticmethod
|
190
|
+
def _symbol(contract_id: str, contract_name: str | None) -> str:
|
191
|
+
if contract_name:
|
192
|
+
return str(contract_name)
|
193
|
+
return str(contract_id)
|
194
|
+
|
195
|
+
@property
|
196
|
+
def version(self) -> int | str | None:
|
197
|
+
"""返回当前缓存的订单簿版本号。"""
|
198
|
+
return self._version
|
199
|
+
|
200
|
+
def sorted(
|
201
|
+
self,
|
202
|
+
query: dict[str, Any] | None = None,
|
203
|
+
limit: int | None = None,
|
204
|
+
) -> dict[str, list[dict[str, Any]]]:
|
205
|
+
"""按买卖方向与价格排序后的订单簿视图。"""
|
206
|
+
return self._sorted(
|
207
|
+
item_key="S",
|
208
|
+
item_asc_key="a",
|
209
|
+
item_desc_key="b",
|
210
|
+
sort_key="p",
|
211
|
+
query=query,
|
212
|
+
limit=limit,
|
213
|
+
)
|
214
|
+
|
215
|
+
|
216
|
+
class Ticker(DataStore):
|
217
|
+
"""24 小时行情推送数据。"""
|
218
|
+
|
219
|
+
_KEYS = ["c"]
|
220
|
+
|
221
|
+
def _on_message(self, msg: dict[str, Any]) -> None:
|
222
|
+
content = msg.get("content") or {}
|
223
|
+
entries = content.get("data") or []
|
224
|
+
data_type = (content.get("dataType") or "").lower()
|
225
|
+
|
226
|
+
for entry in entries:
|
227
|
+
item = self._format(entry)
|
228
|
+
if item is None:
|
229
|
+
continue
|
230
|
+
|
231
|
+
criteria = {"c": item["c"]}
|
232
|
+
if data_type == "snapshot":
|
233
|
+
self._find_and_delete(criteria)
|
234
|
+
self._insert([item])
|
235
|
+
else:
|
236
|
+
self._update([item])
|
237
|
+
|
238
|
+
def _onresponse(self, data: dict[str, Any]) -> None:
|
239
|
+
entries = data.get("data") or []
|
240
|
+
|
241
|
+
if not isinstance(entries, list):
|
242
|
+
entries = [entries]
|
243
|
+
|
244
|
+
items = []
|
245
|
+
for entry in entries:
|
246
|
+
item = self._format(entry)
|
247
|
+
if item:
|
248
|
+
items.append(item)
|
249
|
+
|
250
|
+
self._clear()
|
251
|
+
if items:
|
252
|
+
self._insert(items)
|
253
|
+
|
254
|
+
def _format(self, entry: dict[str, Any]) -> dict[str, Any] | None:
|
255
|
+
contract_id = entry.get("contractId")
|
256
|
+
if contract_id is None:
|
257
|
+
return None
|
258
|
+
|
259
|
+
item: dict[str, Any] = {"c": str(contract_id)}
|
260
|
+
|
261
|
+
name = entry.get("contractName")
|
262
|
+
if name is not None:
|
263
|
+
item["s"] = str(name)
|
264
|
+
|
265
|
+
fields = [
|
266
|
+
"priceChange",
|
267
|
+
"priceChangePercent",
|
268
|
+
"trades",
|
269
|
+
"size",
|
270
|
+
"value",
|
271
|
+
"high",
|
272
|
+
"low",
|
273
|
+
"open",
|
274
|
+
"close",
|
275
|
+
"highTime",
|
276
|
+
"lowTime",
|
277
|
+
"startTime",
|
278
|
+
"endTime",
|
279
|
+
"lastPrice",
|
280
|
+
"indexPrice",
|
281
|
+
"oraclePrice",
|
282
|
+
"openInterest",
|
283
|
+
"fundingRate",
|
284
|
+
"fundingTime",
|
285
|
+
"nextFundingTime",
|
286
|
+
"bestAskPrice",
|
287
|
+
"bestBidPrice",
|
288
|
+
]
|
289
|
+
|
290
|
+
for key in fields:
|
291
|
+
value = entry.get(key)
|
292
|
+
if value is not None:
|
293
|
+
item[key] = str(value)
|
294
|
+
|
295
|
+
return item
|
296
|
+
|
297
|
+
|
298
|
+
class Order(DataStore):
|
299
|
+
"""Order data store combining REST results with trade-event deltas.
|
300
|
+
|
301
|
+
We only keep fields that are practical for trading book-keeping: identifiers,
|
302
|
+
basic order parameters, cumulative fills, high-level status and timestamps.
|
303
|
+
Network payloads carry hundreds of fields (``l2`` signatures, TPSL templates,
|
304
|
+
liquidation metadata, etc.), but the extra data adds noise and bloats memory
|
305
|
+
consumption. This store narrows every entry to a compact schema while still
|
306
|
+
supporting diff events from the private websocket feed.
|
307
|
+
"""
|
308
|
+
|
309
|
+
_KEYS = ["orderId"]
|
310
|
+
|
311
|
+
_TERMINAL_STATUSES = {
|
312
|
+
"FILLED",
|
313
|
+
"CANCELED",
|
314
|
+
"CANCELLED",
|
315
|
+
"REJECTED",
|
316
|
+
"EXPIRED",
|
317
|
+
}
|
318
|
+
|
319
|
+
_ACTIVE_STATUSES = {
|
320
|
+
"OPEN",
|
321
|
+
"PARTIALLY_FILLED",
|
322
|
+
"PENDING",
|
323
|
+
"CREATED",
|
324
|
+
"ACKNOWLEDGED",
|
325
|
+
}
|
326
|
+
|
327
|
+
_KEEP_FIELDS = (
|
328
|
+
"userId",
|
329
|
+
"accountId",
|
330
|
+
"coinId",
|
331
|
+
"contractId",
|
332
|
+
"clientOrderId",
|
333
|
+
"type",
|
334
|
+
"timeInForce",
|
335
|
+
"reduceOnly",
|
336
|
+
"price",
|
337
|
+
"size",
|
338
|
+
"cumFillSize",
|
339
|
+
"cumFillValue",
|
340
|
+
"cumMatchSize",
|
341
|
+
"cumMatchValue",
|
342
|
+
"cumMatchFee",
|
343
|
+
"triggerPrice",
|
344
|
+
"triggerPriceType",
|
345
|
+
"cancelReason",
|
346
|
+
"createdTime",
|
347
|
+
"updatedTime",
|
348
|
+
"matchSequenceId",
|
349
|
+
)
|
350
|
+
|
351
|
+
_BOOL_FIELDS = {"reduceOnly"}
|
352
|
+
|
353
|
+
def _on_message(self, msg: dict[str, Any]) -> None:
|
354
|
+
content = msg.get("content") or {}
|
355
|
+
data = content.get("data") or {}
|
356
|
+
orders = data.get("order") or []
|
357
|
+
|
358
|
+
if not isinstance(orders, list):
|
359
|
+
orders = [orders]
|
360
|
+
|
361
|
+
items = [self._format(order) for order in orders]
|
362
|
+
items = [item for item in items if item]
|
363
|
+
if not items:
|
364
|
+
return
|
365
|
+
|
366
|
+
event = (content.get("event") or "").lower()
|
367
|
+
if event == "snapshot":
|
368
|
+
self._clear()
|
369
|
+
self._insert(items)
|
370
|
+
return
|
371
|
+
|
372
|
+
for item in items:
|
373
|
+
status = str(item.get("status") or "").upper()
|
374
|
+
criteria = {"orderId": item["orderId"]}
|
375
|
+
existing = self.find(criteria)
|
376
|
+
|
377
|
+
if status in self._TERMINAL_STATUSES:
|
378
|
+
if existing:
|
379
|
+
self._update([item])
|
380
|
+
else:
|
381
|
+
self._insert([item])
|
382
|
+
self._find_and_delete(criteria)
|
383
|
+
continue
|
384
|
+
|
385
|
+
if status and status not in self._ACTIVE_STATUSES:
|
386
|
+
if existing:
|
387
|
+
self._update([item])
|
388
|
+
else:
|
389
|
+
self._insert([item])
|
390
|
+
self._find_and_delete(criteria)
|
391
|
+
continue
|
392
|
+
|
393
|
+
if existing:
|
394
|
+
self._update([item])
|
395
|
+
else:
|
396
|
+
self._insert([item])
|
397
|
+
|
398
|
+
def _onresponse(self, data: dict[str, Any]) -> None:
|
399
|
+
payload = data.get("data")
|
400
|
+
|
401
|
+
if isinstance(payload, dict):
|
402
|
+
orders = payload.get("dataList") or payload.get("orderList") or []
|
403
|
+
else:
|
404
|
+
orders = payload or []
|
405
|
+
|
406
|
+
if not isinstance(orders, list):
|
407
|
+
orders = [orders]
|
408
|
+
|
409
|
+
items = [self._format(order) for order in orders]
|
410
|
+
items = [item for item in items if item]
|
411
|
+
|
412
|
+
self._clear()
|
413
|
+
if items:
|
414
|
+
self._insert(items)
|
415
|
+
|
416
|
+
@staticmethod
|
417
|
+
def _normalize_order_id(value: Any) -> str | None:
|
418
|
+
if value is None:
|
419
|
+
return None
|
420
|
+
return str(value)
|
421
|
+
|
422
|
+
@staticmethod
|
423
|
+
def _normalize_side(value: Any) -> str | None:
|
424
|
+
if value is None:
|
425
|
+
return None
|
426
|
+
if isinstance(value, str):
|
427
|
+
return value.lower()
|
428
|
+
return str(value)
|
429
|
+
|
430
|
+
@staticmethod
|
431
|
+
def _normalize_status(value: Any) -> str | None:
|
432
|
+
if value is None:
|
433
|
+
return None
|
434
|
+
return str(value).upper()
|
435
|
+
|
436
|
+
@staticmethod
|
437
|
+
def _stringify(value: Any) -> Any:
|
438
|
+
if value is None:
|
439
|
+
return None
|
440
|
+
if isinstance(value, (bool, dict, list)):
|
441
|
+
return value
|
442
|
+
return str(value)
|
443
|
+
|
444
|
+
def _format(self, order: dict[str, Any] | None) -> dict[str, Any] | None:
|
445
|
+
if not order:
|
446
|
+
return None
|
447
|
+
|
448
|
+
order_id = (
|
449
|
+
order.get("orderId")
|
450
|
+
or order.get("id")
|
451
|
+
or order.get("order_id")
|
452
|
+
or order.get("orderID")
|
453
|
+
)
|
454
|
+
|
455
|
+
normalized_id = self._normalize_order_id(order_id)
|
456
|
+
if normalized_id is None:
|
457
|
+
return None
|
458
|
+
|
459
|
+
item: dict[str, Any] = {"orderId": normalized_id, "id": normalized_id}
|
460
|
+
|
461
|
+
side = self._normalize_side(order.get("side"))
|
462
|
+
if side is not None:
|
463
|
+
item["side"] = side
|
464
|
+
|
465
|
+
status = self._normalize_status(order.get("status"))
|
466
|
+
if status is not None:
|
467
|
+
item["status"] = status
|
468
|
+
|
469
|
+
contract_name = order.get("contractName")
|
470
|
+
if contract_name:
|
471
|
+
symbol = self._stringify(contract_name)
|
472
|
+
item["contractName"] = symbol
|
473
|
+
item.setdefault("symbol", symbol)
|
474
|
+
|
475
|
+
for field in self._KEEP_FIELDS:
|
476
|
+
if field in ("side", "status"):
|
477
|
+
continue
|
478
|
+
value = order.get(field)
|
479
|
+
if value is None:
|
480
|
+
continue
|
481
|
+
if field in self._BOOL_FIELDS:
|
482
|
+
item[field] = bool(value)
|
483
|
+
else:
|
484
|
+
item[field] = self._stringify(value)
|
485
|
+
|
486
|
+
return item
|
487
|
+
|
488
|
+
|
489
|
+
class Balance(DataStore):
|
490
|
+
"""Account balance snapshot retaining only the trading-critical fields."""
|
491
|
+
|
492
|
+
_KEYS = ["accountId", "coinId"]
|
493
|
+
|
494
|
+
|
495
|
+
def _onresponse(self, data: dict[str, Any]) -> None:
|
496
|
+
data = data.get('data', {})
|
497
|
+
collateral_assets = data.get('collateralAssetModelList') or []
|
498
|
+
if collateral_assets:
|
499
|
+
self._update(collateral_assets)
|
500
|
+
|
501
|
+
def _on_message(self, msg: dict[str, Any]) -> None:
|
502
|
+
pass
|
503
|
+
|
504
|
+
|
505
|
+
class Position(DataStore):
|
506
|
+
"""
|
507
|
+
Stores per-account open positions in a simplified camelCase schema.
|
508
|
+
Only the current open position fields are retained: positionId, contractId, accountId,
|
509
|
+
userId, coinId, side, size, value, fee, fundingFee.
|
510
|
+
"""
|
511
|
+
|
512
|
+
_KEYS = ["positionId"]
|
513
|
+
|
514
|
+
@staticmethod
|
515
|
+
def _stringify(value: Any) -> Any:
|
516
|
+
if value is None:
|
517
|
+
return None
|
518
|
+
if isinstance(value, (bool, dict, list)):
|
519
|
+
return value
|
520
|
+
return str(value)
|
521
|
+
|
522
|
+
def _onresponse(self, data: dict[str, Any]) -> None:
|
523
|
+
"""
|
524
|
+
Handle REST response for getAccountAsset (open positions snapshot).
|
525
|
+
Expects data from getAccountAsset (REST), which returns a snapshot of **current open positions**,
|
526
|
+
as a list in data["positionList"].
|
527
|
+
Each entry is normalized to camelCase schema, only including essential fields for the current open position.
|
528
|
+
"""
|
529
|
+
data = data.get("data", {}) or {}
|
530
|
+
positions = data.get("positionList") or []
|
531
|
+
if not isinstance(positions, list):
|
532
|
+
positions = [positions]
|
533
|
+
items = [self._normalize_position(pos) for pos in positions]
|
534
|
+
self._clear()
|
535
|
+
if items:
|
536
|
+
self._update(items)
|
537
|
+
|
538
|
+
def _on_message(self, msg: dict[str, Any]) -> None:
|
539
|
+
data = msg.get("content", {}).get("data", {})
|
540
|
+
if not data:
|
541
|
+
return
|
542
|
+
positions = data.get("position")
|
543
|
+
if not positions:
|
544
|
+
return
|
545
|
+
items = [self._normalize_position(pos) for pos in positions]
|
546
|
+
self._clear()
|
547
|
+
if items:
|
548
|
+
self._update(items)
|
549
|
+
|
550
|
+
def _normalize_position(self, pos: dict[str, Any]) -> dict[str, Any]:
|
551
|
+
# Only keep essential fields for the current open position
|
552
|
+
def get(key, *alts):
|
553
|
+
for k in (key,) + alts:
|
554
|
+
if k in pos and pos[k] is not None:
|
555
|
+
return pos[k]
|
556
|
+
return None
|
557
|
+
|
558
|
+
open_size = get("openSize")
|
559
|
+
open_value = get("openValue")
|
560
|
+
open_fee = get("openFee")
|
561
|
+
funding_fee = get("fundingFee")
|
562
|
+
|
563
|
+
# side: "long" if openSize > 0, "short" if openSize < 0, None if 0
|
564
|
+
side = None
|
565
|
+
try:
|
566
|
+
if open_size is not None:
|
567
|
+
fsize = float(open_size)
|
568
|
+
if fsize > 0:
|
569
|
+
side = "long"
|
570
|
+
elif fsize < 0:
|
571
|
+
side = "short"
|
572
|
+
except Exception:
|
573
|
+
side = None
|
574
|
+
|
575
|
+
size = None
|
576
|
+
if open_size is not None:
|
577
|
+
try:
|
578
|
+
size = str(abs(float(open_size)))
|
579
|
+
except Exception:
|
580
|
+
size = str(open_size)
|
581
|
+
value = None
|
582
|
+
if open_value is not None:
|
583
|
+
try:
|
584
|
+
value = str(abs(float(open_value)))
|
585
|
+
except Exception:
|
586
|
+
value = str(open_value)
|
587
|
+
|
588
|
+
item = {
|
589
|
+
"positionId": self._stringify(get("positionId", "position_id")),
|
590
|
+
"contractId": self._stringify(get("contractId")),
|
591
|
+
"accountId": self._stringify(get("accountId")),
|
592
|
+
"userId": self._stringify(get("userId")),
|
593
|
+
"coinId": self._stringify(get("coinId")),
|
594
|
+
"side": side,
|
595
|
+
"size": size,
|
596
|
+
"value": value,
|
597
|
+
"fee": self._stringify(open_fee),
|
598
|
+
"fundingFee": self._stringify(funding_fee),
|
599
|
+
}
|
600
|
+
return item
|
601
|
+
|
602
|
+
|
603
|
+
class CoinMeta(DataStore):
|
604
|
+
"""Coin metadata (precision, StarkEx info, etc.)."""
|
605
|
+
|
606
|
+
_KEYS = ["coinId"]
|
607
|
+
|
608
|
+
def _onresponse(self, data: dict[str, Any]) -> None:
|
609
|
+
coins = (data.get("data") or {}).get("coinList") or []
|
610
|
+
items: list[dict[str, Any]] = []
|
611
|
+
|
612
|
+
for coin in coins:
|
613
|
+
coin_id = coin.get("coinId")
|
614
|
+
if coin_id is None:
|
615
|
+
continue
|
616
|
+
items.append(
|
617
|
+
{
|
618
|
+
"coinId": str(coin_id),
|
619
|
+
"coinName": coin.get("coinName"),
|
620
|
+
"stepSize": coin.get("stepSize"),
|
621
|
+
"showStepSize": coin.get("showStepSize"),
|
622
|
+
"starkExAssetId": coin.get("starkExAssetId"),
|
623
|
+
}
|
624
|
+
)
|
625
|
+
|
626
|
+
self._clear()
|
627
|
+
if items:
|
628
|
+
self._insert(items)
|
629
|
+
|
630
|
+
|
631
|
+
class ContractMeta(DataStore):
|
632
|
+
"""Per-contract trading parameters from the metadata endpoint."""
|
633
|
+
|
634
|
+
_KEYS = ["contractName"]
|
635
|
+
|
636
|
+
_FIELDS = (
|
637
|
+
"contractName",
|
638
|
+
"baseCoinId",
|
639
|
+
"quoteCoinId",
|
640
|
+
"tickSize",
|
641
|
+
"stepSize",
|
642
|
+
"minOrderSize",
|
643
|
+
"maxOrderSize",
|
644
|
+
"defaultTakerFeeRate",
|
645
|
+
"defaultMakerFeeRate",
|
646
|
+
"enableTrade",
|
647
|
+
"fundingInterestRate",
|
648
|
+
"fundingImpactMarginNotional",
|
649
|
+
"fundingRateIntervalMin",
|
650
|
+
"starkExSyntheticAssetId",
|
651
|
+
"starkExResolution",
|
652
|
+
)
|
653
|
+
|
654
|
+
def _onresponse(self, data: dict[str, Any]) -> None:
|
655
|
+
contracts = (data.get("data") or {}).get("contractList") or []
|
656
|
+
items: list[dict[str, Any]] = []
|
657
|
+
|
658
|
+
for contract in contracts:
|
659
|
+
contract_id = contract.get("contractId")
|
660
|
+
if contract_id is None:
|
661
|
+
continue
|
662
|
+
|
663
|
+
payload = {"contractId": str(contract_id)}
|
664
|
+
for key in self._FIELDS:
|
665
|
+
payload[key] = contract.get(key)
|
666
|
+
payload["riskTierList"] = self._simplify_risk_tiers(
|
667
|
+
contract.get("riskTierList")
|
668
|
+
)
|
669
|
+
|
670
|
+
items.append(payload)
|
671
|
+
|
672
|
+
self._clear()
|
673
|
+
if items:
|
674
|
+
self._insert(items)
|
675
|
+
|
676
|
+
@staticmethod
|
677
|
+
def _simplify_risk_tiers(risk_tiers: Any) -> list[dict[str, Any]]:
|
678
|
+
items: list[dict[str, Any]] = []
|
679
|
+
for tier in risk_tiers or []:
|
680
|
+
items.append(
|
681
|
+
{
|
682
|
+
"tier": tier.get("tier"),
|
683
|
+
"positionValueUpperBound": tier.get("positionValueUpperBound"),
|
684
|
+
"maxLeverage": tier.get("maxLeverage"),
|
685
|
+
"maintenanceMarginRate": tier.get("maintenanceMarginRate"),
|
686
|
+
"starkExRisk": tier.get("starkExRisk"),
|
687
|
+
"starkExUpperBound": tier.get("starkExUpperBound"),
|
688
|
+
}
|
689
|
+
)
|
690
|
+
return items
|
691
|
+
|
692
|
+
|
693
|
+
class AppMeta(DataStore):
|
694
|
+
"""Global metadata (appName, env, fee account, etc.)."""
|
695
|
+
|
696
|
+
_KEYS = ["appName"]
|
697
|
+
|
698
|
+
def _onresponse(self, data: dict[str, Any]) -> None:
|
699
|
+
appdata = (data.get("data") or {}).get("global") or {}
|
700
|
+
if not appdata:
|
701
|
+
self._clear()
|
702
|
+
return
|
703
|
+
# Convert all values to str where appropriate, but preserve fields as-is (for bool/int etc).
|
704
|
+
item = {}
|
705
|
+
for k, v in appdata.items():
|
706
|
+
if k == "starkExCollateralCoin" and isinstance(v, dict):
|
707
|
+
# Flatten the dict into top-level fields with prefix
|
708
|
+
for subk, subv in v.items():
|
709
|
+
# Compose the flattened key
|
710
|
+
prefix = "starkExCollateral"
|
711
|
+
# Capitalize first letter of subkey
|
712
|
+
if subk and subk[0].islower():
|
713
|
+
flatkey = prefix + subk[0].upper() + subk[1:]
|
714
|
+
else:
|
715
|
+
flatkey = prefix + subk
|
716
|
+
item[flatkey] = subv if subv is None or isinstance(subv, (bool, int, float)) else str(subv)
|
717
|
+
continue
|
718
|
+
# Convert to str except for None; preserve bool/int/float as-is
|
719
|
+
if v is None:
|
720
|
+
item[k] = v
|
721
|
+
elif isinstance(v, (bool, int, float)):
|
722
|
+
item[k] = v
|
723
|
+
else:
|
724
|
+
item[k] = str(v)
|
725
|
+
self._clear()
|
726
|
+
if item:
|
727
|
+
self._insert([item])
|
728
|
+
|
729
|
+
|
730
|
+
class EdgexDataStore(DataStoreCollection):
|
731
|
+
"""Edgex DataStore collection exposing the order book feed."""
|
732
|
+
|
733
|
+
def _init(self) -> None:
|
734
|
+
self._create("book", datastore_class=Book)
|
735
|
+
self._create("ticker", datastore_class=Ticker)
|
736
|
+
self._create("orders", datastore_class=Order)
|
737
|
+
self._create("balance", datastore_class=Balance)
|
738
|
+
# Position store holds per-account open positions in simplified camelCase form
|
739
|
+
self._create("position", datastore_class=Position)
|
740
|
+
self._create("meta_coin", datastore_class=CoinMeta)
|
741
|
+
self._create("detail", datastore_class=ContractMeta)
|
742
|
+
self._create("app", datastore_class=AppMeta)
|
743
|
+
|
744
|
+
@property
|
745
|
+
def book(self) -> Book:
|
746
|
+
"""
|
747
|
+
获取 Edgex 合约订单簿数据流。
|
748
|
+
|
749
|
+
.. code:: json
|
750
|
+
|
751
|
+
[
|
752
|
+
{
|
753
|
+
"c": "10000001", # 合约 ID
|
754
|
+
"s": "BTCUSD",
|
755
|
+
"S": "a", # 方向 a=卖 b=买
|
756
|
+
"p": "117388.2", # 价格
|
757
|
+
"q": "12.230", # 数量
|
758
|
+
}
|
759
|
+
]
|
760
|
+
"""
|
761
|
+
return self._get("book")
|
762
|
+
|
763
|
+
@property
|
764
|
+
def orders(self) -> Order:
|
765
|
+
"""
|
766
|
+
账户订单数据流(REST 快照 + 私有 WS 增量)。
|
767
|
+
|
768
|
+
存储为**精简 schema**,仅保留实操必需字段。终态订单(FILLED / CANCELED / CANCELLED / REJECTED / EXPIRED)
|
769
|
+
会在写入一次后从本地缓存删除,只保留进行中的订单(OPEN / PARTIALLY_FILLED / PENDING / CREATED / ACKNOWLEDGED)。
|
770
|
+
|
771
|
+
|
772
|
+
存储示例(本地条目)
|
773
|
+
-------------------
|
774
|
+
REST 快照:
|
775
|
+
|
776
|
+
.. code:: json
|
777
|
+
|
778
|
+
[
|
779
|
+
{
|
780
|
+
"orderId": "564815695875932430",
|
781
|
+
"id": "564815695875932430",
|
782
|
+
"contractId": "10000001",
|
783
|
+
"contractName": "BTCUSD",
|
784
|
+
"symbol": "BTCUSD",
|
785
|
+
"side": "buy",
|
786
|
+
"status": "OPEN",
|
787
|
+
"type": "LIMIT",
|
788
|
+
"timeInForce": "GOOD_TIL_CANCEL",
|
789
|
+
"reduceOnly": false,
|
790
|
+
"price": "97444.5",
|
791
|
+
"size": "0.010",
|
792
|
+
"cumFillSize": "0.000",
|
793
|
+
"cumFillValue": "0",
|
794
|
+
"clientOrderId": "553364074986685",
|
795
|
+
"createdTime": "1734662555665",
|
796
|
+
"updatedTime": "1734662555665"
|
797
|
+
}
|
798
|
+
]
|
799
|
+
|
800
|
+
|
801
|
+
"""
|
802
|
+
return self._get("orders")
|
803
|
+
|
804
|
+
@property
|
805
|
+
def balance(self) -> Balance:
|
806
|
+
"""
|
807
|
+
获取账户资产余额(REST 快照 + 私有 WS 增量)。
|
808
|
+
|
809
|
+
.. code:: json
|
810
|
+
|
811
|
+
[
|
812
|
+
{
|
813
|
+
userId: "663528067892773124",
|
814
|
+
accountId: "663528067938910372",
|
815
|
+
coinId: "1000",
|
816
|
+
totalEquity: "22.721859",
|
817
|
+
totalPositionValueAbs: "0",
|
818
|
+
initialMarginRequirement: "0",
|
819
|
+
starkExRiskValue: "0",
|
820
|
+
pendingWithdrawAmount: "0",
|
821
|
+
pendingTransferOutAmount: "0",
|
822
|
+
orderFrozenAmount: "3.001126965030794963240623474121093750",
|
823
|
+
availableAmount: "19.720732",
|
824
|
+
},
|
825
|
+
]
|
826
|
+
|
827
|
+
"""
|
828
|
+
return self._get("balance")
|
829
|
+
|
830
|
+
@property
|
831
|
+
def position(self) -> "Position":
|
832
|
+
"""
|
833
|
+
获取账户当前未平仓持仓(open positions,来自 getAccountAsset)。
|
834
|
+
|
835
|
+
本属性提供**当前未平仓持仓**的快照(由 REST ``getAccountAsset`` 提供),每条数据为当前账户的一个持仓(多/空/逐仓/全仓等)。
|
836
|
+
字段为 snake_case,包含持仓数量、均价、强平价、杠杆、保证金率等信息,适合用于持仓管理与风险监控。
|
837
|
+
|
838
|
+
数据示例:
|
839
|
+
|
840
|
+
.. code:: python
|
841
|
+
|
842
|
+
[
|
843
|
+
{
|
844
|
+
orderId: "665307878751470244",
|
845
|
+
id: "665307878751470244",
|
846
|
+
side: "buy",
|
847
|
+
status: "OPEN",
|
848
|
+
userId: "663528067892773124",
|
849
|
+
accountId: "663528067938910372",
|
850
|
+
coinId: "1000",
|
851
|
+
contractId: "10000003",
|
852
|
+
clientOrderId: "32570392453812747",
|
853
|
+
type: "LIMIT",
|
854
|
+
timeInForce: "GOOD_TIL_CANCEL",
|
855
|
+
reduceOnly: False,
|
856
|
+
price: "210.00",
|
857
|
+
size: "0.3",
|
858
|
+
cumFillSize: "0",
|
859
|
+
cumFillValue: "0",
|
860
|
+
cumMatchSize: "0",
|
861
|
+
cumMatchValue: "0",
|
862
|
+
cumMatchFee: "0",
|
863
|
+
triggerPrice: "0",
|
864
|
+
triggerPriceType: "UNKNOWN_PRICE_TYPE",
|
865
|
+
cancelReason: "UNKNOWN_ORDER_CANCEL_REASON",
|
866
|
+
createdTime: "1758621759117",
|
867
|
+
updatedTime: "1758621759122",
|
868
|
+
matchSequenceId: "784278904",
|
869
|
+
},
|
870
|
+
];
|
871
|
+
|
872
|
+
|
873
|
+
本属性仅包含**当前持有的未平仓持仓**(由 REST ``getAccountAsset`` 提供)。
|
874
|
+
若需获取**历史已平仓持仓周期**,请调用 ``getPositionTermPage``。
|
875
|
+
"""
|
876
|
+
return self._get("position")
|
877
|
+
|
878
|
+
@property
|
879
|
+
def coins(self) -> CoinMeta:
|
880
|
+
"""
|
881
|
+
获取币种精度及 StarkEx 资产信息列表。
|
882
|
+
|
883
|
+
.. code:: json
|
884
|
+
|
885
|
+
[
|
886
|
+
{
|
887
|
+
"coinId": "1000",
|
888
|
+
"coinName": "USDT",
|
889
|
+
"stepSize": "0.000001",
|
890
|
+
"showStepSize": "0.0001",
|
891
|
+
"starkExAssetId": "0x33bda5c9..."
|
892
|
+
}
|
893
|
+
]
|
894
|
+
"""
|
895
|
+
return self._get("meta_coin")
|
896
|
+
|
897
|
+
@property
|
898
|
+
def detail(self) -> ContractMeta:
|
899
|
+
"""
|
900
|
+
获取合约级别的交易参数。
|
901
|
+
|
902
|
+
.. code:: json
|
903
|
+
|
904
|
+
[
|
905
|
+
{
|
906
|
+
"contractId": "10000001",
|
907
|
+
"contractName": "BTCUSD",
|
908
|
+
"baseCoinId": "1001",
|
909
|
+
"quoteCoinId": "1000",
|
910
|
+
"tickSize": "0.1",
|
911
|
+
"stepSize": "0.001",
|
912
|
+
"minOrderSize": "0.001",
|
913
|
+
"maxOrderSize": "50.000",
|
914
|
+
"defaultMakerFeeRate": "0.0002",
|
915
|
+
"defaultTakerFeeRate": "0.00055",
|
916
|
+
"enableTrade": true,
|
917
|
+
"fundingInterestRate": "0.0003",
|
918
|
+
"fundingImpactMarginNotional": "10",
|
919
|
+
"fundingRateIntervalMin": "240",
|
920
|
+
"starkExSyntheticAssetId": "0x42544332...",
|
921
|
+
"starkExResolution": "0x2540be400",
|
922
|
+
"riskTierList": [
|
923
|
+
{
|
924
|
+
"tier": 1,
|
925
|
+
"positionValueUpperBound": "50000",
|
926
|
+
"maxLeverage": "100",
|
927
|
+
"maintenanceMarginRate": "0.005",
|
928
|
+
"starkExRisk": "21474837",
|
929
|
+
"starkExUpperBound": "214748364800000000000"
|
930
|
+
}
|
931
|
+
]
|
932
|
+
}
|
933
|
+
]
|
934
|
+
"""
|
935
|
+
return self._get("detail")
|
936
|
+
|
937
|
+
@property
|
938
|
+
def ticker(self) -> Ticker:
|
939
|
+
"""
|
940
|
+
获取 24 小时行情推送。
|
941
|
+
|
942
|
+
.. code:: json
|
943
|
+
|
944
|
+
[
|
945
|
+
{
|
946
|
+
"c": "10000001", # 合约 ID
|
947
|
+
"s": "BTCUSD", # 合约名称
|
948
|
+
"lastPrice": "117400", # 最新价
|
949
|
+
"priceChange": "200", # 涨跌额
|
950
|
+
"priceChangePercent": "0.0172", # 涨跌幅
|
951
|
+
"size": "1250", # 24h 成交量
|
952
|
+
"value": "147000000", # 24h 成交额
|
953
|
+
"high": "118000", # 24h 最高价
|
954
|
+
"low": "116500", # 低价
|
955
|
+
"open": "116800", # 开盘价
|
956
|
+
"close": "117400", # 收盘价
|
957
|
+
"indexPrice": "117350", # 指数价
|
958
|
+
"oraclePrice": "117360.12", # 预言机价
|
959
|
+
"openInterest": "50000", # 持仓量
|
960
|
+
"fundingRate": "0.000234", # 当前资金费率
|
961
|
+
"fundingTime": "1758240000000", # 上一次结算时间
|
962
|
+
"nextFundingTime": "1758254400000", # 下一次结算时间
|
963
|
+
"bestAskPrice": "117410", # 卖一价
|
964
|
+
"bestBidPrice": "117400" # 买一价
|
965
|
+
}
|
966
|
+
]
|
967
|
+
"""
|
968
|
+
return self._get("ticker")
|
969
|
+
|
970
|
+
async def initialize(self, *aws: Awaitable["ClientResponse"]) -> None:
|
971
|
+
"""Populate metadata stores from awaited HTTP responses."""
|
972
|
+
|
973
|
+
for fut in asyncio.as_completed(aws):
|
974
|
+
res = await fut
|
975
|
+
data = await res.json()
|
976
|
+
if data['code'] != 'SUCCESS':
|
977
|
+
raise ValueError(f"Unexpected response code: {data}")
|
978
|
+
if res.url.path == "/api/v1/public/meta/getMetaData":
|
979
|
+
self._apply_metadata(data)
|
980
|
+
elif res.url.path == "/api/v1/private/account/getAccountAsset":
|
981
|
+
self.balance._onresponse(data)
|
982
|
+
self.position._onresponse(data)
|
983
|
+
elif res.url.path == "/api/v1/private/order/getActiveOrderPage":
|
984
|
+
self.orders._onresponse(data)
|
985
|
+
elif res.url.path == "/api/v1/public/quote/getTicker":
|
986
|
+
self.ticker._onresponse(data)
|
987
|
+
|
988
|
+
def onmessage(self, msg: Item, ws: ClientWebSocketResponse | None = None) -> None:
|
989
|
+
# print(msg)
|
990
|
+
channel = (msg.get("channel") or "").lower()
|
991
|
+
msg_type = (msg.get("type") or "").lower()
|
992
|
+
|
993
|
+
if msg_type == "ping" and ws is not None:
|
994
|
+
payload = {"type": "pong", "time": msg.get("time")}
|
995
|
+
asyncio.create_task(ws.send_json(payload))
|
996
|
+
return
|
997
|
+
|
998
|
+
if msg_type in {"trade-event", "trade_event", "order-event", "order_event"}:
|
999
|
+
self.orders._on_message(msg)
|
1000
|
+
self.position._on_message(msg)
|
1001
|
+
|
1002
|
+
if "depth" in channel and msg_type in {"quote-event", "payload"}:
|
1003
|
+
self.book._on_message(msg)
|
1004
|
+
|
1005
|
+
if channel.startswith("ticker") and msg_type in {"payload", "quote-event"}:
|
1006
|
+
self.ticker._on_message(msg)
|
1007
|
+
|
1008
|
+
def _apply_metadata(self, data: dict[str, Any]) -> None:
|
1009
|
+
self.app._onresponse(data)
|
1010
|
+
self.coins._onresponse(data)
|
1011
|
+
self.detail._onresponse(data)
|
1012
|
+
|
1013
|
+
|
1014
|
+
@property
|
1015
|
+
def app(self) -> AppMeta:
|
1016
|
+
"""
|
1017
|
+
获取全局元数据,如 appName、环境、fee 账户等。
|
1018
|
+
|
1019
|
+
.. code:: python
|
1020
|
+
|
1021
|
+
|
1022
|
+
[
|
1023
|
+
{
|
1024
|
+
"appName": "edgeX",
|
1025
|
+
"appEnv": "mainnet",
|
1026
|
+
"appOnlySignOn": "https://pro.edgex.exchange",
|
1027
|
+
"feeAccountId": "256105",
|
1028
|
+
"feeAccountL2Key": "0x70092acf49d535fbb64d99883abda95dcf9a4fc60f494437a3d76f27db0a0f5",
|
1029
|
+
"poolAccountId": "508126509156794507",
|
1030
|
+
"poolAccountL2Key": "0x7f2e1e8a572c847086ee93c9b5bbce8b96320aaa69147df1cfca91d5e90bc60",
|
1031
|
+
"fastWithdrawAccountId": "508126509156794507",
|
1032
|
+
"fastWithdrawAccountL2Key": "0x7f2e1e8a572c847086ee93c9b5bbce8b96320aaa69147df1cfca91d5e90bc60",
|
1033
|
+
"fastWithdrawMaxAmount": "100000",
|
1034
|
+
"fastWithdrawRegistryAddress": "0xBE9a129909EbCb954bC065536D2bfAfBd170d27A",
|
1035
|
+
"starkExChainId": "0x1",
|
1036
|
+
"starkExContractAddress": "0xfAaE2946e846133af314d1Df13684c89fA7d83DD",
|
1037
|
+
"starkExCollateralCoinId": "1000",
|
1038
|
+
"starkExCollateralCoinName": "USD",
|
1039
|
+
"starkExCollateralStepSize": "0.000001",
|
1040
|
+
"starkExCollateralShowStepSize": "0.0001",
|
1041
|
+
"starkExCollateralIconUrl": "https://static.edgex.exchange/icons/coin/USDT.svg",
|
1042
|
+
"starkExCollateralStarkExAssetId": "0x2ce625e94458d39dd0bf3b45a843544dd4a14b8169045a3a3d15aa564b936c5",
|
1043
|
+
"starkExCollateralStarkExResolution": "0xf4240",
|
1044
|
+
"starkExMaxFundingRate": 12000,
|
1045
|
+
"starkExOrdersTreeHeight": 64,
|
1046
|
+
"starkExPositionsTreeHeight": 64,
|
1047
|
+
"starkExFundingValidityPeriod": 86400,
|
1048
|
+
"starkExPriceValidityPeriod": 86400,
|
1049
|
+
"maintenanceReason": "",
|
1050
|
+
}
|
1051
|
+
]
|
1052
|
+
"""
|
1053
|
+
return self._get("app")
|