trd-utils 0.0.14__py3-none-any.whl → 0.0.16__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.14"
2
+ __version__ = "0.0.16"
3
3
 
@@ -0,0 +1,8 @@
1
+
2
+ from .datetime_helpers import dt_from_ts
3
+
4
+
5
+ __all__ = [
6
+ "dt_from_ts",
7
+ ]
8
+
@@ -0,0 +1,16 @@
1
+
2
+
3
+
4
+
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ def dt_from_ts(timestamp: float) -> datetime:
9
+ """
10
+ Return a datetime from a timestamp.
11
+ :param timestamp: timestamp in seconds or milliseconds
12
+ """
13
+ if timestamp > 1e10:
14
+ # Timezone in ms - convert to seconds
15
+ timestamp /= 1000
16
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc)
@@ -0,0 +1,201 @@
1
+ # trd_utils.exchanges
2
+
3
+ All exchange clients are stored inside of their own directory.
4
+ Some of these are not really _exchange_, but a tracker for a specific exchange/blockchain. We will still put them in this section as long as they are for a _specific_ one.
5
+
6
+ If they are for a very general platform, such as tradingview, they should be put in a separate directory entirely.
7
+
8
+ ## Writing code for a new exchange
9
+
10
+ Here is a boilerplate code that we can use for creating a new exchange/tracker class:
11
+
12
+ ```py
13
+ import asyncio
14
+ from decimal import Decimal
15
+ import json
16
+ import logging
17
+ from typing import Type
18
+ import httpx
19
+
20
+ import time
21
+ from pathlib import Path
22
+
23
+ # from trd_utils.exchanges.my_exchange.my_exchange_types import (
24
+ # SomeAPIType
25
+ # )
26
+ from trd_utils.cipher import AESCipher
27
+ from trd_utils.exchanges.exchange_base import ExchangeBase
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class MyExchangeClient(ExchangeBase):
33
+ ###########################################################
34
+ # region client parameters
35
+ my_exchange_api_base_host: str = "https://exchange.com"
36
+ my_exchange_api_base_url: str = "https://exchange.com/api/v1"
37
+ origin_header: str = "https://exchange.com/"
38
+
39
+ timezone: str = "Etc/UTC"
40
+
41
+ # endregion
42
+ ###########################################################
43
+ # region client constructor
44
+ def __init__(
45
+ self,
46
+ account_name: str = "default",
47
+ http_verify: bool = True,
48
+ fav_letter: str = "^",
49
+ read_session_file: bool = True,
50
+ sessions_dir: str = "sessions",
51
+ ):
52
+ self.httpx_client = httpx.AsyncClient(
53
+ verify=http_verify,
54
+ http2=True,
55
+ http1=False,
56
+ )
57
+ self.account_name = account_name
58
+ self._fav_letter = fav_letter
59
+ self.sessions_dir = sessions_dir
60
+
61
+ if read_session_file:
62
+ self.read_from_session_file(f"{sessions_dir}/{self.account_name}.bf")
63
+
64
+ # endregion
65
+ ###########################################################
66
+ # region something
67
+ # async def get_something_info(self) -> SomethingInfoResponse:
68
+ # headers = self.get_headers()
69
+ # return await self.invoke_get(
70
+ # f"{self.my_exchange_api_base_url}/something/info",
71
+ # headers=headers,
72
+ # model=SomethingInfoResponse,
73
+ # )
74
+ # endregion
75
+ ###########################################################
76
+ # region another-thing
77
+ # async def get_another_thing_info(self, uid: int) -> AnotherThingInfoResponse:
78
+ # payload = {
79
+ # "uid": uid,
80
+ # }
81
+ # headers = self.get_headers()
82
+ # return await self.invoke_post(
83
+ # f"{self.my_exchange_api_base_url}/another-thing/info",
84
+ # headers=headers,
85
+ # content=payload,
86
+ # model=CopyTraderInfoResponse,
87
+ # )
88
+
89
+ # endregion
90
+ ###########################################################
91
+ # region client helper methods
92
+ def get_headers(self, payload=None, needs_auth: bool = False) -> dict:
93
+ the_timestamp = int(time.time() * 1000)
94
+ the_headers = {
95
+ # "Host": self.my_exchange_api_base_host,
96
+ "Content-Type": "application/json",
97
+ "Accept": "application/json",
98
+ "Origin": self.origin_header,
99
+ "X-Tz": self.timezone,
100
+ "Fp-Request-Id": f"{the_timestamp}.n1fDrN",
101
+ "Accept-Encoding": "gzip, deflate, br, zstd",
102
+ "User-Agent": self.user_agent,
103
+ "Connection": "close",
104
+ "appsiteid": "0",
105
+ }
106
+
107
+ if self.x_requested_with:
108
+ the_headers["X-Requested-With"] = self.x_requested_with
109
+
110
+ if needs_auth:
111
+ the_headers["Authorization"] = f"Bearer {self.authorization_token}"
112
+ return the_headers
113
+
114
+ async def invoke_get(
115
+ self,
116
+ url: str,
117
+ headers: dict | None = None,
118
+ params: dict | None = None,
119
+ model: Type[MyExchangeApiResponse] | None = None,
120
+ parse_float=Decimal,
121
+ ) -> "MyExchangeApiResponse":
122
+ """
123
+ Invokes the specific request to the specific url with the specific params and headers.
124
+ """
125
+ response = await self.httpx_client.get(
126
+ url=url,
127
+ headers=headers,
128
+ params=params,
129
+ )
130
+ return model.deserialize(response.json(parse_float=parse_float))
131
+
132
+ async def invoke_post(
133
+ self,
134
+ url: str,
135
+ headers: dict | None = None,
136
+ params: dict | None = None,
137
+ content: dict | str | bytes = "",
138
+ model: Type[MyExchangeApiResponse] | None = None,
139
+ parse_float=Decimal,
140
+ ) -> "MyExchangeApiResponse":
141
+ """
142
+ Invokes the specific request to the specific url with the specific params and headers.
143
+ """
144
+
145
+ if isinstance(content, dict):
146
+ content = json.dumps(content, separators=(",", ":"), sort_keys=True)
147
+
148
+ response = await self.httpx_client.post(
149
+ url=url,
150
+ headers=headers,
151
+ params=params,
152
+ content=content,
153
+ )
154
+ if not model:
155
+ return response.json()
156
+
157
+ return model.deserialize(response.json(parse_float=parse_float))
158
+
159
+ async def aclose(self) -> None:
160
+ await self.httpx_client.aclose()
161
+ logger.info("MyExchangeClient closed")
162
+ return True
163
+
164
+ def read_from_session_file(self, file_path: str) -> None:
165
+ """
166
+ Reads from session file; if it doesn't exist, creates it.
167
+ """
168
+ # check if path exists
169
+ target_path = Path(file_path)
170
+ if not target_path.exists():
171
+ return self._save_session_file(file_path=file_path)
172
+
173
+ aes = AESCipher(key=f"bf_{self.account_name}_bf", fav_letter=self._fav_letter)
174
+ content = aes.decrypt(target_path.read_text()).decode("utf-8")
175
+ json_data: dict = json.loads(content)
176
+
177
+ self.authorization_token = json_data.get(
178
+ "authorization_token",
179
+ self.authorization_token,
180
+ )
181
+ self.timezone = json_data.get("timezone", self.timezone)
182
+ self.user_agent = json_data.get("user_agent", self.user_agent)
183
+
184
+ def _save_session_file(self, file_path: str) -> None:
185
+ """
186
+ Saves current information to the session file.
187
+ """
188
+
189
+ json_data = {
190
+ "authorization_token": self.authorization_token,
191
+ "timezone": self.timezone,
192
+ "user_agent": self.user_agent,
193
+ }
194
+ aes = AESCipher(key=f"bf_{self.account_name}_bf", fav_letter=self._fav_letter)
195
+ target_path = Path(file_path)
196
+ target_path.write_text(aes.encrypt(json.dumps(json_data)))
197
+
198
+ # endregion
199
+ ###########################################################
200
+
201
+ ```
@@ -1,11 +1,21 @@
1
1
 
2
2
  from .exchange_base import ExchangeBase
3
+ from .base_types import (
4
+ UnifiedTraderInfo,
5
+ UnifiedTraderPositions,
6
+ UnifiedPositionInfo,
7
+ )
3
8
  from .blofin import BlofinClient
4
9
  from .bx_ultra import BXUltraClient
10
+ from .hyperliquid import HyperLiquidClient
5
11
 
6
12
 
7
13
  __all__ = [
8
- ExchangeBase,
9
- BXUltraClient,
10
- BlofinClient,
14
+ "ExchangeBase",
15
+ "UnifiedTraderInfo",
16
+ "UnifiedTraderPositions",
17
+ "UnifiedPositionInfo",
18
+ "BXUltraClient",
19
+ "BlofinClient",
20
+ "HyperLiquidClient",
11
21
  ]
@@ -0,0 +1,106 @@
1
+
2
+
3
+ from datetime import datetime
4
+ from decimal import Decimal
5
+
6
+ from trd_utils.types_helper.base_model import BaseModel
7
+
8
+ class UnifiedPositionInfo(BaseModel):
9
+ # The id of the position.
10
+ position_id: str = None
11
+
12
+ # The pnl (profit) of the position.
13
+ position_pnl: Decimal = None
14
+
15
+ # The position side, either "LONG" or "SHORT".
16
+ position_side: str = None
17
+
18
+ # The position's leverage.
19
+ position_leverage: Decimal = None
20
+
21
+ # The margin mode; e.g. cross or isolated. Please note that
22
+ # different exchanges might provide different kinds of margin modes,
23
+ # depending on what they support, that's why we can't support a unified
24
+ # enum type for this as of yet.
25
+ margin_mode: str = None
26
+
27
+ # The formatted pair string of this position.
28
+ # e.g. BTC/USDT.
29
+ position_pair: str = None
30
+
31
+ # The open time of this position.
32
+ # Note that not all public APIs might provide this field.
33
+ open_time: datetime = None
34
+
35
+ # Open price of the position.
36
+ open_price: Decimal = None
37
+
38
+ # The base unit that the open-price is based on (e.g. USD, USDT, USDC)
39
+ open_price_unit: str | None = None
40
+
41
+ def __str__(self):
42
+ parts = []
43
+
44
+ # Add position pair and ID
45
+ parts.append(f"Position: {self.position_pair or 'Unknown'} (ID: {self.position_id or 'N/A'})")
46
+
47
+ # Add side and leverage
48
+ side_str = f"Side: {self.position_side or 'Unknown'}"
49
+ if self.position_leverage is not None:
50
+ side_str += f", {self.position_leverage}x"
51
+ parts.append(side_str)
52
+
53
+ # Add margin mode if available
54
+ if self.margin_mode:
55
+ parts.append(f"Margin: {self.margin_mode}")
56
+
57
+ # Add open price if available
58
+ price_str = "Open price: "
59
+ if self.open_price is not None:
60
+ price_str += f"{self.open_price}"
61
+ if self.open_price_unit:
62
+ price_str += f" {self.open_price_unit}"
63
+ else:
64
+ price_str += "N/A"
65
+ parts.append(price_str)
66
+
67
+ # Add open time if available
68
+ if self.open_time:
69
+ parts.append(f"Opened: {self.open_time.strftime('%Y-%m-%d %H:%M:%S')}")
70
+
71
+ # Add PNL if available
72
+ if self.position_pnl is not None:
73
+ parts.append(f"PNL: {self.position_pnl}")
74
+
75
+ return " | ".join(parts)
76
+
77
+ def __repr__(self):
78
+ return self.__str__()
79
+
80
+ class UnifiedTraderPositions(BaseModel):
81
+ positions: list[UnifiedPositionInfo] = None
82
+
83
+ class UnifiedTraderInfo(BaseModel):
84
+ # Trader's id. Either int or str. In DEXes (such as HyperLiquid),
85
+ # this might be wallet address of the trader.
86
+ trader_id: int | str = None
87
+
88
+ # Name of the trader
89
+ trader_name: str = None
90
+
91
+ # The URL in which we can see the trader's profile
92
+ trader_url: str = None
93
+
94
+ # Trader's win-rate. Not all exchanges might support this field.
95
+ win_rate: Decimal = None
96
+
97
+ def __str__(self):
98
+ return (
99
+ f"Trader: {self.trader_name} (ID: {self.trader_id})"
100
+ f"{' | Win Rate: ' + str(round(self.win_rate, 2))}"
101
+ f"{' | Profile: ' + self.trader_url}"
102
+ )
103
+
104
+ def __repr__(self):
105
+ return self.__str__()
106
+
@@ -2,5 +2,5 @@
2
2
  from .blofin_client import BlofinClient
3
3
 
4
4
  __all__ = [
5
- BlofinClient,
5
+ "BlofinClient",
6
6
  ]
@@ -2,14 +2,18 @@ import asyncio
2
2
  from decimal import Decimal
3
3
  import json
4
4
  import logging
5
- from typing import Type
6
5
  import httpx
7
6
 
8
7
  import time
9
8
  from pathlib import Path
10
9
 
10
+ from trd_utils.date_utils.datetime_helpers import dt_from_ts
11
+ from trd_utils.exchanges.base_types import (
12
+ UnifiedPositionInfo,
13
+ UnifiedTraderInfo,
14
+ UnifiedTraderPositions,
15
+ )
11
16
  from trd_utils.exchanges.blofin.blofin_types import (
12
- BlofinApiResponse,
13
17
  CmsColorResponse,
14
18
  CopyTraderAllOrderHistory,
15
19
  CopyTraderAllOrderList,
@@ -21,6 +25,9 @@ from trd_utils.exchanges.blofin.blofin_types import (
21
25
  from trd_utils.cipher import AESCipher
22
26
  from trd_utils.exchanges.exchange_base import ExchangeBase
23
27
 
28
+
29
+ BASE_PROFILE_URL = "https://blofin.com/copy-trade/details/"
30
+
24
31
  logger = logging.getLogger(__name__)
25
32
 
26
33
 
@@ -64,7 +71,7 @@ class BlofinClient(ExchangeBase):
64
71
  return await self.invoke_get(
65
72
  f"{self.blofin_api_base_url}/cms/share_config",
66
73
  headers=headers,
67
- model=ShareConfigResponse,
74
+ model_type=ShareConfigResponse,
68
75
  )
69
76
 
70
77
  async def get_cms_color(self) -> CmsColorResponse:
@@ -72,7 +79,7 @@ class BlofinClient(ExchangeBase):
72
79
  return await self.invoke_get(
73
80
  f"{self.blofin_api_base_url}/cms/color",
74
81
  headers=headers,
75
- model=CmsColorResponse,
82
+ model_type=CmsColorResponse,
76
83
  )
77
84
 
78
85
  # endregion
@@ -87,28 +94,28 @@ class BlofinClient(ExchangeBase):
87
94
  f"{self.blofin_api_base_url}/copy/trader/info",
88
95
  headers=headers,
89
96
  content=payload,
90
- model=CopyTraderInfoResponse,
97
+ model_type=CopyTraderInfoResponse,
91
98
  )
92
99
 
93
100
  async def get_copy_trader_order_list(
94
101
  self,
95
- uid: int,
102
+ uid: int | str,
96
103
  from_param: int = 0,
97
104
  limit_param: int = 20,
98
105
  ) -> CopyTraderOrderListResponse:
99
106
  payload = {
100
107
  "from": from_param,
101
108
  "limit": limit_param,
102
- "uid": uid,
109
+ "uid": int(uid),
103
110
  }
104
111
  headers = self.get_headers()
105
112
  return await self.invoke_post(
106
113
  f"{self.blofin_api_base_url}/copy/trader/order/list",
107
114
  headers=headers,
108
115
  content=payload,
109
- model=CopyTraderOrderListResponse,
116
+ model_type=CopyTraderOrderListResponse,
110
117
  )
111
-
118
+
112
119
  async def get_copy_trader_all_order_list(
113
120
  self,
114
121
  uid: int,
@@ -132,10 +139,15 @@ class BlofinClient(ExchangeBase):
132
139
  from_param=current_id_from,
133
140
  limit_param=chunk_limit,
134
141
  )
135
- if not current_result or not isinstance(current_result, CopyTraderOrderListResponse) or \
136
- not current_result.data:
142
+ if not isinstance(current_result, CopyTraderOrderListResponse):
143
+ raise ValueError(
144
+ "get_copy_trader_order_list returned invalid value of "
145
+ f"{type(current_result)}",
146
+ )
147
+ if not current_result.data:
148
+ # we no longer have anything else here
137
149
  return result
138
-
150
+
139
151
  if current_result.data[0].id == current_id_from:
140
152
  if len(current_result.data) < 2:
141
153
  return result
@@ -146,7 +158,7 @@ class BlofinClient(ExchangeBase):
146
158
  "Expected first array to have the same value as from_param: "
147
159
  f"current_id_from: {current_id_from}; but was: {current_result.data[0].id}"
148
160
  )
149
-
161
+
150
162
  current_id_from = current_result.data[-1].id
151
163
  result.data.extend(current_result.data)
152
164
  result.total_count += len(current_result.data)
@@ -157,7 +169,6 @@ class BlofinClient(ExchangeBase):
157
169
  # we don't want to sleep after 1 request only
158
170
  await asyncio.sleep(sleep_delay)
159
171
 
160
-
161
172
  async def get_copy_trader_order_history(
162
173
  self,
163
174
  uid: int,
@@ -174,7 +185,7 @@ class BlofinClient(ExchangeBase):
174
185
  f"{self.blofin_api_base_url}/copy/trader/order/history",
175
186
  headers=headers,
176
187
  content=payload,
177
- model=CopyTraderOrderHistoryResponse,
188
+ model_type=CopyTraderOrderHistoryResponse,
178
189
  )
179
190
 
180
191
  async def get_copy_trader_all_order_history(
@@ -200,10 +211,13 @@ class BlofinClient(ExchangeBase):
200
211
  from_param=current_id_from,
201
212
  limit_param=chunk_limit,
202
213
  )
203
- if not current_result or not isinstance(current_result, CopyTraderOrderHistoryResponse) or \
204
- not current_result.data:
214
+ if (
215
+ not current_result
216
+ or not isinstance(current_result, CopyTraderOrderHistoryResponse)
217
+ or not current_result.data
218
+ ):
205
219
  return result
206
-
220
+
207
221
  if current_result.data[0].id == current_id_from:
208
222
  if len(current_result.data) < 2:
209
223
  return result
@@ -214,7 +228,7 @@ class BlofinClient(ExchangeBase):
214
228
  "Expected first array to have the same value as from_param: "
215
229
  f"current_id_from: {current_id_from}; but was: {current_result.data[0].id}"
216
230
  )
217
-
231
+
218
232
  current_id_from = current_result.data[-1].id
219
233
  result.data.extend(current_result.data)
220
234
  result.total_count += len(current_result.data)
@@ -250,56 +264,6 @@ class BlofinClient(ExchangeBase):
250
264
  the_headers["Authorization"] = f"Bearer {self.authorization_token}"
251
265
  return the_headers
252
266
 
253
- async def invoke_get(
254
- self,
255
- url: str,
256
- headers: dict | None = None,
257
- params: dict | None = None,
258
- model: Type[BlofinApiResponse] | None = None,
259
- parse_float=Decimal,
260
- ) -> "BlofinApiResponse":
261
- """
262
- Invokes the specific request to the specific url with the specific params and headers.
263
- """
264
- response = await self.httpx_client.get(
265
- url=url,
266
- headers=headers,
267
- params=params,
268
- )
269
- return model.deserialize(response.json(parse_float=parse_float))
270
-
271
- async def invoke_post(
272
- self,
273
- url: str,
274
- headers: dict | None = None,
275
- params: dict | None = None,
276
- content: dict | str | bytes = "",
277
- model: Type[BlofinApiResponse] | None = None,
278
- parse_float=Decimal,
279
- ) -> "BlofinApiResponse":
280
- """
281
- Invokes the specific request to the specific url with the specific params and headers.
282
- """
283
-
284
- if isinstance(content, dict):
285
- content = json.dumps(content, separators=(",", ":"), sort_keys=True)
286
-
287
- response = await self.httpx_client.post(
288
- url=url,
289
- headers=headers,
290
- params=params,
291
- content=content,
292
- )
293
- if not model:
294
- return response.json()
295
-
296
- return model.deserialize(response.json(parse_float=parse_float))
297
-
298
- async def aclose(self) -> None:
299
- await self.httpx_client.aclose()
300
- logger.info("BlofinClient closed")
301
- return True
302
-
303
267
  def read_from_session_file(self, file_path: str) -> None:
304
268
  """
305
269
  Reads from session file; if it doesn't exist, creates it.
@@ -336,3 +300,48 @@ class BlofinClient(ExchangeBase):
336
300
 
337
301
  # endregion
338
302
  ###########################################################
303
+ # region unified methods
304
+ async def get_unified_trader_positions(
305
+ self,
306
+ uid: int | str,
307
+ ) -> UnifiedTraderPositions:
308
+ result = await self.get_copy_trader_all_order_list(
309
+ uid=uid,
310
+ )
311
+ unified_result = UnifiedTraderPositions()
312
+ unified_result.positions = []
313
+ for position in result.data:
314
+ unified_pos = UnifiedPositionInfo()
315
+ unified_pos.position_id = position.id or position.order_id
316
+ unified_pos.position_pnl = position.real_pnl or position.pnl
317
+ unified_pos.position_side = (
318
+ "LONG" if position.order_side in ("LONG", "BUY") else "SHORT"
319
+ )
320
+ unified_pos.margin_mode = position.margin_mode
321
+ unified_pos.position_leverage = Decimal(position.leverage)
322
+ unified_pos.position_pair = position.symbol.replace("-", "/")
323
+ unified_pos.open_time = dt_from_ts(position.open_time)
324
+ unified_pos.open_price = position.avg_open_price
325
+ unified_pos.open_price_unit = position.symbol.split("-")[-1]
326
+ unified_result.positions.append(unified_pos)
327
+
328
+ return unified_result
329
+
330
+ async def get_unified_trader_info(
331
+ self,
332
+ uid: int | str,
333
+ ) -> UnifiedTraderInfo:
334
+ info_resp = await self.get_copy_trader_info(
335
+ uid=uid,
336
+ )
337
+ info = info_resp.data
338
+ unified_info = UnifiedTraderInfo()
339
+ unified_info.trader_id = info.uid
340
+ unified_info.trader_name = info.nick_name
341
+ unified_info.trader_url = f"{BASE_PROFILE_URL}{info.uid}"
342
+ unified_info.win_rate = info.win_rate
343
+
344
+ return unified_info
345
+
346
+ # endregion
347
+ ###########################################################