hyperquant 0.2__py3-none-any.whl → 0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1054 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from typing import TYPE_CHECKING, Any, Awaitable
7
+
8
+ import aiohttp
9
+ from pybotters.store import DataStore, DataStoreCollection
10
+
11
+ if TYPE_CHECKING:
12
+ from pybotters.typedefs import Item
13
+ from pybotters.ws import ClientWebSocketResponse
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class Book(DataStore):
20
+ """深度数据存储类,用于处理订单簿深度信息
21
+
22
+ Channel: push.depth.step
23
+
24
+ 用于存储和管理订单簿深度数据,包含买卖盘的价格和数量信息
25
+ Keys: ["symbol", "side", "px"]
26
+ - symbol: 交易对符号
27
+ - side: 买卖方向 (A: ask卖出, B: bid买入)
28
+ - px: 价格
29
+
30
+
31
+ """
32
+
33
+ _KEYS = ["symbol", "side", "i"]
34
+
35
+ def _init(self) -> None:
36
+ # super().__init__()
37
+ self._time: int | None = None
38
+
39
+ def _on_message(self, msg: dict[str, Any]) -> None:
40
+
41
+ symbol = msg.get("symbol")
42
+ data = msg.get("data", {})
43
+ asks = data.get("asks", [])
44
+ bids = data.get("bids", [])
45
+ # 提速 默认 5当前
46
+ asks = asks[:5]
47
+ bids = bids[:5]
48
+
49
+ timestamp = data.get("ct") # 使用服务器时间
50
+
51
+ data_to_insert: list[Item] = []
52
+
53
+ # 先删除旧的订单簿数据
54
+ self._find_and_delete({"symbol": symbol})
55
+
56
+ # 处理买卖盘数据
57
+ for side_id, levels in (("B", bids), ("A", asks)):
58
+ for i, level in enumerate(levels):
59
+ # level格式: [price, size, count]
60
+ if len(level) >= 3:
61
+ price, size, count = level[0:3]
62
+ data_to_insert.append(
63
+ {
64
+ "symbol": symbol,
65
+ "side": side_id,
66
+ "px": str(price),
67
+ "sz": str(size),
68
+ "count": count,
69
+ "i": i
70
+ }
71
+ )
72
+
73
+ # 插入新的订单簿数据
74
+ self._insert(data_to_insert)
75
+ self._time = timestamp
76
+
77
+ @property
78
+ def time(self) -> int | None:
79
+ """返回最后更新时间"""
80
+ return self._time
81
+
82
+ @property
83
+ def sorted(self) -> dict[str, list[Item]]:
84
+ """获取排序后的订单簿数据
85
+
86
+ Returns:
87
+ 返回按价格排序的买卖盘数据,卖盘升序,买盘降序
88
+
89
+ .. code-block:: python
90
+
91
+ {
92
+ "asks": [
93
+ {"symbol": "BTC_USDT", "side": "A", "px": "110152.5", "sz": "53539", "count": 1},
94
+ {"symbol": "BTC_USDT", "side": "A", "px": "110152.6", "sz": "95513", "count": 2}
95
+ ],
96
+ "bids": [
97
+ {"symbol": "BTC_USDT", "side": "B", "px": "110152.4", "sz": "76311", "count": 1},
98
+ {"symbol": "BTC_USDT", "side": "B", "px": "110152.3", "sz": "104688", "count": 2}
99
+ ]
100
+ }
101
+ """
102
+ return self._sorted(
103
+ item_key="side",
104
+ item_asc_key="A", # asks 升序
105
+ item_desc_key="B", # bids 降序
106
+ sort_key="px",
107
+ )
108
+
109
+
110
+ class Ticker(DataStore):
111
+ _KEYS = ["symbol"]
112
+
113
+ def _on_message(self, data: dict[str, Any]):
114
+ self._onresponse(data)
115
+
116
+ def _onresponse(self, data: dict[str, Any]):
117
+ tickers = data.get("data", [])
118
+ if tickers:
119
+ data_to_insert: list[Item] = []
120
+ for ticker in tickers:
121
+ ticker: dict[str, Any] = ticker
122
+ for ticker in tickers:
123
+ data_to_insert.append(
124
+ {
125
+ "amount24": ticker.get("amount24"),
126
+ "fair_price": ticker.get("fairPrice"),
127
+ "high24_price": ticker.get("high24Price"),
128
+ "index_price": ticker.get("indexPrice"),
129
+ "last_price": ticker.get("lastPrice"),
130
+ "lower24_price": ticker.get("lower24Price"),
131
+ "max_bid_price": ticker.get("maxBidPrice"),
132
+ "min_ask_price": ticker.get("minAskPrice"),
133
+ "rise_fall_rate": ticker.get("riseFallRate"),
134
+ "symbol": ticker.get("symbol"),
135
+ "timestamp": ticker.get("timestamp"),
136
+ "volume24": ticker.get("volume24"),
137
+ }
138
+ )
139
+ # self._clear()
140
+ self._insert(data_to_insert)
141
+
142
+
143
+ class Orders(DataStore):
144
+ _KEYS = ["order_id"]
145
+
146
+ def _fmt(self, order:dict):
147
+ return {
148
+ "order_id": order.get("orderId"),
149
+ "symbol": order.get("symbol"),
150
+ "px": order.get("price"),
151
+ "vol": order.get("vol"),
152
+ "lev": order.get("leverage"),
153
+ "side": "buy" if order.get("side") == 1 else "sell",
154
+ "deal_vol": order.get("dealVol"),
155
+ "deal_avg_px": order.get("dealAvgPrice"),
156
+ "create_ts": order.get("createTime"),
157
+ "update_ts": order.get("updateTime"),
158
+ "state": "open"
159
+ }
160
+
161
+ # {'success': True, 'code': 0, 'data': [{'orderId': '219108574599630976', 'symbol': 'SOL_USDT', 'positionId': 0, 'price': 190, 'priceStr': '190', 'vol': 1, 'leverage': 20, 'side': 1, 'category': 1, 'orderType': 1, 'dealAvgPrice': 0, 'dealAvgPriceStr': '0', 'dealVol': 0, 'orderMargin': 0.09652, 'takerFee': 0, 'makerFee': 0, 'profit': 0, 'feeCurrency': 'USDT', 'openType': 1, 'state': 2, 'externalOid': '_m_2228b23a75204e1982b301e44d439cbb', 'errorCode': 0, 'usedMargin': 0, 'createTime': 1756277955008, 'updateTime': 1756277955037, 'positionMode': 1, 'version': 1, 'showCancelReason': 0, 'showProfitRateShare': 0, 'voucher': False}]}
162
+ def _onresponse(self, data: dict[str, Any]):
163
+ orders = data.get("data", [])
164
+ if orders:
165
+ data_to_insert: list[Item] = []
166
+ for order in orders:
167
+ order: dict[str, Any] = order
168
+ data_to_insert.append(self._fmt(order))
169
+
170
+ self._clear()
171
+ self._update(data_to_insert)
172
+
173
+ def _on_message(self, msg: dict[str, Any]) -> None:
174
+ data:dict = msg.get("data", {})
175
+ if msg.get('channel') == 'push.personal.order':
176
+ state = data.get("state")
177
+ if state == 2:
178
+ order = self._fmt(data)
179
+ order["state"] = "open"
180
+ self._insert([order])
181
+ elif state == 3:
182
+ order = self._fmt(data)
183
+ order["state"] = "filled"
184
+ self._update([order])
185
+ self._find_and_delete({
186
+ "order_id": order.get("order_id")
187
+ })
188
+ elif state == 4:
189
+ order = self._fmt(data)
190
+ order["state"] = "canceled"
191
+ self._update([order])
192
+ self._find_and_delete({
193
+ "order_id": order.get("order_id")
194
+ })
195
+
196
+ class Detail(DataStore):
197
+ _KEYS = ["symbol"]
198
+
199
+ def _on_message(self, data: dict[str, Any]):
200
+ self._onresponse(data)
201
+
202
+ def _onresponse(self, data: dict[str, Any]):
203
+ details: dict = data.get("data", {})
204
+ data_to_insert: list[Item] = []
205
+ if details:
206
+ for detail in details:
207
+ data_to_insert.append(
208
+ {
209
+ "symbol": detail.get("symbol"),
210
+ "ft": detail.get("ft"),
211
+ "max_lev": detail.get("maxL"),
212
+ "tick_size": detail.get("pu"),
213
+ "vol_unit": detail.get("vu"),
214
+ "io": detail.get("io"),
215
+ "contract_sz": detail.get("cs"),
216
+ "minv": detail.get("minV"),
217
+ "maxv": detail.get("maxV")
218
+ }
219
+ )
220
+ self._update(data_to_insert)
221
+
222
+ class Position(DataStore):
223
+ _KEYS = ["position_id"]
224
+ # {"success":true,"code":0,"data":[{"positionId":5355366,"symbol":"SOL_USDT","positionType":1,"openType":1,"state":1,"holdVol":1,"frozenVol":0,"closeVol":0,"holdAvgPrice":203.44,"holdAvgPriceFullyScale":"203.44","openAvgPrice":203.44,"openAvgPriceFullyScale":"203.44","closeAvgPrice":0,"liquidatePrice":194.07,"oim":0.10253376,"im":0.10253376,"holdFee":0,"realised":-0.0008,"leverage":20,"marginRatio":0.0998,"createTime":1756275984696,"updateTime":1756275984696,"autoAddIm":false,"version":1,"profitRatio":0,"newOpenAvgPrice":203.44,"newCloseAvgPrice":0,"closeProfitLoss":0,"fee":0.00081376}]}
225
+
226
+ def _fmt(self, position:dict):
227
+ return {
228
+ "position_id": position.get("positionId"),
229
+ "symbol": position.get("symbol"),
230
+ "side": "short" if position.get("positionType") == 2 else "long",
231
+ "open_type": position.get("openType"),
232
+ "state": position.get("state"),
233
+ "hold_vol": position.get("holdVol"),
234
+ "frozen_vol": position.get("frozenVol"),
235
+ "close_vol": position.get("closeVol"),
236
+ "hold_avg_price": position.get("holdAvgPriceFullyScale"),
237
+ "open_avg_price": position.get("openAvgPriceFullyScale"),
238
+ "close_avg_price": str(position.get("closeAvgPrice")),
239
+ "liquidate_price": str(position.get("liquidatePrice")),
240
+ "oim": position.get("oim"),
241
+ "im": position.get("im"),
242
+ "hold_fee": position.get("holdFee"),
243
+ "realised": position.get("realised"),
244
+ "leverage": position.get("leverage"),
245
+ "margin_ratio": position.get("marginRatio"),
246
+ "create_ts": position.get("createTime"),
247
+ "update_ts": position.get("updateTime"),
248
+ }
249
+
250
+ def _onresponse(self, data: dict[str, Any]):
251
+ positions = data.get("data", [])
252
+ if positions:
253
+ data_to_insert: list[Item] = []
254
+ for position in positions:
255
+ position: dict[str, Any] = position
256
+
257
+ data_to_insert.append(
258
+ self._fmt(position)
259
+ )
260
+
261
+ self._clear()
262
+ self._insert(data_to_insert)
263
+ else:
264
+ self._clear()
265
+
266
+ def _on_message(self, msg: dict[str, Any]) -> None:
267
+ data:dict = msg.get("data", {})
268
+ state = data.get("state")
269
+ position_id = data.get("positionId")
270
+ if state == 3:
271
+ self._find_and_delete({"position_id": position_id})
272
+ return
273
+
274
+ self._update([self._fmt(data)])
275
+
276
+ class Balance(DataStore):
277
+ _KEYS = ["currency"]
278
+
279
+ def _fmt(self, balance: dict) -> dict:
280
+ return {
281
+ "available_balance": balance.get("availableBalance"),
282
+ "bonus": balance.get("bonus"),
283
+ "currency": balance.get("currency"),
284
+ "frozen_balance": balance.get("frozenBalance"),
285
+ "last_bonus": balance.get("lastBonus"),
286
+ "position_margin": balance.get("positionMargin"),
287
+ "wallet_balance": balance.get("walletBalance"),
288
+ }
289
+
290
+ def _onresponse(self, data: dict[str, Any]):
291
+ balances = data.get("data", [])
292
+ if balances:
293
+ data_to_insert: list[Item] = []
294
+ for balance in balances:
295
+ balance: dict[str, Any] = balance
296
+ data_to_insert.append(self._fmt(balance))
297
+ self._clear()
298
+ self._insert(data_to_insert)
299
+
300
+ def _on_message(self, msg: dict[str, Any]) -> None:
301
+ data: dict = msg.get("data", {})
302
+ self._update([self._fmt(data)])
303
+
304
+
305
+
306
+ class OurbitSwapDataStore(DataStoreCollection):
307
+ """
308
+ Ourbit DataStoreCollection
309
+
310
+ REST API:
311
+ - 地址: https://futures.ourbit.com
312
+ - 合约详情
313
+ GET /api/v1/contract/detailV2?client=web
314
+ - ticker
315
+ GET /api/v1/contract/ticker
316
+ - open_orders
317
+ GET /api/v1/private/order/list/open_orders?page_size=200
318
+ - open_positions
319
+ GET /api/v1/private/position/open_positions
320
+
321
+ WebSocket API:
322
+ - 地址: wss://futures.ourbit.com/edge or /ws
323
+ - 支持频道:
324
+ * 深度数据(Book): push.depth.step
325
+ * 行情数据(Ticker): push.tickers
326
+
327
+ 示例订阅 JSON:
328
+
329
+ .. code:: json
330
+
331
+ {
332
+ "method": "sub.depth.step",
333
+ "param": {
334
+ "symbol": "BTC_USDT",
335
+ "step": "0.1"
336
+ }
337
+ }
338
+
339
+ .. code:: json
340
+
341
+ {
342
+ "method": "sub.tickers",
343
+ "param": {
344
+ "timezone": "UTC+8"
345
+ }
346
+ }
347
+
348
+ TODO:
349
+ - 添加 trades、ticker、candle 等其他数据流
350
+ """
351
+
352
+ def _init(self) -> None:
353
+ self._create("book", datastore_class=Book)
354
+ self._create("detail", datastore_class=Detail)
355
+ self._create("ticker", datastore_class=Ticker)
356
+ self._create("orders", datastore_class=Orders)
357
+ self._create("position", datastore_class=Position)
358
+ self._create("balance", datastore_class=Balance)
359
+ # TODO: 添加其他数据流,如 trades, ticker, candle 等
360
+
361
+ def onmessage(self, msg: Item, ws: ClientWebSocketResponse | None = None) -> None:
362
+ channel = msg.get("channel")
363
+
364
+ if channel == "push.depth.step":
365
+ self.book._on_message(msg)
366
+ if channel == "push.tickers":
367
+ self.ticker._on_message(msg)
368
+ if channel == "push.personal.position":
369
+ self.position._on_message(msg)
370
+ if channel == "push.personal.order":
371
+ self.orders._on_message(msg)
372
+ if channel == "push.personal.asset":
373
+ self.balance._on_message(msg)
374
+ else:
375
+ logger.debug(f"未知的channel: {channel}")
376
+
377
+ async def initialize(self, *aws: Awaitable[aiohttp.ClientResponse]) -> None:
378
+ """Initialize DataStore from HTTP response data."""
379
+ for f in asyncio.as_completed(aws):
380
+ res = await f
381
+ data = await res.json()
382
+ if res.url.path == "/api/v1/contract/detailV2":
383
+ self.detail._onresponse(data)
384
+ if res.url.path == "/api/v1/contract/ticker":
385
+ self.ticker._onresponse(data)
386
+ if res.url.path == "/api/v1/private/order/list/open_orders":
387
+ self.orders._onresponse(data)
388
+ if res.url.path == "/api/v1/private/position/open_positions":
389
+ self.position._onresponse(data)
390
+ if res.url.path == "/api/v1/private/account/assets":
391
+ self.balance._onresponse(data)
392
+
393
+ @property
394
+ def detail(self) -> Detail:
395
+ """合约详情
396
+ Data structure:
397
+ .. code:: python
398
+ [
399
+ {
400
+ "symbol": "BTC_USDT", # 交易对
401
+ "ft": 100, # 合约面值
402
+ "max_lev": 100, # 最大杠杆
403
+ "tick_size": 0.1, # 最小变动价位
404
+ "vol_unit": 1, # 合约单位
405
+ "io": ["binance", "mexc"], # 交易所列表
406
+ "contract_sz": 1,
407
+ "minv": 1,
408
+ "maxv": 10000
409
+
410
+ }
411
+ ]
412
+ """
413
+ return self._get("detail", Detail)
414
+
415
+ @property
416
+ def book(self) -> Book:
417
+ """订单簿深度数据流
418
+
419
+ Data type: Mutable
420
+
421
+ Keys: ("symbol", "side", "px")
422
+
423
+ Data structure:
424
+
425
+ .. code:: python
426
+
427
+ [
428
+ {
429
+ "symbol": "BTC_USDT", # 交易对
430
+ "side": "A", # 卖出方向
431
+ "px": "110152.5", # 价格
432
+ "sz": "53539", # 数量
433
+ "count": 1 # 订单数量
434
+ "i" 0 # 价格档位索引
435
+ },
436
+ {
437
+ "symbol": "BTC_USDT", # 交易对
438
+ "side": "B", # 买入方向
439
+ "px": "110152.4", # 价格
440
+ "sz": "76311", # 数量
441
+ "count": 1 # 订单数量
442
+ "i" 0 # 价格档位索引
443
+ }
444
+ ]
445
+ """
446
+ return self._get("book", Book)
447
+
448
+ @property
449
+ def ticker(self) -> Ticker:
450
+ """市场行情数据流
451
+
452
+ Data type: Mutable
453
+
454
+ Keys: ("symbol",)
455
+
456
+ Data structure:
457
+
458
+ .. code:: python
459
+
460
+ [
461
+ {
462
+ "symbol": "BTC_USDT", # 交易对
463
+ "last_price": "110152.5", # 最新价格
464
+ "index_price": "110000.0", # 指数价格
465
+ "fair_price": "110100.0", # 公允价格
466
+ "high24_price": "115000.0", # 24小时最高价
467
+ "lower24_price": "105000.0", # 24小时最低价
468
+ "volume24": "1500", # 24小时交易量
469
+ "amount24": "165000000", # 24小时交易额
470
+ "rise_fall_rate": "0.05", # 涨跌幅
471
+ "max_bid_price": "110150.0", # 买一价
472
+ "min_ask_price": "110155.0", # 卖一价
473
+ "timestamp": 1625247600000 # 时间戳
474
+ }
475
+ ]
476
+ """
477
+ return self._get("ticker", Ticker)
478
+
479
+ @property
480
+ def orders(self) -> Orders:
481
+ """
482
+ 订单数据
483
+ Data structure:
484
+
485
+ .. code:: json
486
+
487
+ [
488
+ {
489
+ "id": "123456",
490
+ "symbol": "BTC_USDT",
491
+ "side": "buy",
492
+ "price": "110152.5",
493
+ "size": "0.1",
494
+ "state": "open", // ("open", "closed", "canceled")
495
+ "create_ts": 1625247600000,
496
+ "update_ts": 1625247600000
497
+ }
498
+ ]
499
+ """
500
+ return self._get("orders", Orders)
501
+
502
+ @property
503
+ def position(self) -> Position:
504
+ """
505
+ 持仓数据
506
+ Data structure:
507
+
508
+ .. code:: json
509
+
510
+ [
511
+ {
512
+ "position_id": "123456",
513
+ "symbol": "BTC_USDT",
514
+ "side": "long",
515
+ "open_type": "limit",
516
+ "state": "open",
517
+ "hold_vol": "0.1",
518
+ "frozen_vol": "0.0",
519
+ "close_vol": "0.0",
520
+ "hold_avg_price": "110152.5",
521
+ "open_avg_price": "110152.5",
522
+ "close_avg_price": "0.0",
523
+ "liquidate_price": "100000.0",
524
+ "oim": "0.0",
525
+ "im": "0.0",
526
+ "hold_fee": "0.0",
527
+ "realised": "0.0",
528
+ "leverage": "10",
529
+ "margin_ratio": "0.1",
530
+ "create_ts": 1625247600000,
531
+ "update_ts": 1625247600000
532
+ }
533
+ ]
534
+ """
535
+ return self._get("position", Position)
536
+
537
+ @property
538
+ def balance(self) -> Balance:
539
+ @property
540
+ def balance(self) -> Balance:
541
+ """账户余额数据
542
+
543
+ Data structure:
544
+
545
+ .. code:: python
546
+
547
+ [
548
+ {
549
+ "currency": "USDT", # 币种
550
+ "position_margin": 0.3052, # 持仓保证金
551
+ "available_balance": 19.7284, # 可用余额
552
+ "frozen_balance": 0, # 冻结余额
553
+ "bonus": 0, # 奖励
554
+ "last_bonus": 0, # 最后奖励
555
+ "wallet_balance": 20.0337 # 钱包余额
556
+ }
557
+ ]
558
+ """
559
+ return self._get("balance", Balance)
560
+ return self._get("balance", Balance)
561
+
562
+ # SpotBalance: 现货账户余额数据存储
563
+
564
+ class SpotBalance(DataStore):
565
+ _KEYS = ["currency"]
566
+
567
+ def _fmt(self, balance: dict) -> dict:
568
+ return {
569
+ "currency": balance.get("currency"),
570
+ "available": balance.get("available"),
571
+ "frozen": balance.get("frozen"),
572
+ "amount": balance.get("amount"),
573
+ "avg_price": balance.get("avgPrice"),
574
+ }
575
+
576
+ def _fmt_ws(self, balance: dict) -> dict:
577
+ return {
578
+ "currency": balance.get("s"),
579
+ "available": balance.get("av"),
580
+ "frozen": balance.get("fr"),
581
+ "amount": balance.get("to"),
582
+ "avg_price": balance.get("ap"),
583
+ }
584
+
585
+ def _onresponse(self, data: dict[str, Any]):
586
+ balances = data.get("data", [])
587
+ items = [self._fmt(b) for b in balances]
588
+ if items:
589
+ self._clear()
590
+ self._insert(items)
591
+
592
+ def _on_message(self, msg: dict[str, Any]) -> None:
593
+ data = msg.get("d", {})
594
+ item = self._fmt_ws(data)
595
+ self._update([item])
596
+
597
+
598
+ # SpotOrders: 现货订单数据存储
599
+ class SpotOrders(DataStore):
600
+ _KEYS = ["order_id"]
601
+
602
+
603
+ def _fmt(self, order: dict) -> dict:
604
+ state = order.get("state")
605
+ if state == 1 or state == 2:
606
+ state = "open"
607
+ elif state == 3:
608
+ state = "filled"
609
+ elif state == 4:
610
+ state = "canceled"
611
+
612
+ return {
613
+ "order_id": order.get("id"),
614
+ "symbol": order.get("symbol"),
615
+ "currency": order.get("currency"),
616
+ "market": order.get("market"),
617
+ "trade_type": order.get("tradeType"),
618
+ "order_type": order.get("orderType"),
619
+ "price": order.get("price"),
620
+ "quantity": order.get("quantity"),
621
+ "amount": order.get("amount"),
622
+ "deal_quantity": order.get("dealQuantity"),
623
+ "deal_amount": order.get("dealAmount"),
624
+ "avg_price": order.get("avgPrice"),
625
+ "state": order.get("state"),
626
+ "source": order.get("source"),
627
+ "fee": order.get("fee"),
628
+ "create_ts": order.get("createTime"),
629
+ "unique_id": order.get("uniqueId"),
630
+ }
631
+
632
+
633
+
634
+ def _onresponse(self, data: dict[str, Any]):
635
+ orders = (data.get("data") or {}).get("resultList", [])
636
+ items = [self._fmt(order) for order in orders]
637
+ self._clear()
638
+ if items:
639
+ self._insert(items)
640
+
641
+ def _on_message(self, msg: dict[str, Any]) -> None:
642
+ d:dict = msg.get("d", {})
643
+
644
+
645
+ item = {
646
+ "order_id": d.get("id"),
647
+ "symbol": msg.get("s") or d.get("symbol"),
648
+ "trade_type": d.get("tradeType"),
649
+ "order_type": d.get("orderType"),
650
+ "price": d.get("price"),
651
+ "quantity": d.get("quantity"),
652
+ "amount": d.get("amount"),
653
+ "remain_quantity": d.get("remainQ"),
654
+ "remain_amount": d.get("remainA"),
655
+ "state": d.get("status"),
656
+ "client_order_id": d.get("clientOrderId"),
657
+ "is_taker": d.get("isTaker"),
658
+ "create_ts": d.get("createTime"),
659
+ "source": d.get("internal"),
660
+ }
661
+
662
+ state = d.get("status")
663
+
664
+ if state == 2 or state == 1:
665
+ item["state"] = "open"
666
+ self._insert([item])
667
+
668
+ elif state == 3:
669
+ item["state"] = "filled"
670
+
671
+ # 如果这三个字段存在追加
672
+ if d.get("singleDealId") and d.get("singleDealPrice") and d.get("singleDealQuantity"):
673
+ item.update({
674
+ "unique_id": d.get("singleDealId"),
675
+ "avg_price": d.get("singleDealPrice"),
676
+ "deal_quantity": d.get("singleDealQuantity"),
677
+ })
678
+
679
+ self._update([item])
680
+ self._find_and_delete({ "order_id": d.get("id") })
681
+
682
+ elif state == 4:
683
+ item["state"] = "canceled"
684
+ self._update([item])
685
+ self._find_and_delete({ "order_id": d.get("id") })
686
+
687
+
688
+
689
+ class SpotBook(DataStore):
690
+ _KEYS = ["s", "S", 'p']
691
+
692
+ def _init(self) -> None:
693
+ # super().__init__()
694
+ self._time: int | None = None
695
+ self.limit = 1
696
+
697
+ def _onresponse(self, data: dict[str, Any]):
698
+
699
+ top = data.get("data") or data.get("d") or data
700
+ symbol = (
701
+ top.get("s")
702
+ or top.get("symbol")
703
+ or (top.get("data") or {}).get("symbol")
704
+ )
705
+
706
+ inner = top.get("data") or top
707
+ asks = inner.get("asks") or []
708
+ bids = inner.get("bids") or []
709
+
710
+ items: list[Item] = []
711
+ if symbol:
712
+ # Snapshot semantics: rebuild entries for this symbol
713
+ self._find_and_delete({"s": symbol})
714
+
715
+ def extract_pq(level: Any) -> tuple[Any, Any] | None:
716
+ # Accept dict {"p": x, "q": y} or list/tuple [p, q, ...]
717
+ if isinstance(level, dict):
718
+ p = level.get("p")
719
+ q = level.get("q")
720
+ return (p, q)
721
+ if isinstance(level, (list, tuple)) and len(level) >= 2:
722
+ return (level[0], level[1])
723
+ return None
724
+
725
+ for side, S in ((asks, "a"), (bids, "b")):
726
+ for level in side:
727
+ pq = extract_pq(level)
728
+ if not pq:
729
+ continue
730
+ p, q = pq
731
+ if p is None or q is None:
732
+ continue
733
+ try:
734
+ if float(q) == 0.0:
735
+ continue
736
+ except (TypeError, ValueError):
737
+ continue
738
+ items.append({"s": symbol, "S": S, "p": p, "q": q})
739
+
740
+ if items:
741
+ self._insert(items)
742
+
743
+ sort_data = self.sorted({'s': symbol}, self.limit)
744
+ asks = sort_data.get('a', [])
745
+ bids = sort_data.get('b', [])
746
+ self._find_and_delete({'s': symbol})
747
+ self._update(asks + bids)
748
+
749
+
750
+
751
+ def _on_message(self, msg: dict[str, Any]) -> None:
752
+ ts = time.time() * 1000 # 预留时间戳(如需记录可用)
753
+ data = msg.get("d", {}) or {}
754
+ symbol = msg.get("s")
755
+
756
+ asks: list = data.get("asks", []) or []
757
+ bids: list = data.get("bids", []) or []
758
+
759
+ to_delete, to_update = [], []
760
+ for side, S in ((asks, "a"), (bids, "b")):
761
+ for item in side:
762
+ if float(item["q"]) == 0.0:
763
+ to_delete.append({"s": symbol, "S": S, "p": item["p"]})
764
+ else:
765
+ to_update.append({"s": symbol, "S": S, "p": item["p"], "q": item["q"]})
766
+
767
+ self._delete(to_delete)
768
+ self._insert(to_update)
769
+
770
+ sort_data = self.sorted({'s': symbol}, self.limit)
771
+ asks = sort_data.get('a', [])
772
+ bids = sort_data.get('b', [])
773
+ self._find_and_delete({'s': symbol})
774
+ self._update(asks + bids)
775
+
776
+ # print(f'处理耗时: {time.time()*1000 - ts:.2f} ms')
777
+
778
+
779
+
780
+ def sorted(
781
+ self, query: Item | None = None, limit: int | None = None
782
+ ) -> dict[str, list[Item]]:
783
+ return self._sorted(
784
+ item_key="S",
785
+ item_asc_key="a",
786
+ item_desc_key="b",
787
+ sort_key="p",
788
+ query=query,
789
+ limit=limit,
790
+ )
791
+
792
+ @property
793
+ def time(self) -> int | None:
794
+ """返回最后更新时间"""
795
+ return self._time
796
+
797
+
798
+
799
+ class SpotTicker(DataStore):
800
+ _KEYS = ["symbol"]
801
+
802
+ def _fmt(self, t: dict[str, Any]) -> dict[str, Any]:
803
+ # 根据示例:
804
+ # { id: "...", sb: "WCT_USDT", r8: "0.0094", tzr: "0.0094", c: "0.3002", h: "0.3035", l: "0.292", o: "0.2974", q: "1217506.41", a: "363548.8205" }
805
+ return {
806
+ "id": t.get("id"),
807
+ "symbol": t.get("sb"),
808
+ "last_price": t.get("c"),
809
+ "open_price": t.get("o"),
810
+ "high_price": t.get("h"),
811
+ "low_price": t.get("l"),
812
+ "volume24": t.get("q"),
813
+ "amount24": t.get("a"),
814
+ "rise_fall_rate": t.get("r8") if t.get("r8") is not None else t.get("tzr"),
815
+ }
816
+
817
+ def _onresponse(self, data: dict[str, Any] | list[dict[str, Any]]):
818
+ # 支持 data 为:
819
+ # - 直接为 list[dict]
820
+ # - {"data": list[dict]}
821
+ # - {"d": list[dict]}
822
+ payload = data
823
+ if isinstance(data, dict):
824
+ payload = data.get("data") or data.get("d") or data
825
+ if not isinstance(payload, list):
826
+ payload = [payload]
827
+ items = [self._fmt(t) for t in payload if isinstance(t, dict)]
828
+ if not items:
829
+ return
830
+ self._clear()
831
+ self._insert(items)
832
+
833
+ def _on_message(self, msg: dict[str, Any]) -> None:
834
+ # 兼容 WS:
835
+ # { "c": "increase.tickers", "d": { ...ticker... } }
836
+ d = msg.get("d") or msg.get("data") or msg
837
+ if not isinstance(d, dict):
838
+ return
839
+ item = self._fmt(d)
840
+ if not item.get("symbol"):
841
+ return
842
+ # 覆盖式更新该 symbol
843
+ self._find_and_delete({"symbol": item["symbol"]})
844
+ self._insert([item])
845
+
846
+ class SpotDetail(DataStore):
847
+ _KEYS = ["name"]
848
+
849
+ def _fmt(self, detail: dict) -> dict:
850
+ return {
851
+ "id": detail.get("id"), # 唯一ID
852
+ "name": detail.get("vn"), # 虚拟币简称
853
+ "name_abbr": detail.get("vna"), # 虚拟币全称
854
+ "final_name": detail.get("fn"), # 法币符号/展示名
855
+ "sort": detail.get("srt"), # 排序字段
856
+ "status": detail.get("sts"), # 状态 (1=可用, 0=不可用)
857
+ "type": detail.get("tp"), # 类型 (NEW=新币种)
858
+ "internal_id": detail.get("in"), # 内部唯一流水号
859
+ "first_online_time": detail.get("fot"), # 首次上线时间
860
+ "online_time": detail.get("ot"), # 上线时间
861
+ "coin_partition": detail.get("cp"), # 所属交易区分类
862
+ "price_scale": detail.get("ps"), # 价格小数位数
863
+ "quantity_scale": detail.get("qs"), # 数量小数位数
864
+ "contract_decimal_mode": detail.get("cdm"), # 合约精度模式
865
+ "contract_address": detail.get("ca"), # 代币合约地址
866
+ }
867
+
868
+ def _onresponse(self, data: dict[str, Any]):
869
+ details = data.get("data", {}).get('USDT')
870
+ if not details:
871
+ return
872
+
873
+ items = [self._fmt(detail) for detail in details]
874
+ self._clear()
875
+ self._insert(items)
876
+
877
+
878
+ class OurbitSpotDataStore(DataStoreCollection):
879
+ """
880
+ Ourbit DataStoreCollection Spot
881
+ """
882
+ def _init(self) -> None:
883
+ self._create("book", datastore_class=SpotBook)
884
+ self._create("ticker", datastore_class=SpotTicker)
885
+ self._create("balance", datastore_class=SpotBalance)
886
+ self._create("order", datastore_class=SpotOrders)
887
+ self._create("detail", datastore_class=SpotDetail)
888
+
889
+ @property
890
+ def book(self) -> SpotBook:
891
+ """
892
+ 获取现货订单簿
893
+ .. code:: json
894
+ [
895
+ {
896
+ "s": "BTC_USDT",
897
+ "S": "a",
898
+ "p": "110152.5",
899
+ "q": "53539"
900
+ }
901
+ ]
902
+
903
+ """
904
+ return self._get("book")
905
+
906
+ @property
907
+ def ticker(self) -> SpotTicker:
908
+ """
909
+ 获取现货 Ticker
910
+ .. code:: json
911
+ [
912
+ {
913
+ "symbol": "WCT_USDT",
914
+ "last_price": "0.3002",
915
+ "open_price": "0.2974",
916
+ "high_price": "0.3035",
917
+ "low_price": "0.292",
918
+ "volume24": "1217506.41",
919
+ "amount24": "363548.8205",
920
+ "rise_fall_rate": "0.0094",
921
+ "id": "dc893d07ca8345008db4d874da726a15"
922
+ }
923
+ ]
924
+ """
925
+ return self._get("ticker")
926
+
927
+ @property
928
+ def balance(self) -> SpotBalance:
929
+ """
930
+ 现货账户余额数据流
931
+
932
+ Data structure:
933
+
934
+ .. code:: python
935
+
936
+ [
937
+ {
938
+ "currency": "USDT", # 币种
939
+ "available": "100.0", # 可用余额
940
+ "frozen": "0.0", # 冻结余额
941
+ "usdt_available": "100.0",# USDT 可用余额
942
+ "usdt_frozen": "0.0", # USDT 冻结余额
943
+ "amount": "100.0", # 总金额
944
+ "avg_price": "1.0", # 平均价格
945
+ "last_price": "1.0", # 最新价格
946
+ "hidden_small": False, # 是否隐藏小额资产
947
+ "icon": "" # 币种图标
948
+ }
949
+ ]
950
+ """
951
+ return self._get("balance", SpotBalance)
952
+
953
+ @property
954
+ def detail(self) -> SpotDetail:
955
+ """
956
+ 现货交易对详情数据流
957
+
958
+ Data structure:
959
+
960
+ .. code:: python
961
+
962
+ [
963
+ {
964
+ "id": "3aada397655d44d69f4fc899b9c88531", # 唯一ID
965
+ "name": "USD1", # 虚拟币简称
966
+ "name_abbr": "USD1", # 虚拟币全称
967
+ "final_name": "USD1", # 法币符号/展示名
968
+ "sort": 57, # 排序字段
969
+ "status": 1, # 状态 (1=可用, 0=不可用)
970
+ "type": "NEW", # 类型 (NEW=新币种)
971
+ "internal_id": "F20250508113754813fb3qX9NPxRoNUF", # 内部唯一流水号
972
+ "first_online_time": 1746676200000, # 首次上线时间
973
+ "online_time": 1746676200000, # 上线时间
974
+ "coin_partition": ["ob_trade_zone_defi"], # 所属交易区分类
975
+ "price_scale": 4, # 价格小数位数
976
+ "quantity_scale": 2, # 数量小数位数
977
+ "contract_decimal_mode": 1, # 合约精度模式
978
+ "contract_address": "0x8d0D000Ee44948FC98c9B98A4FA4921476f08B0d" # 代币合约地址
979
+ }
980
+ ]
981
+ """
982
+ return self._get("detail", SpotDetail)
983
+
984
+ @property
985
+ def orders(self) -> SpotOrders:
986
+ """
987
+ 现货订单数据流
988
+
989
+ Data structure:
990
+
991
+ .. code:: python
992
+
993
+ [
994
+ {
995
+ "order_id": "123456", # 订单ID
996
+ "symbol": "BTC_USDT", # 交易对
997
+ "currency": "USDT", # 币种
998
+ "market": "BTC_USDT", # 市场
999
+ "trade_type": "buy", # 交易类型
1000
+ "order_type": "limit", # 订单类型
1001
+ "price": "11000.0", # 委托价格
1002
+ "quantity": "0.01", # 委托数量
1003
+ "amount": "110.0", # 委托金额
1004
+ "deal_quantity": "0.01", # 成交数量
1005
+ "deal_amount": "110.0", # 成交金额
1006
+ "avg_price": "11000.0", # 成交均价
1007
+ "state": "open", # 订单状态
1008
+ "source": "api", # 来源
1009
+ "fee": "0.01", # 手续费
1010
+ "create_ts": 1625247600000,# 创建时间戳
1011
+ "unique_id": "abcdefg" # 唯一标识
1012
+ }
1013
+ ]
1014
+ """
1015
+ return self._get("order", SpotOrders)
1016
+
1017
+ def onmessage(self, msg: Item, ws: ClientWebSocketResponse | None = None) -> None:
1018
+
1019
+ channel = msg.get("c")
1020
+ if 'msg' in msg:
1021
+ if 'invalid' in msg['msg']:
1022
+ logger.warning(f"WebSocket message invalid: {msg['msg']}")
1023
+ return
1024
+
1025
+ if channel is None:
1026
+ return
1027
+
1028
+ if 'increase.aggre.depth' in channel:
1029
+ self.book._on_message(msg)
1030
+
1031
+ if 'spot@private.orders' in channel:
1032
+ self.orders._on_message(msg)
1033
+
1034
+ if 'spot@private.balances' in channel:
1035
+ self.balance._on_message(msg)
1036
+
1037
+ if 'ticker' in channel:
1038
+ self.ticker._on_message(msg)
1039
+
1040
+ async def initialize(self, *aws: Awaitable[aiohttp.ClientResponse]) -> None:
1041
+ """Initialize DataStore from HTTP response data."""
1042
+ for f in asyncio.as_completed(aws):
1043
+ res = await f
1044
+ data = await res.json()
1045
+ if res.url.path == "/api/platform/spot/market/depth":
1046
+ self.book._onresponse(data)
1047
+ if res.url.path == "/api/platform/spot/market/v2/tickers":
1048
+ self.ticker._onresponse(data)
1049
+ if res.url.path == "/api/assetbussiness/asset/spot/statistic":
1050
+ self.balance._onresponse(data)
1051
+ if res.url.path == "/api/platform/spot/order/current/orders/v2":
1052
+ self.orders._onresponse(data)
1053
+ if res.url.path == "/api/platform/spot/market/v2/symbols":
1054
+ self.detail._onresponse(data)