trd-utils 0.0.28__py3-none-any.whl → 0.0.30__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 trd-utils might be problematic. Click here for more details.

trd_utils/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
 
2
- __version__ = "0.0.28"
2
+ __version__ = "0.0.30"
3
3
 
@@ -8,4 +8,14 @@ def dec_to_str(dec_value: Decimal) -> str:
8
8
  return format(dec_value.quantize(default_quantize), "f")
9
9
 
10
10
  def dec_to_normalize(dec_value: Decimal) -> str:
11
- return format(dec_value.normalize(), "f")
11
+ return format(dec_value.normalize(), "f")
12
+
13
+ def as_decimal(value) -> Decimal:
14
+ if value is None:
15
+ return None
16
+
17
+ if isinstance(value, Decimal):
18
+ # prevent extra allocation
19
+ return value
20
+
21
+ return Decimal(value)
@@ -37,6 +37,14 @@ class UnifiedPositionInfo(BaseModel):
37
37
  # The base unit that the open-price is based on (e.g. USD, USDT, USDC)
38
38
  open_price_unit: str | None = None
39
39
 
40
+ # The last price of this pair on the target exchange.
41
+ # not all exchanges support this yet, so use it with caution.
42
+ last_price: Decimal | None = None
43
+
44
+ # The last volume of this pair being traded on the target exchange.
45
+ # not all exchanges support this yet, so use it with caution.
46
+ last_volume: Decimal | None = None
47
+
40
48
  def __str__(self):
41
49
  parts = []
42
50
 
@@ -3,9 +3,11 @@ from decimal import Decimal
3
3
  from datetime import datetime, timedelta
4
4
  import pytz
5
5
 
6
+ from trd_utils.exchanges.errors import ExchangeError
6
7
  from trd_utils.types_helper import BaseModel
7
8
 
8
9
  from trd_utils.common_utils.float_utils import (
10
+ as_decimal,
9
11
  dec_to_str,
10
12
  dec_to_normalize,
11
13
  )
@@ -88,6 +90,28 @@ class ExchangeVo(BaseModel):
88
90
  return f"{self.exchange_name} ({self.exchange_id}) - {self.account_enum}"
89
91
 
90
92
 
93
+ class OrderLeverInfo(BaseModel):
94
+ lever_times: int = None
95
+ selected: bool = False
96
+
97
+ def __str__(self):
98
+ return f"{self.lever_times}x"
99
+
100
+ def __repr__(self):
101
+ return self.__str__()
102
+
103
+
104
+ class MarginDisplayInfo(BaseModel):
105
+ margin: Decimal = None
106
+ selected: bool = False
107
+
108
+
109
+ class SysForceVoInfo(BaseModel):
110
+ begin_level: int = None
111
+ end_level: int = None
112
+ adjust_margin_rate: Decimal = None
113
+
114
+
91
115
  # endregion
92
116
 
93
117
  ###########################################################
@@ -387,12 +411,15 @@ class ZenDeskABStatusResult(BaseModel):
387
411
  class ZenDeskABStatusResponse(BxApiResponse):
388
412
  data: ZenDeskABStatusResult = None
389
413
 
414
+
390
415
  class ZenDeskAuthResult(BaseModel):
391
416
  jwt: str = None
392
417
 
418
+
393
419
  class ZenDeskAuthResponse(BxApiResponse):
394
420
  data: ZenDeskAuthResult = None
395
421
 
422
+
396
423
  # endregion
397
424
 
398
425
  ###########################################################
@@ -550,6 +577,7 @@ class CopyTraderInfo(BaseModel):
550
577
  short_uid: int = None
551
578
  identity_type: int = None
552
579
 
580
+
553
581
  class CopyTraderVo(BaseModel):
554
582
  audit_status: int = None
555
583
  trader_status: int = None
@@ -565,6 +593,7 @@ class CopyTraderVo(BaseModel):
565
593
  rank_account_id: int = None
566
594
  last_trader_time: datetime = None
567
595
 
596
+
568
597
  class CopyTraderAccountGradeVO(BaseModel):
569
598
  uid: int = None
570
599
  api_identity: int = None
@@ -572,6 +601,7 @@ class CopyTraderAccountGradeVO(BaseModel):
572
601
  label: int = None
573
602
  uid_and_api: str = None
574
603
 
604
+
575
605
  class CopyTraderSharingAccount(BaseModel):
576
606
  category: int = None
577
607
  trader: int = None
@@ -588,6 +618,7 @@ class CopyTraderSharingAccount(BaseModel):
588
618
  hide_info: int = None
589
619
  copy_trade_label_type: Optional[int] = None
590
620
 
621
+
591
622
  class CopyTraderResumeResult(BaseModel):
592
623
  trader_info: CopyTraderInfo = None
593
624
  trader_vo: CopyTraderVo = None
@@ -605,9 +636,11 @@ class CopyTraderResumeResult(BaseModel):
605
636
  swap_copy_trade_label_type: int = None
606
637
  is_pro: int = None
607
638
 
639
+
608
640
  class CopyTraderResumeResponse(BxApiResponse):
609
641
  data: CopyTraderResumeResult = None
610
642
 
643
+
611
644
  # endregion
612
645
 
613
646
  ###########################################################
@@ -782,6 +815,13 @@ class SearchCopyTradersResponse(BxApiResponse):
782
815
  # region Account Assets types
783
816
 
784
817
 
818
+ class MinimalAssetInfo(BaseModel):
819
+ asset_id: int = None
820
+ asset_amount: Decimal = None
821
+ asset_name: str = None
822
+ has_value: bool = None
823
+
824
+
785
825
  class TotalAssetsInfo(BaseModel):
786
826
  amount: Any = None # unknown
787
827
  currency_amount: Decimal = None
@@ -1042,6 +1082,9 @@ class ContractOrdersHistoryResponse(BxApiResponse):
1042
1082
  found_any_for_today: bool = False
1043
1083
  today_earnings = Decimal("0.00")
1044
1084
  today = datetime.now(timezone).date()
1085
+ if not self.data and self.msg:
1086
+ raise ExchangeError(self.msg)
1087
+
1045
1088
  for current_order in self.data.orders:
1046
1089
  # check if the date is for today
1047
1090
  closed_date = (
@@ -1123,6 +1166,107 @@ class ContractOrdersHistoryResponse(BxApiResponse):
1123
1166
  return len(self.data.orders)
1124
1167
 
1125
1168
 
1169
+ class ContractConfigData(BaseModel):
1170
+ quotation_coin_id: int = None
1171
+ max_lever: OrderLeverInfo = None
1172
+ min_amount: Decimal = None
1173
+ levers: list[OrderLeverInfo] = None
1174
+ default_stop_loss_rate: Decimal = None
1175
+ default_stop_profit_rate: Decimal = None
1176
+ max_stop_loss_rate: Decimal = None
1177
+ max_stop_profit_rate: Decimal = None
1178
+ fee_rate: Decimal = None
1179
+ interest_rate: Decimal = None
1180
+ lever_fee_rate: Decimal = None
1181
+ sys_force_rate: Decimal = None
1182
+ new_sys_force_vo_list: list[SysForceVoInfo] = None
1183
+ margin_displays: list[MarginDisplayInfo] = None
1184
+ mlr: Decimal = None
1185
+ lsf: Decimal = None
1186
+ lsh: Decimal = None
1187
+ hold_amount: Decimal = None
1188
+ msr: Decimal = None
1189
+ sfa: Decimal = None
1190
+ available_asset: MinimalAssetInfo = None
1191
+ coupon_asset_value: Any = None
1192
+ contract_account_balance: MinimalAssetInfo = None
1193
+ delegate_order_up_threshold_rate: Any = None
1194
+ delegate_order_down_threshold_rate: Any = None
1195
+ profit_loss_extra_vo: Any = None
1196
+ fund_balance: Decimal = None
1197
+ balance: Decimal = None
1198
+ up_amount: Decimal = None
1199
+ down_amount: Decimal = None
1200
+ max_amount: Decimal = None
1201
+ stop_offset_rate: Decimal = None
1202
+
1203
+
1204
+ class ContractConfigResponse(BxApiResponse):
1205
+ data: ContractConfigData = None
1206
+
1207
+
1208
+ # endregion
1209
+
1210
+ ###########################################################
1211
+
1212
+ # region contract delegation types
1213
+
1214
+
1215
+ class CreateOrderDelegationData(BaseModel):
1216
+ order_id: str = None
1217
+ spread_rate: str = None
1218
+
1219
+
1220
+ class CreateOrderDelegationResponse(BxApiResponse):
1221
+ data: CreateOrderDelegationData = None
1222
+
1223
+
1224
+ # endregion
1225
+
1226
+ ###########################################################
1227
+
1228
+ # region candle types
1229
+
1230
+
1231
+ class SingleCandleInfo(BaseModel):
1232
+ # The pair in format of BTC/USDT.
1233
+ pair: str = None
1234
+
1235
+ # This candle's open price.
1236
+ open_price: Decimal = None
1237
+
1238
+ # The close price.
1239
+ close_price: Decimal = None
1240
+
1241
+ # volume in the first pair (e.g. BTC).
1242
+ volume: Decimal = None
1243
+
1244
+ # volume in the second part of the pair (e.g. USDT).
1245
+ quote_volume: Decimal = None
1246
+
1247
+ @staticmethod
1248
+ def deserialize_short(data: dict) -> "SingleCandleInfo":
1249
+ info = SingleCandleInfo()
1250
+ base: str = data.get("n", "")
1251
+ quote: str = data.get("m", "")
1252
+ info.pair = f"{base.upper()}/{quote.upper()}"
1253
+ info.open_price = as_decimal(data.get("o", None))
1254
+ info.close_price = as_decimal(data.get("c", None))
1255
+ info.volume = data.get("v", None)
1256
+ info.quote_volume = data.get("a", None)
1257
+
1258
+ return info
1259
+
1260
+ def __str__(self):
1261
+ return (
1262
+ f"{self.pair}, open: {self.open_price}, "
1263
+ f"close: {self.close_price}, volume: {self.quote_volume}"
1264
+ )
1265
+
1266
+ def __repr__(self):
1267
+ return super().__str__()
1268
+
1269
+
1126
1270
  # endregion
1127
1271
 
1128
1272
  ###########################################################
@@ -3,9 +3,12 @@ BxUltra exchange subclass
3
3
  """
4
4
 
5
5
  import asyncio
6
+ from datetime import datetime, timedelta, timezone
6
7
  from decimal import Decimal
7
8
  import json
8
9
  import logging
10
+ import gzip
11
+ from urllib.parse import urlencode
9
12
  import uuid
10
13
 
11
14
  import httpx
@@ -13,6 +16,11 @@ import httpx
13
16
  import time
14
17
  from pathlib import Path
15
18
 
19
+ import websockets
20
+ import websockets.asyncio
21
+ import websockets.asyncio.client
22
+ import websockets.asyncio.connection
23
+
16
24
  from trd_utils.exchanges.base_types import (
17
25
  UnifiedPositionInfo,
18
26
  UnifiedTraderInfo,
@@ -21,17 +29,20 @@ from trd_utils.exchanges.base_types import (
21
29
  from trd_utils.exchanges.bx_ultra.bx_utils import do_ultra_ss
22
30
  from trd_utils.exchanges.bx_ultra.bx_types import (
23
31
  AssetsInfoResponse,
32
+ ContractConfigResponse,
24
33
  ContractOrdersHistoryResponse,
25
34
  ContractsListResponse,
26
35
  CopyTraderFuturesStatsResponse,
27
36
  CopyTraderResumeResponse,
28
37
  CopyTraderTradePositionsResponse,
38
+ CreateOrderDelegationResponse,
29
39
  HintListResponse,
30
40
  HomePageResponse,
31
41
  HotSearchResponse,
32
42
  QuotationRankResponse,
33
43
  SearchCopyTraderCondition,
34
44
  SearchCopyTradersResponse,
45
+ SingleCandleInfo,
35
46
  UserFavoriteQuotationResponse,
36
47
  ZenDeskABStatusResponse,
37
48
  ZenDeskAuthResponse,
@@ -39,7 +50,8 @@ from trd_utils.exchanges.bx_ultra.bx_types import (
39
50
  )
40
51
  from trd_utils.cipher import AESCipher
41
52
 
42
- from trd_utils.exchanges.exchange_base import ExchangeBase
53
+ from trd_utils.exchanges.errors import ExchangeError
54
+ from trd_utils.exchanges.exchange_base import ExchangeBase, JWTManager
43
55
 
44
56
  PLATFORM_ID_ANDROID = "10"
45
57
  PLATFORM_ID_WEB = "30"
@@ -71,6 +83,7 @@ class BXUltraClient(ExchangeBase):
71
83
  # region client parameters
72
84
  we_api_base_host: str = "\u0061pi-\u0061pp.w\u0065-\u0061pi.com"
73
85
  we_api_base_url: str = "https://\u0061pi-\u0061pp.w\u0065-\u0061pi.com/\u0061pi"
86
+ ws_we_api_base_url: str = "wss://ws-market-swap.w\u0065-\u0061pi.com/ws"
74
87
  original_base_host: str = "https://\u0062ing\u0078.co\u006d"
75
88
  qq_os_base_host: str = "https://\u0061pi-\u0061pp.\u0071\u0071-os.com"
76
89
  qq_os_base_url: str = "https://\u0061pi-\u0061pp.\u0071\u0071-os.com/\u0061pi"
@@ -85,6 +98,9 @@ class BXUltraClient(ExchangeBase):
85
98
  platform_lang: str = "en"
86
99
  sys_lang: str = "en"
87
100
 
101
+ # a dict that maps "BTC/USDT" to it single candle info.
102
+ __last_candle_storage: dict = None
103
+ __last_candle_lock: asyncio.Lock = None
88
104
  # endregion
89
105
  ###########################################################
90
106
  # region client constructor
@@ -106,8 +122,16 @@ class BXUltraClient(ExchangeBase):
106
122
  self.device_brand = device_brand
107
123
  self.app_version = app_version
108
124
  self._fav_letter = fav_letter
125
+ self.sessions_dir = sessions_dir
109
126
 
110
- self.read_from_session_file(f"{sessions_dir}/{self.account_name}.bx")
127
+ self.read_from_session_file(
128
+ file_path=f"{self.sessions_dir}/{self.account_name}.bx"
129
+ )
130
+ self.__last_candle_storage = {}
131
+ self.__last_candle_lock = asyncio.Lock()
132
+ self._internal_lock = asyncio.Lock()
133
+ self.extra_tasks = []
134
+ self.ws_connections = []
111
135
 
112
136
  # endregion
113
137
  ###########################################################
@@ -222,6 +246,15 @@ class BXUltraClient(ExchangeBase):
222
246
  model_type=ZenDeskAuthResponse,
223
247
  )
224
248
 
249
+ async def re_authorize_user(self) -> bool:
250
+ result = await self.do_zendesk_auth()
251
+ if not result.data.jwt:
252
+ return False
253
+
254
+ self.authorization_token = result.data.jwt
255
+ self._save_session_file(file_path=f"{self.sessions_dir}/{self.account_name}.bx")
256
+ return True
257
+
225
258
  # endregion
226
259
  ###########################################################
227
260
  # region platform-tool
@@ -244,9 +277,120 @@ class BXUltraClient(ExchangeBase):
244
277
  model_type=AssetsInfoResponse,
245
278
  )
246
279
 
280
+ # endregion
281
+ ###########################################################
282
+ # region ws-subscribes
283
+ async def do_price_subscribe(self) -> None:
284
+ """
285
+ Subscribes to the price changes coming from the exchange.
286
+ NOTE: This method DOES NOT return. you should do create_task
287
+ for it.
288
+ """
289
+ params = {
290
+ "platformid": self.platform_id,
291
+ "app_version": self.app_version,
292
+ "x-router-tag": self.x_router_tag,
293
+ "lang": self.platform_lang,
294
+ "device_id": self.device_id,
295
+ "channel": self.channel_header,
296
+ "device_brand": self.device_brand,
297
+ "traceId": self.trace_id,
298
+ }
299
+ url = f"{self.ws_we_api_base_url}?{urlencode(params, doseq=True)}"
300
+ async with websockets.connect(url, ping_interval=None) as ws:
301
+ await self._internal_lock.acquire()
302
+ self.ws_connections.append(ws)
303
+ self._internal_lock.release()
304
+
305
+ await ws.send(json.dumps({
306
+ "dataType": "swap.market.v2.contracts",
307
+ "id": uuid.uuid4().hex,
308
+ "reqType": "sub",
309
+ }))
310
+ async for msg in ws:
311
+ try:
312
+ decompressed_message = gzip.decompress(msg)
313
+ if decompressed_message.lower() == "ping":
314
+ await ws.send("Pong")
315
+ continue
316
+
317
+ data: dict = json.loads(decompressed_message, parse_float=Decimal)
318
+ if not isinstance(data, dict):
319
+ logger.warning(f"invalid data instance: {type(data)}")
320
+ continue
321
+
322
+ if data.get("code", 0) == 0 and data.get("data", None) is None:
323
+ # it's all fine
324
+ continue
325
+
326
+ if data.get("ping", None):
327
+ target_id = data["ping"]
328
+ target_time = data.get(
329
+ "time",
330
+ datetime.now(
331
+ timezone(timedelta(hours=8))
332
+ ).isoformat(timespec="seconds")
333
+ )
334
+ await ws.send(json.dumps({
335
+ "pong": target_id,
336
+ "time": target_time,
337
+ }))
338
+ continue
339
+
340
+ inner_data = data.get("data", None)
341
+ if isinstance(inner_data, dict):
342
+ if data.get("dataType", None) == "swap.market.v2.contracts":
343
+ list_data = inner_data.get("l", None)
344
+ await self.__last_candle_lock.acquire()
345
+ for current in list_data:
346
+ info = SingleCandleInfo.deserialize_short(current)
347
+ if info:
348
+ self.__last_candle_storage[info.pair.lower()] = info
349
+ self.__last_candle_lock.release()
350
+ continue
351
+
352
+ logger.info(f"we got some unknown data: {data}")
353
+ except Exception as ex:
354
+ logger.info(f"failed to handle ws message from exchange: {msg}; {ex}")
355
+
356
+ async def get_last_candle(self, pair: str) -> SingleCandleInfo:
357
+ """
358
+ Returns the last candle's info in this exchange.
359
+ This method is safe to be called ONLY from the exact same thread
360
+ that the loop is currently operating on.
361
+ """
362
+ await self.__last_candle_lock.acquire()
363
+ info = self.__last_candle_storage.get(pair.lower())
364
+ self.__last_candle_lock.release()
365
+ return info
366
+
247
367
  # endregion
248
368
  ###########################################################
249
369
  # region contract
370
+ async def get_contract_config(
371
+ self,
372
+ fund_type: int, # e.g. 1
373
+ coin_name: str, # e.g. "SOL"
374
+ valuation_name: str, # e.g. "USDT"
375
+ margin_coin_name: str, # e.g. "USDT"
376
+ ) -> ContractConfigResponse:
377
+ params = {
378
+ "fundType": f"{fund_type}",
379
+ "coinName": f"{coin_name}",
380
+ "valuationName": f"{valuation_name}",
381
+ "marginCoinName": f"{margin_coin_name}",
382
+ }
383
+ headers = self.get_headers(
384
+ payload=params,
385
+ )
386
+ return await self.invoke_get(
387
+ # "https://bingx.com/api/v2/contract/config",
388
+ f"{self.qq_os_base_url}/v2/contract/config",
389
+ headers=headers,
390
+ params=params,
391
+ model_type=CopyTraderTradePositionsResponse,
392
+ )
393
+
250
394
  async def get_contract_list(
251
395
  self,
252
396
  quotation_coin_id: int = -1,
@@ -403,6 +547,60 @@ class BXUltraClient(ExchangeBase):
403
547
 
404
548
  # endregion
405
549
  ###########################################################
550
+ # region contract delegation
551
+ async def create_order_delegation(
552
+ self,
553
+ balance_direction: int, # e.g. 1
554
+ delegate_price: Decimal, # e.g. 107414.70
555
+ fund_type: int, # # e.g. 1
556
+ large_spread_rate: int, # e.g. 0
557
+ lever_times: int, # e.g. 5
558
+ margin: int, # e.g. 5
559
+ margin_coin_name: str, # e.g. "USDT"
560
+ market_factor: int, # e.g. 1
561
+ order_type: int, # e.g. 0
562
+ price: Decimal, # the current price of the market??
563
+ stop_loss_rate: int, # e.g. -1
564
+ stop_profit_rate: int, # e.g. -1
565
+ quotation_coin_id: int, # e.g. 1
566
+ spread_rate: float, # something very low. e.g. 0.00003481
567
+ stop_loss_price: float, # e.g. -1
568
+ stop_profit_price: float, # e.g. -1
569
+ up_ratio: Decimal, # e.g. 0.5
570
+ ) -> CreateOrderDelegationResponse:
571
+ payload = {
572
+ "balanceDirection": balance_direction,
573
+ "delegatePrice": f"{delegate_price}",
574
+ "fundType": fund_type,
575
+ "largeSpreadRate": large_spread_rate or 0,
576
+ "leverTimes": lever_times or 1,
577
+ "margin": margin,
578
+ "marginCoinName": margin_coin_name or "USDT",
579
+ "marketFactor": market_factor or 1,
580
+ "orderType": f"{order_type or 0}",
581
+ "price": float(price), # e.g. 107161.27
582
+ "profitLossRateDto": {
583
+ "stopProfitRate": stop_profit_rate or -1,
584
+ "stopLossRate": stop_loss_rate or -1,
585
+ },
586
+ "quotationCoinId": quotation_coin_id or 1,
587
+ "spreadRate": float(spread_rate) or 0.00003481,
588
+ "stopLossPrice": stop_loss_price or -1,
589
+ "stopProfitPrice": stop_profit_price or -1,
590
+ "upRatio": f"{0.5 if up_ratio is None else up_ratio}",
591
+ }
592
+ headers = self.get_headers(
593
+ needs_auth=True,
594
+ payload=payload,
595
+ )
596
+ return await self.invoke_post(
597
+ f"{self.we_api_base_url}/v2/contract/order/delegation",
598
+ headers=headers,
599
+ content=payload,
600
+ model_type=CreateOrderDelegationResponse,
601
+ )
602
+ # endregion
603
+ ###########################################################
406
604
  # region copy-trade-facade
407
605
  async def get_copy_trader_positions(
408
606
  self,
@@ -505,6 +703,7 @@ class BXUltraClient(ExchangeBase):
505
703
  self,
506
704
  uid: int | str,
507
705
  ) -> int | str:
706
+ global user_api_identity_cache
508
707
  api_identity = user_api_identity_cache.get(uid, None)
509
708
  if not api_identity:
510
709
  resume = await self.get_copy_trader_resume(
@@ -595,6 +794,8 @@ class BXUltraClient(ExchangeBase):
595
794
  self.authorization_token = json_data.get(
596
795
  "authorization_token", self.authorization_token
597
796
  )
797
+ if self.authorization_token:
798
+ self.jwt_manager = JWTManager(self.jwt_manager)
598
799
  self.app_id = json_data.get("app_id", self.app_id)
599
800
  self.trade_env = json_data.get("trade_env", self.trade_env)
600
801
  self.timezone = json_data.get("timezone", self.timezone)
@@ -608,6 +809,9 @@ class BXUltraClient(ExchangeBase):
608
809
  """
609
810
  Saves current information to the session file.
610
811
  """
812
+ if file_path is None:
813
+ file_path = f"{self.sessions_dir}/{self.account_name}.bx"
814
+
611
815
  if not self.device_id:
612
816
  self.device_id = uuid.uuid4().hex.replace("-", "") + "##"
613
817
 
@@ -645,8 +849,6 @@ class BXUltraClient(ExchangeBase):
645
849
  self,
646
850
  uid: int | str,
647
851
  ) -> UnifiedTraderPositions:
648
- global user_api_identity_cache
649
-
650
852
  api_identity = await self.get_trader_api_identity(
651
853
  uid=uid,
652
854
  )
@@ -656,6 +858,13 @@ class BXUltraClient(ExchangeBase):
656
858
  api_identity=api_identity,
657
859
  page_size=50, # TODO: make this dynamic I guess...
658
860
  )
861
+ if not result.data:
862
+ if result.msg:
863
+ raise ExchangeError(result.msg)
864
+ raise ExchangeError(
865
+ f"Unknown error happened while fetching positions of {uid}, "
866
+ f"code: {result.code}"
867
+ )
659
868
  if result.data.hide == 0 and not result.data.positions:
660
869
  # TODO: do proper exceptions here...
661
870
  raise ValueError("The trader has made their positions hidden")
@@ -674,6 +883,12 @@ class BXUltraClient(ExchangeBase):
674
883
  unified_pos.open_price_unit = (
675
884
  position.valuation_coin_name or position.symbol.split("-")[-1]
676
885
  ) # TODO
886
+
887
+ last_candle = await self.get_last_candle(unified_pos.position_pair)
888
+ if last_candle:
889
+ unified_pos.last_price = last_candle.close_price
890
+ unified_pos.last_volume = last_candle.quote_volume
891
+
677
892
  unified_result.positions.append(unified_pos)
678
893
 
679
894
  return unified_result
@@ -0,0 +1,10 @@
1
+
2
+
3
+
4
+ class ExchangeError(RuntimeError):
5
+ """
6
+ Specifies an error coming from the exchange.
7
+ """
8
+ def __init__(self, *args):
9
+ super().__init__(*args)
10
+
@@ -1,13 +1,38 @@
1
+ import asyncio
1
2
  from decimal import Decimal
2
3
  import json
4
+ import logging
3
5
  from typing import Type
4
6
  from abc import ABC
5
7
 
8
+ import base64
9
+ import time
10
+
6
11
  import httpx
12
+ from websockets.asyncio.connection import Connection as WSConnection
7
13
 
8
14
  from trd_utils.exchanges.base_types import UnifiedTraderInfo, UnifiedTraderPositions
9
15
  from trd_utils.types_helper.base_model import BaseModel
10
16
 
17
+ logger = logging.getLogger(__name__)
18
+
19
+ class JWTManager():
20
+ _jwt_string: str = None
21
+
22
+ def __init__(self, jwt_string: str):
23
+ self._jwt_string = jwt_string
24
+ try:
25
+ payload_b64 = self._jwt_string.split('.')[1]
26
+ payload_bytes = base64.urlsafe_b64decode(payload_b64 + '==')
27
+ self.payload = json.loads(payload_bytes)
28
+ except Exception:
29
+ self.payload = {}
30
+
31
+ def is_expired(self):
32
+ if "exp" not in self.payload:
33
+ return False
34
+
35
+ return time.time() > self.payload["exp"]
11
36
 
12
37
  class ExchangeBase(ABC):
13
38
  ###########################################################
@@ -22,11 +47,23 @@ class ExchangeBase(ABC):
22
47
  device_id: str = None
23
48
  trace_id: str = None
24
49
  app_version: str = "4.28.3"
50
+ x_router_tag: str = "gray-develop"
25
51
  platform_id: str = "10"
26
52
  install_channel: str = "officialAPK"
27
53
  channel_header: str = "officialAPK"
28
54
 
55
+ jwt_manager: JWTManager = None
56
+
29
57
  _fav_letter: str = "^"
58
+
59
+ # the lock for internal operations.
60
+ _internal_lock: asyncio.Lock = None
61
+
62
+ # extra tasks to be cancelled when the client closes.
63
+ extra_tasks: list[asyncio.Task] = None
64
+
65
+ # the ws connections to be closed when this client is closed.
66
+ ws_connections: list[WSConnection] = None
30
67
  # endregion
31
68
  ###########################################################
32
69
 
@@ -169,9 +206,32 @@ class ExchangeBase(ABC):
169
206
  # Now parse the decompressed content
170
207
  return json.loads(content.decode("utf-8"), parse_float=parse_float)
171
208
 
209
+ async def __aenter__(self):
210
+ return self
211
+
212
+ async def __aexit__(
213
+ self,
214
+ exc_type=None,
215
+ exc_value=None,
216
+ traceback=None,
217
+ ) -> None:
218
+ await self.aclose()
219
+
172
220
  async def aclose(self) -> None:
221
+ await self._internal_lock.acquire()
173
222
  await self.httpx_client.aclose()
174
223
 
224
+ if self.ws_connections:
225
+ for current in self.ws_connections:
226
+ try:
227
+ await current.close()
228
+ except Exception as ex:
229
+ logger.warning(f"failed to close ws connection: {ex}")
230
+ continue
231
+ self.ws_connections = []
232
+
233
+ self._internal_lock.release()
234
+
175
235
  # endregion
176
236
  ###########################################################
177
237
  # region data-files related methods
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: trd_utils
3
- Version: 0.0.28
3
+ Version: 0.0.30
4
4
  Summary: Common Basic Utils for Python3. By ALiwoto.
5
5
  Keywords: utils,trd_utils,basic-utils,common-utils
6
6
  Author: ALiwoto
@@ -19,6 +19,8 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
20
  Requires-Dist: cryptography (>=41.0.7)
21
21
  Requires-Dist: httpx (>=0.21.0)
22
+ Requires-Dist: python-dateutil (>=2.9.0.post0)
23
+ Requires-Dist: websockets (>=15.0.1)
22
24
  Project-URL: Homepage, https://github.com/ALiwoto/trd_utils
23
25
  Description-Content-Type: text/markdown
24
26
 
@@ -1,20 +1,21 @@
1
- trd_utils/__init__.py,sha256=66Y_sF6wGqRS2D6AE1d9RI5UDSNDIy9_d8fnOFNzILE,25
1
+ trd_utils/__init__.py,sha256=yHJ8D3i2dO26rZPCv6zktwpCzBAZJncL4kRz1UsG-Po,25
2
2
  trd_utils/cipher/__init__.py,sha256=V05KNuzQwCic-ihMVHlC8sENaJGc3I8MCb4pg4849X8,1765
3
- trd_utils/common_utils/float_utils.py,sha256=W-jv7nzjl88xwGB6gsEXmDDhF6DseOrrVT2qx7OvyCo,266
3
+ trd_utils/common_utils/float_utils.py,sha256=aYPwJ005LmrRhXAngojwvdDdtRgeb1FfR6hKeQ5ndMU,470
4
4
  trd_utils/common_utils/wallet_utils.py,sha256=OX9q2fymP0VfIWTRIRBP8W33cfyjLXimxMgPOsZe-3g,727
5
5
  trd_utils/date_utils/__init__.py,sha256=Erg_E1TfKWNpiuZFm_NXRjCwoRMfxpPS2-mJK6V4lFM,77
6
6
  trd_utils/date_utils/datetime_helpers.py,sha256=euIJBr-6PfJzLScOC9xVXd8Re_Gw5CSBPwtHX9_Il4A,596
7
7
  trd_utils/exchanges/README.md,sha256=UwkpsfcoLCJaMvJe4yBsFkDpf8P6DOLYhtybb6xWMLc,6738
8
8
  trd_utils/exchanges/__init__.py,sha256=sZRyp24q0KyMYASshAfsP-AfvsCADTYqqefxiRulPKE,484
9
- trd_utils/exchanges/base_types.py,sha256=9sjXwGaUlUgdYg2NJz64AIalPxKc2aGvjVZdt6aM63I,3284
9
+ trd_utils/exchanges/base_types.py,sha256=NGykHGyY97mcc4gqxa0RLNElro0y3cQsdSCM1XAtIz8,3625
10
10
  trd_utils/exchanges/blofin/__init__.py,sha256=X4r9o4Nyjla4UeOBG8lrgtnGYO2aErFMKaJ7yQrFasE,76
11
11
  trd_utils/exchanges/blofin/blofin_client.py,sha256=x0CU75_wWRA6RmVfm2wh7y-jNjohpcMG6HJ1tqkD1ok,12342
12
12
  trd_utils/exchanges/blofin/blofin_types.py,sha256=ZlHX1ClYTd2pDRTQIlZYyBu5ReGpMgxXxKASsPeBQug,4090
13
13
  trd_utils/exchanges/bx_ultra/__init__.py,sha256=8Ssy-eOemQR32Nv1-FoPHm87nRqRO4Fm2PU5GHEFKfQ,80
14
- trd_utils/exchanges/bx_ultra/bx_types.py,sha256=muHLa9lEjIOIDLQCb6xdyxSoQiIMSHErVe0NCoAS-RI,31017
15
- trd_utils/exchanges/bx_ultra/bx_ultra_client.py,sha256=AjaKbUFPWLTO_KwyCAB7DVbmJKUjSQqZs5A1NfSUCBo,25110
14
+ trd_utils/exchanges/bx_ultra/bx_types.py,sha256=oEXd825UVTINiQTKuUsJqG0qUo4bkl4Bay6cEDOAS_g,34532
15
+ trd_utils/exchanges/bx_ultra/bx_ultra_client.py,sha256=rSGq95C7OmN44W9K7rAMLMMCL8vys-9TnO6Dmc7BOoo,33787
16
16
  trd_utils/exchanges/bx_ultra/bx_utils.py,sha256=PwapomwDW33arVmKIDj6cL-aP0ptu4BYy_lOCqSAPOo,1392
17
- trd_utils/exchanges/exchange_base.py,sha256=QDWzltlGq4k2KsdAGMoLw_6_lZ7k3rPOP64cZKet0ng,5987
17
+ trd_utils/exchanges/errors.py,sha256=P_NTuc389XL7rFegomP59BydWmHv8ckiGyNU-_l5qNQ,167
18
+ trd_utils/exchanges/exchange_base.py,sha256=6vlwQXogZaxg1wNgZWvGZ0BYv0hfjdQNdXBpV-tvHkE,7665
18
19
  trd_utils/exchanges/hyperliquid/README.md,sha256=-qaxmDt_9NTus2xRuzyFGkKgYDWgWk7ufHVTSkyn3t4,105
19
20
  trd_utils/exchanges/hyperliquid/__init__.py,sha256=QhwGRcneGFHREM-MMdYpbcx-aWdsWsu2WznHzx7LaUM,92
20
21
  trd_utils/exchanges/hyperliquid/hyperliquid_client.py,sha256=dE-WIj1_H_5qHnO2Uu4DBVl1qEPdLdQFJW0IIwia1UM,6804
@@ -29,7 +30,7 @@ trd_utils/tradingview/tradingview_client.py,sha256=g_eWYaCRQAL8Kvd-r6AnAdbH7Jha6
29
30
  trd_utils/tradingview/tradingview_types.py,sha256=z21MXPVdWHAduEl3gSeMIRhxtBN9yK-jPYHfZSMIbSA,6144
30
31
  trd_utils/types_helper/__init__.py,sha256=lLbUiW1jUV1gjzTMFLthwkvF0hwauH-F_J2JZq--1U0,67
31
32
  trd_utils/types_helper/base_model.py,sha256=Q2KK0r4UXP9PlWeIl6nxdAeCGB5InU5IkTNGAfJasfM,11808
32
- trd_utils-0.0.28.dist-info/LICENSE,sha256=J1EP2xt87RjjmsTV1jTjHDQMLIM9FjdwEftTpw8hyv4,1067
33
- trd_utils-0.0.28.dist-info/METADATA,sha256=jPMNi7OsWV2GIUlYE-YaR4Vog9xGvh6WPgrq6goTtdk,1095
34
- trd_utils-0.0.28.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
35
- trd_utils-0.0.28.dist-info/RECORD,,
33
+ trd_utils-0.0.30.dist-info/LICENSE,sha256=J1EP2xt87RjjmsTV1jTjHDQMLIM9FjdwEftTpw8hyv4,1067
34
+ trd_utils-0.0.30.dist-info/METADATA,sha256=uM6m-5n_NAzTrGqOgkVRlZHmUmqy-cwaZi-zDNJaen4,1179
35
+ trd_utils-0.0.30.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
36
+ trd_utils-0.0.30.dist-info/RECORD,,