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.
- trd_utils/__init__.py +3 -0
- trd_utils/cipher/__init__.py +44 -0
- trd_utils/common_utils/float_utils.py +21 -0
- trd_utils/common_utils/wallet_utils.py +26 -0
- trd_utils/date_utils/__init__.py +8 -0
- trd_utils/date_utils/datetime_helpers.py +25 -0
- trd_utils/exchanges/README.md +203 -0
- trd_utils/exchanges/__init__.py +28 -0
- trd_utils/exchanges/base_types.py +229 -0
- trd_utils/exchanges/binance/__init__.py +13 -0
- trd_utils/exchanges/binance/binance_client.py +389 -0
- trd_utils/exchanges/binance/binance_types.py +116 -0
- trd_utils/exchanges/blofin/__init__.py +6 -0
- trd_utils/exchanges/blofin/blofin_client.py +375 -0
- trd_utils/exchanges/blofin/blofin_types.py +173 -0
- trd_utils/exchanges/bx_ultra/__init__.py +6 -0
- trd_utils/exchanges/bx_ultra/bx_types.py +1338 -0
- trd_utils/exchanges/bx_ultra/bx_ultra_client.py +1123 -0
- trd_utils/exchanges/bx_ultra/bx_utils.py +51 -0
- trd_utils/exchanges/errors.py +10 -0
- trd_utils/exchanges/exchange_base.py +301 -0
- trd_utils/exchanges/hyperliquid/README.md +3 -0
- trd_utils/exchanges/hyperliquid/__init__.py +7 -0
- trd_utils/exchanges/hyperliquid/hyperliquid_client.py +292 -0
- trd_utils/exchanges/hyperliquid/hyperliquid_types.py +183 -0
- trd_utils/exchanges/okx/__init__.py +6 -0
- trd_utils/exchanges/okx/okx_client.py +219 -0
- trd_utils/exchanges/okx/okx_types.py +197 -0
- trd_utils/exchanges/price_fetcher.py +48 -0
- trd_utils/html_utils/__init__.py +26 -0
- trd_utils/html_utils/html_formats.py +72 -0
- trd_utils/tradingview/__init__.py +8 -0
- trd_utils/tradingview/tradingview_client.py +128 -0
- trd_utils/tradingview/tradingview_types.py +185 -0
- trd_utils/types_helper/__init__.py +12 -0
- trd_utils/types_helper/base_model.py +350 -0
- trd_utils/types_helper/decorators.py +20 -0
- trd_utils/types_helper/model_config.py +6 -0
- trd_utils/types_helper/ultra_list.py +39 -0
- trd_utils/types_helper/utils.py +40 -0
- trd_utils-0.0.57.dist-info/METADATA +42 -0
- trd_utils-0.0.57.dist-info/RECORD +44 -0
- trd_utils-0.0.57.dist-info/WHEEL +4 -0
- 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
|
+
###########################################################
|