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
trd_utils/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
|
|
2
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
3
|
+
from cryptography.hazmat.backends import default_backend
|
|
4
|
+
from cryptography.hazmat.primitives import padding
|
|
5
|
+
from base64 import b64encode, b64decode
|
|
6
|
+
from os import urandom
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AESCipher:
|
|
10
|
+
def __init__(self, key: str, fav_letter: str):
|
|
11
|
+
if len(key) > 32:
|
|
12
|
+
raise ValueError("Key length must be 32 bytes or less")
|
|
13
|
+
elif len(key) < 32:
|
|
14
|
+
key = key.ljust(len(key) + (32 - len(key) % 32), fav_letter)
|
|
15
|
+
|
|
16
|
+
key = key.encode('utf-8')
|
|
17
|
+
if len(key) != 32:
|
|
18
|
+
raise ValueError("Key length must be 32 bytes")
|
|
19
|
+
|
|
20
|
+
self.key = key
|
|
21
|
+
self.backend = default_backend()
|
|
22
|
+
|
|
23
|
+
def encrypt(self, plaintext):
|
|
24
|
+
if isinstance(plaintext, str):
|
|
25
|
+
plaintext = plaintext.encode('utf-8')
|
|
26
|
+
|
|
27
|
+
iv = urandom(16)
|
|
28
|
+
cipher = Cipher(algorithms.AES(self.key), modes.CBC(iv), backend=self.backend)
|
|
29
|
+
padder = padding.PKCS7(128).padder()
|
|
30
|
+
padded_data = padder.update(plaintext) + padder.finalize()
|
|
31
|
+
encryptor = cipher.encryptor()
|
|
32
|
+
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
|
|
33
|
+
return b64encode(iv + ciphertext).decode('utf-8')
|
|
34
|
+
|
|
35
|
+
def decrypt(self, b64_encrypted_data):
|
|
36
|
+
encrypted_data = b64decode(b64_encrypted_data)
|
|
37
|
+
iv = encrypted_data[:16]
|
|
38
|
+
ciphertext = encrypted_data[16:]
|
|
39
|
+
cipher = Cipher(algorithms.AES(self.key), modes.CBC(iv), backend=self.backend)
|
|
40
|
+
unpadder = padding.PKCS7(128).unpadder()
|
|
41
|
+
decryptor = cipher.decryptor()
|
|
42
|
+
padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
|
43
|
+
plaintext = unpadder.update(padded_plaintext) + unpadder.finalize()
|
|
44
|
+
return plaintext
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
default_quantize = Decimal("1.00")
|
|
6
|
+
|
|
7
|
+
def dec_to_str(dec_value: Decimal) -> str:
|
|
8
|
+
return format(dec_value.quantize(default_quantize), "f")
|
|
9
|
+
|
|
10
|
+
def dec_to_normalize(dec_value: Decimal) -> str:
|
|
11
|
+
return format(dec_value.normalize(), "f")
|
|
12
|
+
|
|
13
|
+
def as_decimal(value) -> Decimal:
|
|
14
|
+
if value is None:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
if isinstance(value, Decimal):
|
|
18
|
+
# prevent extra allocation
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
return Decimal(value)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
General usage wallet-related utils code.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def shorten_wallet_address(
|
|
7
|
+
address: str,
|
|
8
|
+
start_chars: int = 7,
|
|
9
|
+
end_chars: int = 6,
|
|
10
|
+
):
|
|
11
|
+
"""
|
|
12
|
+
Shortens an Ethereum address by keeping a specific number of characters
|
|
13
|
+
from the beginning and end, separated by '...'.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
address (str): The Ethereum address to shorten
|
|
17
|
+
start_chars (int): Number of characters to keep from the beginning (default: 7)
|
|
18
|
+
end_chars (int): Number of characters to keep from the end (default: 6)
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
str: The shortened address
|
|
22
|
+
"""
|
|
23
|
+
if not address or len(address) <= start_chars + end_chars:
|
|
24
|
+
return address
|
|
25
|
+
|
|
26
|
+
return f"{address[:start_chars]}...{address[-end_chars:]}"
|
|
@@ -0,0 +1,25 @@
|
|
|
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)
|
|
17
|
+
|
|
18
|
+
def dt_to_ts(dt: datetime, default: int = 0) -> int:
|
|
19
|
+
"""
|
|
20
|
+
Return dt in ms as a timestamp in UTC.
|
|
21
|
+
If dt is None, return the given default.
|
|
22
|
+
"""
|
|
23
|
+
if dt:
|
|
24
|
+
return int(dt.timestamp() * 1000)
|
|
25
|
+
return default
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
use_http1: bool = False,
|
|
52
|
+
use_http2: bool = True,
|
|
53
|
+
):
|
|
54
|
+
self.httpx_client = httpx.AsyncClient(
|
|
55
|
+
verify=http_verify,
|
|
56
|
+
http1=use_http1,
|
|
57
|
+
http2=use_http2,
|
|
58
|
+
)
|
|
59
|
+
self.account_name = account_name
|
|
60
|
+
self._fav_letter = fav_letter
|
|
61
|
+
self.sessions_dir = sessions_dir
|
|
62
|
+
|
|
63
|
+
if read_session_file:
|
|
64
|
+
self.read_from_session_file(f"{sessions_dir}/{self.account_name}.bf")
|
|
65
|
+
|
|
66
|
+
# endregion
|
|
67
|
+
###########################################################
|
|
68
|
+
# region something
|
|
69
|
+
# async def get_something_info(self) -> SomethingInfoResponse:
|
|
70
|
+
# headers = self.get_headers()
|
|
71
|
+
# return await self.invoke_get(
|
|
72
|
+
# f"{self.my_exchange_api_base_url}/something/info",
|
|
73
|
+
# headers=headers,
|
|
74
|
+
# model=SomethingInfoResponse,
|
|
75
|
+
# )
|
|
76
|
+
# endregion
|
|
77
|
+
###########################################################
|
|
78
|
+
# region another-thing
|
|
79
|
+
# async def get_another_thing_info(self, uid: int) -> AnotherThingInfoResponse:
|
|
80
|
+
# payload = {
|
|
81
|
+
# "uid": uid,
|
|
82
|
+
# }
|
|
83
|
+
# headers = self.get_headers()
|
|
84
|
+
# return await self.invoke_post(
|
|
85
|
+
# f"{self.my_exchange_api_base_url}/another-thing/info",
|
|
86
|
+
# headers=headers,
|
|
87
|
+
# content=payload,
|
|
88
|
+
# model=CopyTraderInfoResponse,
|
|
89
|
+
# )
|
|
90
|
+
|
|
91
|
+
# endregion
|
|
92
|
+
###########################################################
|
|
93
|
+
# region client helper methods
|
|
94
|
+
def get_headers(self, payload=None, needs_auth: bool = False) -> dict:
|
|
95
|
+
the_timestamp = int(time.time() * 1000)
|
|
96
|
+
the_headers = {
|
|
97
|
+
# "Host": self.my_exchange_api_base_host,
|
|
98
|
+
"Content-Type": "application/json",
|
|
99
|
+
"Accept": "application/json",
|
|
100
|
+
"Origin": self.origin_header,
|
|
101
|
+
"X-Tz": self.timezone,
|
|
102
|
+
"Fp-Request-Id": f"{the_timestamp}.n1fDrN",
|
|
103
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
104
|
+
"User-Agent": self.user_agent,
|
|
105
|
+
"Connection": "close",
|
|
106
|
+
"appsiteid": "0",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if self.x_requested_with:
|
|
110
|
+
the_headers["X-Requested-With"] = self.x_requested_with
|
|
111
|
+
|
|
112
|
+
if needs_auth:
|
|
113
|
+
the_headers["Authorization"] = f"Bearer {self.authorization_token}"
|
|
114
|
+
return the_headers
|
|
115
|
+
|
|
116
|
+
async def invoke_get(
|
|
117
|
+
self,
|
|
118
|
+
url: str,
|
|
119
|
+
headers: dict | None = None,
|
|
120
|
+
params: dict | None = None,
|
|
121
|
+
model: Type[MyExchangeApiResponse] | None = None,
|
|
122
|
+
parse_float=Decimal,
|
|
123
|
+
) -> "MyExchangeApiResponse":
|
|
124
|
+
"""
|
|
125
|
+
Invokes the specific request to the specific url with the specific params and headers.
|
|
126
|
+
"""
|
|
127
|
+
response = await self.httpx_client.get(
|
|
128
|
+
url=url,
|
|
129
|
+
headers=headers,
|
|
130
|
+
params=params,
|
|
131
|
+
)
|
|
132
|
+
return model.deserialize(response.json(parse_float=parse_float))
|
|
133
|
+
|
|
134
|
+
async def invoke_post(
|
|
135
|
+
self,
|
|
136
|
+
url: str,
|
|
137
|
+
headers: dict | None = None,
|
|
138
|
+
params: dict | None = None,
|
|
139
|
+
content: dict | str | bytes = "",
|
|
140
|
+
model: Type[MyExchangeApiResponse] | None = None,
|
|
141
|
+
parse_float=Decimal,
|
|
142
|
+
) -> "MyExchangeApiResponse":
|
|
143
|
+
"""
|
|
144
|
+
Invokes the specific request to the specific url with the specific params and headers.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
if isinstance(content, dict):
|
|
148
|
+
content = json.dumps(content, separators=(",", ":"), sort_keys=True)
|
|
149
|
+
|
|
150
|
+
response = await self.httpx_client.post(
|
|
151
|
+
url=url,
|
|
152
|
+
headers=headers,
|
|
153
|
+
params=params,
|
|
154
|
+
content=content,
|
|
155
|
+
)
|
|
156
|
+
if not model:
|
|
157
|
+
return response.json()
|
|
158
|
+
|
|
159
|
+
return model.deserialize(response.json(parse_float=parse_float))
|
|
160
|
+
|
|
161
|
+
async def aclose(self) -> None:
|
|
162
|
+
await self.httpx_client.aclose()
|
|
163
|
+
logger.info("MyExchangeClient closed")
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
def read_from_session_file(self, file_path: str) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Reads from session file; if it doesn't exist, creates it.
|
|
169
|
+
"""
|
|
170
|
+
# check if path exists
|
|
171
|
+
target_path = Path(file_path)
|
|
172
|
+
if not target_path.exists():
|
|
173
|
+
return self._save_session_file(file_path=file_path)
|
|
174
|
+
|
|
175
|
+
aes = AESCipher(key=f"bf_{self.account_name}_bf", fav_letter=self._fav_letter)
|
|
176
|
+
content = aes.decrypt(target_path.read_text()).decode("utf-8")
|
|
177
|
+
json_data: dict = json.loads(content)
|
|
178
|
+
|
|
179
|
+
self.authorization_token = json_data.get(
|
|
180
|
+
"authorization_token",
|
|
181
|
+
self.authorization_token,
|
|
182
|
+
)
|
|
183
|
+
self.timezone = json_data.get("timezone", self.timezone)
|
|
184
|
+
self.user_agent = json_data.get("user_agent", self.user_agent)
|
|
185
|
+
|
|
186
|
+
def _save_session_file(self, file_path: str) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Saves current information to the session file.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
json_data = {
|
|
192
|
+
"authorization_token": self.authorization_token,
|
|
193
|
+
"timezone": self.timezone,
|
|
194
|
+
"user_agent": self.user_agent,
|
|
195
|
+
}
|
|
196
|
+
aes = AESCipher(key=f"bf_{self.account_name}_bf", fav_letter=self._fav_letter)
|
|
197
|
+
target_path = Path(file_path)
|
|
198
|
+
target_path.write_text(aes.encrypt(json.dumps(json_data)))
|
|
199
|
+
|
|
200
|
+
# endregion
|
|
201
|
+
###########################################################
|
|
202
|
+
|
|
203
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .exchange_base import ExchangeBase
|
|
2
|
+
from .base_types import (
|
|
3
|
+
UnifiedTraderInfo,
|
|
4
|
+
UnifiedTraderPositions,
|
|
5
|
+
UnifiedPositionInfo,
|
|
6
|
+
UnifiedFuturesMarketInfo,
|
|
7
|
+
UnifiedSingleFutureMarketInfo,
|
|
8
|
+
)
|
|
9
|
+
from .binance import BinanceClient
|
|
10
|
+
from .blofin import BlofinClient
|
|
11
|
+
from .bx_ultra import BXUltraClient
|
|
12
|
+
from .hyperliquid import HyperLiquidClient
|
|
13
|
+
from .okx import OkxClient
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ExchangeBase",
|
|
18
|
+
"BXUltraClient",
|
|
19
|
+
"BinanceClient",
|
|
20
|
+
"BlofinClient",
|
|
21
|
+
"HyperLiquidClient",
|
|
22
|
+
"OkxClient",
|
|
23
|
+
"UnifiedTraderInfo",
|
|
24
|
+
"UnifiedTraderPositions",
|
|
25
|
+
"UnifiedPositionInfo",
|
|
26
|
+
"UnifiedFuturesMarketInfo",
|
|
27
|
+
"UnifiedSingleFutureMarketInfo",
|
|
28
|
+
]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
import statistics
|
|
4
|
+
|
|
5
|
+
from trd_utils.types_helper.base_model import BaseModel
|
|
6
|
+
|
|
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
|
+
# The total position size.
|
|
42
|
+
position_size: Decimal | None = None
|
|
43
|
+
|
|
44
|
+
# The initial amount of open_price_unit that the trader has put to open
|
|
45
|
+
# this position.
|
|
46
|
+
# Note that not all public APIs might provide this field.
|
|
47
|
+
initial_margin: Decimal | None = None
|
|
48
|
+
|
|
49
|
+
# The last price of this pair on the target exchange.
|
|
50
|
+
# not all exchanges support this yet, so use it with caution.
|
|
51
|
+
last_price: Decimal | None = None
|
|
52
|
+
|
|
53
|
+
# The last volume of this pair being traded on the target exchange.
|
|
54
|
+
# not all exchanges support this yet, so use it with caution.
|
|
55
|
+
last_volume: Decimal | None = None
|
|
56
|
+
|
|
57
|
+
def recalculate_pnl(self) -> tuple[Decimal, Decimal]:
|
|
58
|
+
"""
|
|
59
|
+
Recalculates the PnL based on the available data.
|
|
60
|
+
This requires `last_price`, `open_price`, `initial_margin`,
|
|
61
|
+
and `position_leverage` to be set.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The recalculated (PnL, percentage) as a Decimal, or None if calculation
|
|
65
|
+
is not possible with the current data.
|
|
66
|
+
"""
|
|
67
|
+
if not self.position_leverage:
|
|
68
|
+
self.position_leverage = 1
|
|
69
|
+
|
|
70
|
+
if not all([self.last_price, self.open_price, self.initial_margin]):
|
|
71
|
+
# Not enough data to calculate PnL.
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
price_change_percentage = (self.last_price - self.open_price) / self.open_price
|
|
75
|
+
if self.position_side == "SHORT":
|
|
76
|
+
# For a short position, profit is made when the price goes down.
|
|
77
|
+
price_change_percentage *= -1
|
|
78
|
+
|
|
79
|
+
pnl_percentage = self.position_leverage * price_change_percentage
|
|
80
|
+
# PnL = Initial Margin * Leverage * Price Change %
|
|
81
|
+
pnl = self.initial_margin * pnl_percentage
|
|
82
|
+
self.position_pnl = pnl
|
|
83
|
+
return (pnl, pnl_percentage)
|
|
84
|
+
|
|
85
|
+
def __str__(self):
|
|
86
|
+
parts = []
|
|
87
|
+
|
|
88
|
+
# Add position pair and ID
|
|
89
|
+
parts.append(
|
|
90
|
+
f"Position: {self.position_pair or 'Unknown'} (ID: {self.position_id or 'N/A'})"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Add side and leverage
|
|
94
|
+
side_str = f"Side: {self.position_side or 'Unknown'}"
|
|
95
|
+
if self.position_leverage is not None:
|
|
96
|
+
side_str += f", {self.position_leverage}x"
|
|
97
|
+
parts.append(side_str)
|
|
98
|
+
|
|
99
|
+
# Add margin mode if available
|
|
100
|
+
if self.margin_mode:
|
|
101
|
+
parts.append(f"Margin: {self.margin_mode}")
|
|
102
|
+
|
|
103
|
+
# Add open price if available
|
|
104
|
+
price_str = "Open price: "
|
|
105
|
+
if self.open_price is not None:
|
|
106
|
+
price_str += f"{self.open_price}"
|
|
107
|
+
if self.open_price_unit:
|
|
108
|
+
price_str += f" {self.open_price_unit}"
|
|
109
|
+
else:
|
|
110
|
+
price_str += "N/A"
|
|
111
|
+
parts.append(price_str)
|
|
112
|
+
|
|
113
|
+
# Add open time if available
|
|
114
|
+
if self.open_time:
|
|
115
|
+
parts.append(f"Opened: {self.open_time.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
116
|
+
|
|
117
|
+
# Add PNL if available
|
|
118
|
+
if self.position_pnl is not None:
|
|
119
|
+
parts.append(f"PNL: {self.position_pnl}")
|
|
120
|
+
|
|
121
|
+
return " | ".join(parts)
|
|
122
|
+
|
|
123
|
+
def __repr__(self):
|
|
124
|
+
return self.__str__()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class UnifiedTraderPositions(BaseModel):
|
|
128
|
+
positions: list[UnifiedPositionInfo] = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class UnifiedTraderInfo(BaseModel):
|
|
132
|
+
# Trader's id. Either int or str. In DEXes (such as HyperLiquid),
|
|
133
|
+
# this might be wallet address of the trader.
|
|
134
|
+
trader_id: int | str = None
|
|
135
|
+
|
|
136
|
+
# Name of the trader
|
|
137
|
+
trader_name: str = None
|
|
138
|
+
|
|
139
|
+
# The URL in which we can see the trader's profile
|
|
140
|
+
trader_url: str = None
|
|
141
|
+
|
|
142
|
+
# Trader's win-rate. Not all exchanges might support this field.
|
|
143
|
+
win_rate: Decimal = None
|
|
144
|
+
|
|
145
|
+
def get_win_rate_str(self) -> str:
|
|
146
|
+
return str(round(self.win_rate, 2)) if self.win_rate is not None else "N/A"
|
|
147
|
+
|
|
148
|
+
def __str__(self):
|
|
149
|
+
return (
|
|
150
|
+
f"{self.trader_name} ({self.trader_id})"
|
|
151
|
+
f" | Win Rate: {self.get_win_rate_str()}"
|
|
152
|
+
f" | Profile: {self.trader_url}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def __repr__(self):
|
|
156
|
+
return self.__str__()
|
|
157
|
+
|
|
158
|
+
class UnifiedMarketStatistics(BaseModel):
|
|
159
|
+
mean_change_24h: Decimal = None
|
|
160
|
+
stdev_change_24h: Decimal = None
|
|
161
|
+
median_volume_24h: Decimal = None
|
|
162
|
+
|
|
163
|
+
class UnifiedSingleFutureMarketInfo(BaseModel):
|
|
164
|
+
name: str = None
|
|
165
|
+
pair: str = None
|
|
166
|
+
price: Decimal = None
|
|
167
|
+
previous_day_price: Decimal = None
|
|
168
|
+
absolute_change_24h: Decimal = None
|
|
169
|
+
percentage_change_24h: Decimal = None
|
|
170
|
+
funding_rate: Decimal = None
|
|
171
|
+
daily_volume: Decimal = None
|
|
172
|
+
open_interest: Decimal = None
|
|
173
|
+
|
|
174
|
+
def __str__(self):
|
|
175
|
+
return (
|
|
176
|
+
f"{self.name} | Price: {round(self.price, 4)} "
|
|
177
|
+
f"| 24h Change: {round(self.percentage_change_24h, 4)}% "
|
|
178
|
+
f"| 24h Volume: {round(self.daily_volume, 4)} "
|
|
179
|
+
f"| Funding Rate: {round(self.funding_rate, 6)}% "
|
|
180
|
+
f"| Preferred Side: {self.get_preferred_position_side()}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def __repr__(self):
|
|
184
|
+
return self.__str__()
|
|
185
|
+
|
|
186
|
+
def get_preferred_position_side(self) -> str:
|
|
187
|
+
return "LONG" if self.funding_rate <= 0 else "SHORT"
|
|
188
|
+
|
|
189
|
+
def get_z_score_24h(self, market_stats: UnifiedMarketStatistics) -> Decimal | None:
|
|
190
|
+
if not market_stats.stdev_change_24h:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
z_score = (
|
|
194
|
+
self.percentage_change_24h - market_stats.mean_change_24h
|
|
195
|
+
) / market_stats.stdev_change_24h
|
|
196
|
+
return z_score
|
|
197
|
+
|
|
198
|
+
class UnifiedFuturesMarketInfo(BaseModel):
|
|
199
|
+
sorted_markets: list[UnifiedSingleFutureMarketInfo] = None
|
|
200
|
+
|
|
201
|
+
def __str__(self):
|
|
202
|
+
return f"Total Markets: {len(self.sorted_markets) if self.sorted_markets else 0}"
|
|
203
|
+
|
|
204
|
+
def __repr__(self):
|
|
205
|
+
return self.__str__()
|
|
206
|
+
|
|
207
|
+
def find_market_by_name(
|
|
208
|
+
self,
|
|
209
|
+
name: str,
|
|
210
|
+
) -> UnifiedSingleFutureMarketInfo | None:
|
|
211
|
+
if not self.sorted_markets:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
for market in self.sorted_markets:
|
|
215
|
+
if market.name.lower() == name.lower():
|
|
216
|
+
return market
|
|
217
|
+
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def get_statistics(self) -> UnifiedMarketStatistics:
|
|
221
|
+
changes_24h = [m.percentage_change_24h for m in self.sorted_markets]
|
|
222
|
+
volumes_24h = [m.daily_volume for m in self.sorted_markets]
|
|
223
|
+
|
|
224
|
+
s_obj = UnifiedMarketStatistics()
|
|
225
|
+
s_obj.mean_change_24h = statistics.mean(changes_24h)
|
|
226
|
+
s_obj.stdev_change_24h = statistics.stdev(changes_24h)
|
|
227
|
+
s_obj.median_volume_24h = statistics.median(volumes_24h)
|
|
228
|
+
|
|
229
|
+
return s_obj
|