trd-utils 0.0.57__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.
Files changed (44) hide show
  1. trd_utils/__init__.py +3 -0
  2. trd_utils/cipher/__init__.py +44 -0
  3. trd_utils/common_utils/float_utils.py +21 -0
  4. trd_utils/common_utils/wallet_utils.py +26 -0
  5. trd_utils/date_utils/__init__.py +8 -0
  6. trd_utils/date_utils/datetime_helpers.py +25 -0
  7. trd_utils/exchanges/README.md +203 -0
  8. trd_utils/exchanges/__init__.py +28 -0
  9. trd_utils/exchanges/base_types.py +229 -0
  10. trd_utils/exchanges/binance/__init__.py +13 -0
  11. trd_utils/exchanges/binance/binance_client.py +389 -0
  12. trd_utils/exchanges/binance/binance_types.py +116 -0
  13. trd_utils/exchanges/blofin/__init__.py +6 -0
  14. trd_utils/exchanges/blofin/blofin_client.py +375 -0
  15. trd_utils/exchanges/blofin/blofin_types.py +173 -0
  16. trd_utils/exchanges/bx_ultra/__init__.py +6 -0
  17. trd_utils/exchanges/bx_ultra/bx_types.py +1338 -0
  18. trd_utils/exchanges/bx_ultra/bx_ultra_client.py +1123 -0
  19. trd_utils/exchanges/bx_ultra/bx_utils.py +51 -0
  20. trd_utils/exchanges/errors.py +10 -0
  21. trd_utils/exchanges/exchange_base.py +301 -0
  22. trd_utils/exchanges/hyperliquid/README.md +3 -0
  23. trd_utils/exchanges/hyperliquid/__init__.py +7 -0
  24. trd_utils/exchanges/hyperliquid/hyperliquid_client.py +292 -0
  25. trd_utils/exchanges/hyperliquid/hyperliquid_types.py +183 -0
  26. trd_utils/exchanges/okx/__init__.py +6 -0
  27. trd_utils/exchanges/okx/okx_client.py +219 -0
  28. trd_utils/exchanges/okx/okx_types.py +197 -0
  29. trd_utils/exchanges/price_fetcher.py +48 -0
  30. trd_utils/html_utils/__init__.py +26 -0
  31. trd_utils/html_utils/html_formats.py +72 -0
  32. trd_utils/tradingview/__init__.py +8 -0
  33. trd_utils/tradingview/tradingview_client.py +128 -0
  34. trd_utils/tradingview/tradingview_types.py +185 -0
  35. trd_utils/types_helper/__init__.py +12 -0
  36. trd_utils/types_helper/base_model.py +350 -0
  37. trd_utils/types_helper/decorators.py +20 -0
  38. trd_utils/types_helper/model_config.py +6 -0
  39. trd_utils/types_helper/ultra_list.py +39 -0
  40. trd_utils/types_helper/utils.py +40 -0
  41. trd_utils-0.0.57.dist-info/METADATA +42 -0
  42. trd_utils-0.0.57.dist-info/RECORD +44 -0
  43. trd_utils-0.0.57.dist-info/WHEEL +4 -0
  44. trd_utils-0.0.57.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1123 @@
1
+ """
2
+ BxUltra exchange subclass
3
+ """
4
+
5
+ import asyncio
6
+ from datetime import datetime, timedelta, timezone
7
+ from decimal import Decimal
8
+ import json
9
+ import logging
10
+ import gzip
11
+ from urllib.parse import urlencode
12
+ import uuid
13
+
14
+ import httpx
15
+
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import pytz
20
+ import websockets
21
+
22
+ from trd_utils.exchanges.base_types import (
23
+ UnifiedPositionInfo,
24
+ UnifiedTraderInfo,
25
+ UnifiedTraderPositions,
26
+ )
27
+ from trd_utils.exchanges.bx_ultra.bx_utils import do_ultra_ss
28
+ from trd_utils.exchanges.bx_ultra.bx_types import (
29
+ AssetsInfoResponse,
30
+ ContractConfigResponse,
31
+ ContractOrdersHistoryResponse,
32
+ ContractsListResponse,
33
+ CopyTraderFuturesStatsResponse,
34
+ CopyTraderResumeResponse,
35
+ CopyTraderStdFuturesPositionsResponse,
36
+ CopyTraderTradePositionsResponse,
37
+ CreateOrderDelegationResponse,
38
+ HintListResponse,
39
+ HomePageResponse,
40
+ HotSearchResponse,
41
+ QuotationRankResponse,
42
+ SearchCopyTraderCondition,
43
+ SearchCopyTradersResponse,
44
+ SingleCandleInfo,
45
+ UserFavoriteQuotationResponse,
46
+ ZenDeskABStatusResponse,
47
+ ZenDeskAuthResponse,
48
+ ZoneModuleListResponse,
49
+ )
50
+ from trd_utils.cipher import AESCipher
51
+
52
+ from trd_utils.exchanges.errors import ExchangeError
53
+ from trd_utils.exchanges.exchange_base import ExchangeBase, JWTManager
54
+ from trd_utils.exchanges.price_fetcher import IPriceFetcher
55
+ from trd_utils.types_helper import new_list
56
+
57
+ PLATFORM_ID_ANDROID = "10"
58
+ PLATFORM_ID_WEB = "30"
59
+ PLATFORM_ID_TG = "100"
60
+
61
+ ANDROID_DEVICE_BRAND = "SM-N976N"
62
+ WEB_DEVICE_BRAND = "Windows 10_Chrome_127.0.0.0"
63
+ EDGE_DEVICE_BRAND = "Windows 10_Edge_131.0.0.0"
64
+
65
+ ANDROID_APP_VERSION = "4.28.3"
66
+ WEB_APP_VERSION = "4.78.12"
67
+ TG_APP_VERSION = "5.0.15"
68
+
69
+ ACCEPT_ENCODING_HEADER = "gzip, deflate, br, zstd"
70
+ BASE_PROFILE_URL = "https://\u0062ing\u0078.co\u006d/en/CopyTr\u0061ding/"
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ # The cache in which we will be storing the api identities.
75
+ # The key of this dict is uid (long user identifier), to api-identity.
76
+ # Why is this a global variable, and not a class attribute? because as far as
77
+ # I've observed, api-identities in bx (unlike Telegram's access-hashes) are not
78
+ # specific to the current session that is fetching them,
79
+ user_api_identity_cache: dict[int, int] = {}
80
+
81
+
82
+ class BXUltraClient(ExchangeBase, IPriceFetcher):
83
+ ###########################################################
84
+ # region client parameters
85
+ we_api_base_host: str = "\u0061pi-\u0061pp.w\u0065-\u0061pi.com"
86
+ we_api_base_url: str = "https://\u0061pi-\u0061pp.w\u0065-\u0061pi.com/\u0061pi"
87
+ ws_we_api_base_url: str = "wss://ws-market-sw\u0061p.w\u0065-\u0061pi.com/ws"
88
+ f_ws_we_api_base_url: str = "wss://f-ws-\u0061pp.w\u0065-\u0061pi.com/market"
89
+ original_base_host: str = "https://\u0062ing\u0078.co\u006d"
90
+ qq_os_base_host: str = "https://\u0061pi-\u0061pp.\u0071\u0071-os.com"
91
+ qq_os_base_url: str = "https://\u0061pi-\u0061pp.\u0071\u0071-os.com/\u0061pi"
92
+
93
+ origin_header: str = "https://\u0062ing\u0078.co\u006d"
94
+ app_id: str = "30004"
95
+ main_app_id: str = "10009"
96
+ trade_env: str = "real"
97
+ timezone: str = "3"
98
+ os_version: str = "7.1.2"
99
+ device_brand: str = "SM-N976N"
100
+ platform_lang: str = "en"
101
+ sys_lang: str = "en"
102
+
103
+ # a dict that maps "BTC/USDT" to it single candle info.
104
+ __last_candle_storage: dict = None
105
+ __last_candle_lock: asyncio.Lock = None
106
+
107
+ # endregion
108
+ ###########################################################
109
+ # region client constructor
110
+ def __init__(
111
+ self,
112
+ account_name: str = "default",
113
+ platform_id: str = PLATFORM_ID_ANDROID,
114
+ device_brand: str = ANDROID_DEVICE_BRAND,
115
+ app_version: str = ANDROID_APP_VERSION,
116
+ http_verify: bool = True,
117
+ fav_letter: str = "^",
118
+ sessions_dir: str = "sessions",
119
+ use_http1: bool = False,
120
+ use_http2: bool = True,
121
+ ):
122
+ self.httpx_client = httpx.AsyncClient(
123
+ verify=http_verify,
124
+ http1=use_http1,
125
+ http2=use_http2,
126
+ )
127
+ self.account_name = account_name
128
+ self.platform_id = platform_id
129
+ self.device_brand = device_brand
130
+ self.app_version = app_version
131
+ self._fav_letter = fav_letter
132
+ self.sessions_dir = sessions_dir
133
+ self.exchange_name = "\u0062ing\u0078"
134
+
135
+ super().__init__()
136
+ self.read_from_session_file(
137
+ file_path=f"{self.sessions_dir}/{self.account_name}.bx"
138
+ )
139
+ self.__last_candle_storage = {}
140
+ self.__last_candle_lock = asyncio.Lock()
141
+
142
+ # endregion
143
+ ###########################################################
144
+ # region api/coin/v1
145
+ async def get_zone_module_info(
146
+ self,
147
+ only_one_position: int = 0,
148
+ biz_type: int = 10,
149
+ ) -> ZoneModuleListResponse:
150
+ """
151
+ Fetches and returns zone module info from the API.
152
+ Available zones are: All, Forex, Indices, MEME, Elon-inspired,
153
+ Innovation, AI Agent, BTC Ecosystem, TON Ecosystem, Commodities,
154
+ GameFi, Fan Tokens , Layer1 & Layer2, SOL Ecosystem, RWA, LST, DePin, AI
155
+ """
156
+ params = {
157
+ "bizType": f"{biz_type}",
158
+ }
159
+ headers = self.get_headers(params)
160
+ headers["Only_one_position"] = f"{only_one_position}"
161
+ return await self.invoke_get(
162
+ f"{self.we_api_base_url}/coin/v1/zone/module-info",
163
+ headers=headers,
164
+ params=params,
165
+ model_type=ZoneModuleListResponse,
166
+ )
167
+
168
+ async def get_user_favorite_quotation(
169
+ self,
170
+ only_one_position: int = 0,
171
+ biz_type: int = 1,
172
+ ) -> UserFavoriteQuotationResponse:
173
+ params = {
174
+ "bizType": f"{biz_type}",
175
+ }
176
+ headers = self.get_headers(params)
177
+ headers["Only_one_position"] = f"{only_one_position}"
178
+ return await self.invoke_get(
179
+ f"{self.we_api_base_url}/coin/v1/user/favorite/quotation",
180
+ headers=headers,
181
+ params=params,
182
+ model_type=UserFavoriteQuotationResponse,
183
+ )
184
+
185
+ async def get_quotation_rank(
186
+ self,
187
+ only_one_position: int = 0,
188
+ order_flag: int = 0,
189
+ ) -> QuotationRankResponse:
190
+ params = {
191
+ "orderFlag": f"{order_flag}",
192
+ }
193
+ headers = self.get_headers(params)
194
+ headers["Only_one_position"] = f"{only_one_position}"
195
+ return await self.invoke_get(
196
+ f"{self.we_api_base_url}/coin/v1/rank/quotation-rank",
197
+ headers=headers,
198
+ params=params,
199
+ model_type=QuotationRankResponse,
200
+ )
201
+
202
+ async def get_hot_search(
203
+ self,
204
+ only_one_position: int = 0,
205
+ biz_type: int = 30,
206
+ ) -> HotSearchResponse:
207
+ params = {
208
+ "bizType": f"{biz_type}",
209
+ }
210
+ headers = self.get_headers(params)
211
+ headers["Only_one_position"] = f"{only_one_position}"
212
+ return await self.invoke_get(
213
+ f"{self.we_api_base_url}/coin/v1/quotation/hot-search",
214
+ headers=headers,
215
+ params=params,
216
+ model_type=HotSearchResponse,
217
+ )
218
+
219
+ async def get_homepage(
220
+ self,
221
+ only_one_position: int = 0,
222
+ biz_type: int = 30,
223
+ ) -> HomePageResponse:
224
+ params = {
225
+ "biz-type": f"{biz_type}",
226
+ }
227
+ headers = self.get_headers(params)
228
+ headers["Only_one_position"] = f"{only_one_position}"
229
+ return await self.invoke_get(
230
+ f"{self.we_api_base_url}/coin/v1/discovery/homepage",
231
+ headers=headers,
232
+ params=params,
233
+ model_type=HomePageResponse,
234
+ )
235
+
236
+ # endregion
237
+ ###########################################################
238
+ # region customer
239
+ async def get_zendesk_ab_status(self) -> ZenDeskABStatusResponse:
240
+ headers = self.get_headers()
241
+ return await self.invoke_get(
242
+ f"{self.we_api_base_url}/customer/v1/zendesk/ab-status",
243
+ headers=headers,
244
+ model_type=ZenDeskABStatusResponse,
245
+ )
246
+
247
+ async def do_zendesk_auth(self) -> ZenDeskAuthResponse:
248
+ headers = self.get_headers(needs_auth=True)
249
+ return await self.invoke_get(
250
+ f"{self.we_api_base_url}/customer/v1/zendesk/auth/jwt",
251
+ headers=headers,
252
+ model_type=ZenDeskAuthResponse,
253
+ )
254
+
255
+ async def re_authorize_user(self) -> bool:
256
+ result = await self.do_zendesk_auth()
257
+ if not result.data.jwt:
258
+ return False
259
+
260
+ self.authorization_token = result.data.jwt
261
+ self._save_session_file(file_path=f"{self.sessions_dir}/{self.account_name}.bx")
262
+ return True
263
+
264
+ # endregion
265
+ ###########################################################
266
+ # region platform-tool
267
+ async def get_hint_list(self) -> HintListResponse:
268
+ headers = self.get_headers()
269
+ return await self.invoke_get(
270
+ f"{self.we_api_base_url}/platform-tool/v1/hint/list",
271
+ headers=headers,
272
+ model_type=HintListResponse,
273
+ )
274
+
275
+ # endregion
276
+ ###########################################################
277
+ # region asset-manager
278
+ async def get_assets_info(self) -> AssetsInfoResponse:
279
+ headers = self.get_headers(needs_auth=True)
280
+ return await self.invoke_get(
281
+ f"{self.we_api_base_url}/asset-manager/v1/assets/account-total-overview",
282
+ headers=headers,
283
+ model_type=AssetsInfoResponse,
284
+ )
285
+
286
+ # endregion
287
+ ###########################################################
288
+ # region ws last-candle methods
289
+ async def do_price_subscribe(self) -> None:
290
+ """
291
+ Subscribes to the price changes coming from the exchange.
292
+ NOTE: This method DOES NOT return. you should do create_task
293
+ for it.
294
+ """
295
+ params = {
296
+ "platformid": self.platform_id,
297
+ "app_version": self.app_version,
298
+ "x-router-tag": self.x_router_tag,
299
+ "lang": self.platform_lang,
300
+ "device_id": self.device_id,
301
+ "channel": self.channel_header,
302
+ "device_brand": self.device_brand,
303
+ "traceId": self.trace_id,
304
+ }
305
+ url = f"{self.ws_we_api_base_url}?{urlencode(params, doseq=True)}"
306
+ while True:
307
+ try:
308
+ await self._do_price_ws(
309
+ url=url,
310
+ )
311
+ except asyncio.CancelledError:
312
+ return
313
+ except Exception as ex:
314
+ err_str = f"{ex}"
315
+ if err_str.find("Event loop is closed") != -1:
316
+ # just return
317
+ return
318
+
319
+ logger.warning(f"error at _do_price_ws: {err_str}")
320
+ await asyncio.sleep(1)
321
+
322
+ async def _do_price_ws(self, url: str):
323
+ async with websockets.connect(url, ping_interval=None) as ws:
324
+ await self._internal_lock.acquire()
325
+ self.price_ws_connection = ws
326
+ self._internal_lock.release()
327
+
328
+ await ws.send(
329
+ json.dumps(
330
+ {
331
+ "dataType": "swap.market.v2.contracts",
332
+ "id": uuid.uuid4().hex,
333
+ "reqType": "sub",
334
+ }
335
+ )
336
+ )
337
+ async for msg in ws:
338
+ try:
339
+ decompressed_message = gzip.decompress(msg)
340
+ str_msg = decompressed_message.decode("utf-8")
341
+ await self._handle_price_ws_msg(
342
+ str_msg=str_msg,
343
+ )
344
+ except Exception as ex:
345
+ logger.info(
346
+ f"failed to handle ws message from exchange: {msg}; {ex}"
347
+ )
348
+
349
+ async def _handle_price_ws_msg(self, str_msg: str):
350
+ if str_msg.lower() == "ping":
351
+ await self.price_ws_connection.send("Pong")
352
+ return
353
+
354
+ data: dict = json.loads(str_msg, parse_float=Decimal)
355
+ if not isinstance(data, dict):
356
+ logger.warning(f"invalid data instance: {type(data)}")
357
+ return
358
+
359
+ if data.get("code", 0) == 0 and data.get("data", None) is None:
360
+ # it's all fine
361
+ return
362
+
363
+ if data.get("ping", None):
364
+ target_id = data["ping"]
365
+ target_time = data.get(
366
+ "time",
367
+ datetime.now(timezone(timedelta(hours=8))).isoformat(
368
+ timespec="seconds"
369
+ ),
370
+ )
371
+ await self.price_ws_connection.send(
372
+ json.dumps(
373
+ {
374
+ "pong": target_id,
375
+ "time": target_time,
376
+ }
377
+ )
378
+ )
379
+ return
380
+
381
+ inner_data = data.get("data", None)
382
+ if isinstance(inner_data, dict):
383
+ if data.get("dataType", None) == "swap.market.v2.contracts":
384
+ list_data = inner_data.get("l", None)
385
+ await self.__last_candle_lock.acquire()
386
+ for current in list_data:
387
+ info = SingleCandleInfo.deserialize_short(current)
388
+ if info:
389
+ self.__last_candle_storage[info.pair.lower()] = info
390
+ self.__last_candle_lock.release()
391
+ return
392
+
393
+ logger.info(f"we got some unknown data: {data}")
394
+
395
+ async def get_last_candle(self, pair: str) -> SingleCandleInfo:
396
+ """
397
+ Returns the last candle's info in this exchange.
398
+ This method is safe to be called ONLY from the exact same thread
399
+ that the loop is currently operating on.
400
+ """
401
+ await self.__last_candle_lock.acquire()
402
+ info = self.__last_candle_storage.get(pair.lower())
403
+ self.__last_candle_lock.release()
404
+ return info
405
+
406
+ # endregion
407
+ ###########################################################
408
+ # region contract
409
+ async def get_contract_config(
410
+ self,
411
+ fund_type: int, # e.g. 1
412
+ coin_name: str, # e.g. "SOL"
413
+ valuation_name: str, # e.g. "USDT"
414
+ margin_coin_name: str, # e.g. "USDT"
415
+ ) -> ContractConfigResponse:
416
+ params = {
417
+ "fundType": f"{fund_type}",
418
+ "coinName": f"{coin_name}",
419
+ "valuationName": f"{valuation_name}",
420
+ "marginCoinName": f"{margin_coin_name}",
421
+ }
422
+ headers = self.get_headers(
423
+ payload=params,
424
+ )
425
+ return await self.invoke_get(
426
+ # "https://bingx.com/api/v2/contract/config",
427
+ f"{self.qq_os_base_url}/v2/contract/config",
428
+ headers=headers,
429
+ params=params,
430
+ model_type=CopyTraderTradePositionsResponse,
431
+ )
432
+
433
+ async def get_contract_list(
434
+ self,
435
+ quotation_coin_id: int = -1,
436
+ margin_type: int = -1,
437
+ page_size: int = 20,
438
+ page_id: int = 0,
439
+ margin_coin_name: str = "",
440
+ create_type: str = -1,
441
+ ) -> ContractsListResponse:
442
+ params = {
443
+ "quotationCoinId": f"{quotation_coin_id}",
444
+ "marginType": f"{margin_type}",
445
+ "pageSize": f"{page_size}",
446
+ "pageId": f"{page_id}",
447
+ "createType": f"{create_type}",
448
+ }
449
+ if margin_coin_name:
450
+ params["marginCoinName"] = margin_coin_name
451
+ headers = self.get_headers(params, needs_auth=True)
452
+ return await self.invoke_get(
453
+ f"{self.we_api_base_url}/v4/contract/order/hold",
454
+ headers=headers,
455
+ params=params,
456
+ model_type=ContractsListResponse,
457
+ )
458
+
459
+ async def get_contract_order_history(
460
+ self,
461
+ fund_type: int = 1,
462
+ paging_size: int = 10,
463
+ page_id: int = 0,
464
+ from_order_no: int = 0,
465
+ margin_coin_name: str = "USDT",
466
+ margin_type: int = 0,
467
+ ) -> ContractOrdersHistoryResponse:
468
+ params = {
469
+ "fundType": f"{fund_type}",
470
+ "pagingSize": f"{paging_size}",
471
+ "pageId": f"{page_id}",
472
+ "marginCoinName": margin_coin_name,
473
+ "marginType": f"{margin_type}",
474
+ }
475
+ if from_order_no:
476
+ params["fromOrderNo"] = f"{from_order_no}"
477
+
478
+ headers = self.get_headers(params, needs_auth=True)
479
+ return await self.invoke_get(
480
+ f"{self.we_api_base_url}/v2/contract/order/history",
481
+ headers=headers,
482
+ params=params,
483
+ model_type=ContractOrdersHistoryResponse,
484
+ )
485
+
486
+ async def get_today_contract_earnings(
487
+ self,
488
+ margin_coin_name: str = "USDT",
489
+ page_size: int = 10,
490
+ max_total_size: int = 500,
491
+ delay_per_fetch: float = 1.0,
492
+ ) -> Decimal:
493
+ """
494
+ Fetches today's earnings from the contract orders.
495
+ NOTE: This method is a bit slow due to the API rate limiting.
496
+ NOTE: If the user has not opened ANY contract orders today,
497
+ this method will return None.
498
+ """
499
+ return await self._get_period_contract_earnings(
500
+ period="day",
501
+ margin_coin_name=margin_coin_name,
502
+ page_size=page_size,
503
+ max_total_size=max_total_size,
504
+ delay_per_fetch=delay_per_fetch,
505
+ )
506
+
507
+ async def get_this_week_contract_earnings(
508
+ self,
509
+ margin_coin_name: str = "USDT",
510
+ page_size: int = 10,
511
+ max_total_size: int = 500,
512
+ delay_per_fetch: float = 1.0,
513
+ ) -> Decimal:
514
+ """
515
+ Fetches this week's earnings from the contract orders.
516
+ NOTE: This method is a bit slow due to the API rate limiting.
517
+ NOTE: If the user has not opened ANY contract orders this week,
518
+ this method will return None.
519
+ """
520
+ return await self._get_period_contract_earnings(
521
+ period="week",
522
+ margin_coin_name=margin_coin_name,
523
+ page_size=page_size,
524
+ max_total_size=max_total_size,
525
+ delay_per_fetch=delay_per_fetch,
526
+ )
527
+
528
+ async def get_this_month_contract_earnings(
529
+ self,
530
+ margin_coin_name: str = "USDT",
531
+ page_size: int = 10,
532
+ max_total_size: int = 500,
533
+ delay_per_fetch: float = 1.0,
534
+ ) -> Decimal:
535
+ """
536
+ Fetches this month's earnings from the contract orders.
537
+ NOTE: This method is a bit slow due to the API rate limiting.
538
+ NOTE: If the user has not opened ANY contract orders this week,
539
+ this method will return None.
540
+ """
541
+ return await self._get_period_contract_earnings(
542
+ period="month",
543
+ margin_coin_name=margin_coin_name,
544
+ page_size=page_size,
545
+ max_total_size=max_total_size,
546
+ delay_per_fetch=delay_per_fetch,
547
+ )
548
+
549
+ async def _get_period_contract_earnings(
550
+ self,
551
+ period: str,
552
+ margin_coin_name: str = "USDT",
553
+ page_size: int = 10,
554
+ max_total_size: int = 500,
555
+ delay_per_fetch: float = 1.0,
556
+ ) -> Decimal:
557
+ total_fetched = 0
558
+ total_earnings = Decimal("0.00")
559
+ has_earned_any = False
560
+ while total_fetched < max_total_size:
561
+ current_page = total_fetched // page_size
562
+ result = await self.get_contract_order_history(
563
+ page_id=current_page,
564
+ paging_size=page_size,
565
+ margin_coin_name=margin_coin_name,
566
+ )
567
+ if period == "day":
568
+ temp_earnings = result.get_today_earnings()
569
+ elif period == "week":
570
+ temp_earnings = result.get_this_week_earnings()
571
+ elif period == "month":
572
+ temp_earnings = result.get_this_month_earnings()
573
+ if temp_earnings is None:
574
+ # all ended
575
+ break
576
+ total_earnings += temp_earnings
577
+ has_earned_any = True
578
+ total_fetched += page_size
579
+ if result.get_orders_len() < page_size:
580
+ break
581
+ await asyncio.sleep(delay_per_fetch)
582
+
583
+ if not has_earned_any:
584
+ return None
585
+ return total_earnings
586
+
587
+ # endregion
588
+ ###########################################################
589
+ # region contract delegation
590
+ async def create_order_delegation(
591
+ self,
592
+ balance_direction: int, # e.g. 1
593
+ delegate_price: Decimal, # e.g. 107414.70
594
+ fund_type: int, # # e.g. 1
595
+ large_spread_rate: int, # e.g. 0
596
+ lever_times: int, # e.g. 5
597
+ margin: int, # e.g. 5
598
+ margin_coin_name: str, # e.g. "USDT"
599
+ market_factor: int, # e.g. 1
600
+ order_type: int, # e.g. 0
601
+ price: Decimal, # the current price of the market??
602
+ stop_loss_rate: int, # e.g. -1
603
+ stop_profit_rate: int, # e.g. -1
604
+ quotation_coin_id: int, # e.g. 1
605
+ spread_rate: float, # something very low. e.g. 0.00003481
606
+ stop_loss_price: float, # e.g. -1
607
+ stop_profit_price: float, # e.g. -1
608
+ up_ratio: Decimal, # e.g. 0.5
609
+ ) -> CreateOrderDelegationResponse:
610
+ payload = {
611
+ "balanceDirection": balance_direction,
612
+ "delegatePrice": f"{delegate_price}",
613
+ "fundType": fund_type,
614
+ "largeSpreadRate": large_spread_rate or 0,
615
+ "leverTimes": lever_times or 1,
616
+ "margin": margin,
617
+ "marginCoinName": margin_coin_name or "USDT",
618
+ "marketFactor": market_factor or 1,
619
+ "orderType": f"{order_type or 0}",
620
+ "price": float(price), # e.g. 107161.27
621
+ "profitLossRateDto": {
622
+ "stopProfitRate": stop_profit_rate or -1,
623
+ "stopLossRate": stop_loss_rate or -1,
624
+ },
625
+ "quotationCoinId": quotation_coin_id or 1,
626
+ "spreadRate": float(spread_rate) or 0.00003481,
627
+ "stopLossPrice": stop_loss_price or -1,
628
+ "stopProfitPrice": stop_profit_price or -1,
629
+ "upRatio": f"{0.5 if up_ratio is None else up_ratio}",
630
+ }
631
+ headers = self.get_headers(
632
+ needs_auth=True,
633
+ payload=payload,
634
+ )
635
+ return await self.invoke_post(
636
+ f"{self.we_api_base_url}/v2/contract/order/delegation",
637
+ headers=headers,
638
+ content=payload,
639
+ model_type=CreateOrderDelegationResponse,
640
+ )
641
+
642
+ # endregion
643
+ ###########################################################
644
+ # region copy-trade-facade
645
+ async def get_copy_trader_positions(
646
+ self,
647
+ uid: int | str,
648
+ api_identity: str,
649
+ page_size: int = 20,
650
+ page_id: int = 0,
651
+ copy_trade_label_type: int = 1,
652
+ ) -> CopyTraderTradePositionsResponse:
653
+ params = {
654
+ "uid": f"{uid}",
655
+ "apiIdentity": f"{api_identity}",
656
+ "pageSize": f"{page_size}",
657
+ "pageId": f"{page_id}",
658
+ "copyTradeLabelType": f"{copy_trade_label_type}",
659
+ }
660
+ headers = self.get_headers(params)
661
+ return await self.invoke_get(
662
+ f"{self.we_api_base_url}/copy-trade-facade/v2/real/trader/positions",
663
+ headers=headers,
664
+ params=params,
665
+ model_type=CopyTraderTradePositionsResponse,
666
+ )
667
+
668
+ async def get_copy_trader_std_futures_positions(
669
+ self,
670
+ uid: int | str,
671
+ page_size: int = 20,
672
+ page_id: int = 0,
673
+ ) -> CopyTraderStdFuturesPositionsResponse:
674
+ params = {
675
+ "trader": f"{uid}",
676
+ # it seems like this method doesn't really need api identity param...
677
+ # "apiIdentity": f"{api_identity}",
678
+ "pageSize": f"{page_size}",
679
+ "pageId": f"{page_id}",
680
+ }
681
+ headers = self.get_headers(params)
682
+ return await self.invoke_get(
683
+ f"{self.we_api_base_url}/v1/copy-trade/traderContractHold",
684
+ headers=headers,
685
+ params=params,
686
+ model_type=CopyTraderStdFuturesPositionsResponse,
687
+ )
688
+
689
+ async def search_copy_traders(
690
+ self,
691
+ exchange_id: int = 2,
692
+ nick_name: str = "",
693
+ conditions: list[SearchCopyTraderCondition] = None,
694
+ page_id: int = 0,
695
+ page_size: int = 20,
696
+ sort: str = "comprehensive",
697
+ order: str = "desc",
698
+ ) -> SearchCopyTradersResponse:
699
+ params = {
700
+ "pageId": f"{page_id}",
701
+ "pageSize": f"{page_size}",
702
+ "sort": sort,
703
+ "order": order,
704
+ }
705
+ if conditions is None:
706
+ conditions = [
707
+ {"key": "exchangeId", "selected": "2", "type": "singleSelect"}
708
+ ]
709
+ else:
710
+ conditions = [x.to_dict() for x in conditions]
711
+
712
+ payload = {
713
+ "conditions": conditions,
714
+ "exchangeId": f"{exchange_id}",
715
+ "nickName": nick_name,
716
+ }
717
+ headers = self.get_headers(payload)
718
+ return await self.invoke_post(
719
+ f"{self.we_api_base_url}/v6/copy-trade/search/search",
720
+ headers=headers,
721
+ params=params,
722
+ content=payload,
723
+ model_type=SearchCopyTradersResponse,
724
+ )
725
+
726
+ async def get_copy_trader_futures_stats(
727
+ self,
728
+ uid: int | str,
729
+ api_identity: str,
730
+ ) -> CopyTraderFuturesStatsResponse:
731
+ """
732
+ Returns futures statistics of a certain trader.
733
+ If you do not have the api_identity parameter, please first invoke
734
+ get_copy_trader_resume method and get it from there.
735
+ """
736
+ params = {
737
+ "uid": f"{uid}",
738
+ "apiIdentity": f"{api_identity}",
739
+ }
740
+ headers = self.get_headers(params)
741
+ return await self.invoke_get(
742
+ f"{self.we_api_base_url}/copy-trade-facade/v4/trader/account/futures/stat",
743
+ headers=headers,
744
+ params=params,
745
+ model_type=CopyTraderFuturesStatsResponse,
746
+ )
747
+
748
+ async def get_copy_trader_resume(
749
+ self,
750
+ uid: int | str,
751
+ ) -> CopyTraderResumeResponse:
752
+ params = {
753
+ "uid": f"{uid}",
754
+ }
755
+ headers = self.get_headers(params)
756
+ return await self.invoke_get(
757
+ f"{self.we_api_base_url}/copy-trade-facade/v1/trader/resume",
758
+ headers=headers,
759
+ params=params,
760
+ model_type=CopyTraderResumeResponse,
761
+ )
762
+
763
+ async def get_trader_api_identity(
764
+ self,
765
+ uid: int | str,
766
+ sub_account_filter: str = "futures",
767
+ ) -> int | str:
768
+ global user_api_identity_cache
769
+ api_identity = user_api_identity_cache.get(uid, None)
770
+ if not api_identity:
771
+ resume = await self.get_copy_trader_resume(
772
+ uid=uid,
773
+ )
774
+ api_identity = resume.data.api_identity
775
+ if not api_identity:
776
+ # second try: try to use one of the sub-accounts' identity
777
+ api_identity = resume.data.get_account_identity_by_filter(
778
+ filter_text=sub_account_filter,
779
+ )
780
+
781
+ # maybe also try to fetch it in other ways later?
782
+ # ...
783
+ user_api_identity_cache[uid] = api_identity
784
+ return api_identity
785
+
786
+ # endregion
787
+ ###########################################################
788
+ # region welfare
789
+ async def do_daily_check_in(self):
790
+ headers = self.get_headers(needs_auth=True)
791
+ return await self.invoke_post(
792
+ f"{self.original_base_host}/api/act-operation/v1/welfare/sign-in/do",
793
+ headers=headers,
794
+ content="",
795
+ model_type=None,
796
+ )
797
+
798
+ # endregion
799
+ ###########################################################
800
+ # region client helper methods
801
+ def get_headers(self, payload=None, needs_auth: bool = False) -> dict:
802
+ the_timestamp = int(time.time() * 1000)
803
+ the_headers = {
804
+ "Host": self.we_api_base_host,
805
+ "Content-Type": "application/json",
806
+ "Mainappid": self.main_app_id,
807
+ "Accept": "application/json",
808
+ "Origin": self.origin_header,
809
+ "Traceid": self.trace_id,
810
+ "App_version": self.app_version,
811
+ "Platformid": self.platform_id,
812
+ "Device_id": self.device_id,
813
+ "Device_brand": self.device_brand,
814
+ "Channel": self.channel_header,
815
+ "Appid": self.app_id,
816
+ "Trade_env": self.trade_env,
817
+ "Timezone": self.timezone,
818
+ "Lang": self.platform_lang,
819
+ "Syslang": self.sys_lang,
820
+ "Sign": do_ultra_ss(
821
+ e_param=None,
822
+ se_param=None,
823
+ le_param=None,
824
+ timestamp=the_timestamp,
825
+ trace_id=self.trace_id,
826
+ device_id=self.device_id,
827
+ platform_id=self.platform_id,
828
+ app_version=self.app_version,
829
+ payload_data=payload,
830
+ ),
831
+ "Timestamp": f"{the_timestamp}",
832
+ "Accept-Encoding": ACCEPT_ENCODING_HEADER,
833
+ "User-Agent": self.user_agent,
834
+ "Connection": "close",
835
+ "appsiteid": "0",
836
+ }
837
+
838
+ if self.x_requested_with:
839
+ the_headers["X-Requested-With"] = self.x_requested_with
840
+
841
+ if needs_auth:
842
+ the_headers["Authorization"] = f"Bearer {self.authorization_token}"
843
+ return the_headers
844
+
845
+ def read_from_session_file(self, file_path: str) -> None:
846
+ """
847
+ Reads from session file; if it doesn't exist, creates it.
848
+ """
849
+ # check if path exists
850
+ target_path = Path(file_path)
851
+ if not target_path.exists():
852
+ return self._save_session_file(file_path=file_path)
853
+
854
+ aes = AESCipher(key=f"bx_{self.account_name}_bx", fav_letter=self._fav_letter)
855
+ content = aes.decrypt(target_path.read_text()).decode("utf-8")
856
+ json_data: dict = json.loads(content)
857
+
858
+ self.device_id = json_data.get("device_id", self.device_id)
859
+ self.trace_id = json_data.get("trace_id", self.trace_id)
860
+ self.app_version = json_data.get("app_version", self.app_version)
861
+ self.platform_id = json_data.get("platform_id", self.platform_id)
862
+ self.install_channel = json_data.get("install_channel", self.install_channel)
863
+ self.channel_header = json_data.get("channel_header", self.channel_header)
864
+ self.authorization_token = json_data.get(
865
+ "authorization_token", self.authorization_token
866
+ )
867
+ if self.authorization_token:
868
+ self.jwt_manager = JWTManager(self.jwt_manager)
869
+ self.app_id = json_data.get("app_id", self.app_id)
870
+ self.trade_env = json_data.get("trade_env", self.trade_env)
871
+ self.timezone = json_data.get("timezone", self.timezone)
872
+ self.os_version = json_data.get("os_version", self.os_version)
873
+ self.device_brand = json_data.get("device_brand", self.device_brand)
874
+ self.platform_lang = json_data.get("platform_lang", self.platform_lang)
875
+ self.sys_lang = json_data.get("sys_lang", self.sys_lang)
876
+ self.user_agent = json_data.get("user_agent", self.user_agent)
877
+
878
+ def _save_session_file(self, file_path: str) -> None:
879
+ """
880
+ Saves current information to the session file.
881
+ """
882
+ if file_path is None:
883
+ file_path = f"{self.sessions_dir}/{self.account_name}.bx"
884
+
885
+ if not self.device_id:
886
+ self.device_id = uuid.uuid4().hex.replace("-", "") + "##"
887
+
888
+ if not self.trace_id:
889
+ self.trace_id = uuid.uuid4().hex.replace("-", "")
890
+
891
+ json_data = {
892
+ "device_id": self.device_id,
893
+ "trace_id": self.trace_id,
894
+ "app_version": self.app_version,
895
+ "platform_id": self.platform_id,
896
+ "install_channel": self.install_channel,
897
+ "channel_header": self.channel_header,
898
+ "authorization_token": self.authorization_token,
899
+ "app_id": self.app_id,
900
+ "trade_env": self.trade_env,
901
+ "timezone": self.timezone,
902
+ "os_version": self.os_version,
903
+ "device_brand": self.device_brand,
904
+ "platform_lang": self.platform_lang,
905
+ "sys_lang": self.sys_lang,
906
+ "user_agent": self.user_agent,
907
+ }
908
+ aes = AESCipher(key=f"bx_{self.account_name}_bx", fav_letter=self._fav_letter)
909
+ target_path = Path(file_path)
910
+ if not target_path.exists():
911
+ target_path.parent.mkdir(parents=True, exist_ok=True)
912
+ target_path.touch()
913
+ target_path.write_text(aes.encrypt(json.dumps(json_data)))
914
+
915
+ # endregion
916
+ ###########################################################
917
+ # region unified methods
918
+
919
+ async def get_unified_trader_positions(
920
+ self,
921
+ uid: int | str,
922
+ api_identity: int | str | None = None,
923
+ min_margin: Decimal = 0,
924
+ ) -> UnifiedTraderPositions:
925
+ perp_positions = new_list()
926
+ std_positions = new_list()
927
+ perp_ex: str = None
928
+ std_ex: str = None
929
+
930
+ try:
931
+ result = await self.get_unified_trader_positions_perp(
932
+ uid=uid,
933
+ api_identity=api_identity,
934
+ min_margin=min_margin,
935
+ )
936
+ perp_positions = result.positions
937
+ except Exception as ex:
938
+ err_str = f"{ex}"
939
+ if err_str.find("as the client has been closed") != -1:
940
+ raise ex
941
+ perp_ex = ex
942
+
943
+ try:
944
+ result = await self.get_unified_trader_positions_std(
945
+ uid=uid,
946
+ min_margin=min_margin,
947
+ )
948
+ std_positions = result.positions
949
+ except Exception as ex:
950
+ err_str = f"{ex}"
951
+ if err_str.find("as the client has been closed") != -1:
952
+ raise ex
953
+ std_ex = ex
954
+
955
+ if not perp_positions and not std_positions:
956
+ if perp_ex or std_ex:
957
+ raise RuntimeError(
958
+ f"Failed to fetch both std and perp positions: perp: {perp_ex}; std: {std_ex}"
959
+ )
960
+
961
+ unified_result = UnifiedTraderPositions()
962
+ unified_result.positions = perp_positions + std_positions
963
+ return unified_result
964
+
965
+ async def get_unified_trader_positions_perp(
966
+ self,
967
+ uid: int | str,
968
+ api_identity: int | str | None = None,
969
+ sub_account_filter: str = "futures",
970
+ min_margin: Decimal = 0,
971
+ ) -> UnifiedTraderPositions:
972
+ if not api_identity:
973
+ api_identity = await self.get_trader_api_identity(
974
+ uid=uid,
975
+ sub_account_filter=sub_account_filter,
976
+ )
977
+
978
+ if not api_identity:
979
+ raise ValueError(f"Failed to fetch api_identity for user {uid}")
980
+
981
+ result = await self.get_copy_trader_positions(
982
+ uid=uid,
983
+ api_identity=api_identity,
984
+ page_size=50, # TODO: make this dynamic I guess...
985
+ )
986
+ if not result.data:
987
+ if result.msg:
988
+ raise ExchangeError(result.msg)
989
+ raise ExchangeError(
990
+ f"Unknown error happened while fetching positions of {uid}, "
991
+ f"code: {result.code}"
992
+ )
993
+ if result.data.hide == 0 and not result.data.positions:
994
+ # TODO: do proper exceptions here...
995
+ raise ValueError("The trader has made their positions hidden")
996
+ unified_result = UnifiedTraderPositions()
997
+ unified_result.positions = new_list()
998
+ for position in result.data.positions:
999
+ if min_margin and (not position.margin or position.margin < min_margin):
1000
+ continue
1001
+
1002
+ unified_pos = UnifiedPositionInfo()
1003
+ unified_pos.position_id = position.position_no
1004
+ unified_pos.position_pnl = position.unrealized_pnl
1005
+ unified_pos.position_side = position.position_side
1006
+ unified_pos.margin_mode = "isolated" # TODO: fix this
1007
+ unified_pos.position_leverage = position.leverage
1008
+ unified_pos.position_pair = position.symbol.replace("-", "/")
1009
+ unified_pos.open_time = datetime.now(
1010
+ pytz.UTC
1011
+ ) # TODO: do something for this?
1012
+ unified_pos.open_price = position.avg_price
1013
+ unified_pos.initial_margin = position.margin
1014
+ unified_pos.open_price_unit = (
1015
+ position.valuation_coin_name or position.symbol.split("-")[-1]
1016
+ ) # TODO
1017
+
1018
+ last_candle = await self.get_last_candle(unified_pos.position_pair)
1019
+ if last_candle:
1020
+ unified_pos.last_price = last_candle.close_price
1021
+ unified_pos.last_volume = last_candle.quote_volume
1022
+
1023
+ unified_result.positions.append(unified_pos)
1024
+
1025
+ return unified_result
1026
+
1027
+ async def get_unified_trader_positions_std(
1028
+ self,
1029
+ uid: int | str,
1030
+ page_offset: int = 0,
1031
+ page_size: int = 50,
1032
+ delay_amount: float = 1,
1033
+ min_margin: Decimal = 0,
1034
+ ) -> UnifiedTraderPositions:
1035
+ unified_result = UnifiedTraderPositions()
1036
+ unified_result.positions = new_list()
1037
+ current_page_id = page_offset - 1
1038
+
1039
+ while True:
1040
+ current_page_id += 1
1041
+ try:
1042
+ result = await self.get_copy_trader_std_futures_positions(
1043
+ uid=uid,
1044
+ page_size=page_size,
1045
+ page_id=current_page_id,
1046
+ )
1047
+
1048
+ if result.code != 0 and not result.data:
1049
+ if result.msg:
1050
+ raise ExchangeError(f"got error from API: {result.msg}")
1051
+ raise ExchangeError(
1052
+ f"got unknown error from bx API while fetching std positions for {uid}"
1053
+ )
1054
+
1055
+ for position in result.data.positions:
1056
+ if min_margin and (not position.margin or position.margin < min_margin):
1057
+ continue
1058
+
1059
+ unified_pos = UnifiedPositionInfo()
1060
+ unified_pos.position_id = position.order_no
1061
+ unified_pos.position_pnl = (
1062
+ position.current_price - position.display_price
1063
+ ) * position.amount
1064
+ unified_pos.position_side = (
1065
+ "LONG" if position.amount > 0 else "SHORT"
1066
+ )
1067
+ unified_pos.margin_mode = "isolated" # TODO: fix this
1068
+ unified_pos.position_leverage = position.lever_times
1069
+ unified_pos.position_pair = f"{position.quotation_coin_vo.coin.name}/{position.margin_coin_name}"
1070
+ unified_pos.open_time = position.create_time
1071
+ unified_pos.open_price = position.display_price
1072
+ unified_pos.initial_margin = position.margin
1073
+ unified_pos.open_price_unit = position.margin_coin_name
1074
+
1075
+ last_candle = await self.get_last_candle(unified_pos.position_pair)
1076
+ if last_candle:
1077
+ unified_pos.last_price = last_candle.close_price
1078
+ unified_pos.last_volume = last_candle.quote_volume
1079
+
1080
+ unified_result.positions.append(unified_pos)
1081
+
1082
+ if not result.data.total_str or result.data.total_str.find("+") == -1:
1083
+ # all is done
1084
+ return unified_result
1085
+ await asyncio.sleep(delay_amount)
1086
+ except Exception as ex:
1087
+ logger.warning(
1088
+ f"Failed to fetch std positions from exchange for {uid}: {ex}"
1089
+ )
1090
+ return unified_result
1091
+
1092
+ async def get_unified_trader_info(
1093
+ self,
1094
+ uid: int | str,
1095
+ ) -> UnifiedTraderInfo:
1096
+ resume_resp = await self.get_copy_trader_resume(
1097
+ uid=uid,
1098
+ )
1099
+ if resume_resp.code != 0 and not resume_resp.data:
1100
+ if resume_resp.msg:
1101
+ raise ExchangeError(f"got error from API: {resume_resp.msg}")
1102
+ raise ExchangeError(
1103
+ f"got unknown error from bx API while fetching resume for {uid}"
1104
+ )
1105
+
1106
+ resume = resume_resp.data
1107
+ api_identity = resume.api_identity
1108
+
1109
+ info_resp = await self.get_copy_trader_futures_stats(
1110
+ uid=uid,
1111
+ api_identity=api_identity,
1112
+ )
1113
+ info = info_resp.data
1114
+ unified_info = UnifiedTraderInfo()
1115
+ unified_info.trader_id = resume.trader_info.uid
1116
+ unified_info.trader_name = resume.trader_info.nick_name
1117
+ unified_info.trader_url = f"{BASE_PROFILE_URL}{uid}"
1118
+ unified_info.win_rate = Decimal(info.win_rate.rstrip("%")) / 100
1119
+
1120
+ return unified_info
1121
+
1122
+ # endregion
1123
+ ###########################################################